1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-04-26 07:05:21 -04:00

Migrate to app router (#2254)

This commit is contained in:
Jake Jarvis 2025-02-07 11:33:38 -05:00 committed by GitHub
parent e97613dda5
commit 8aabb4a66f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
179 changed files with 4095 additions and 4951 deletions

View File

@ -1,29 +1,13 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/devcontainers/images/tree/main/src/base-ubuntu
{
"name": "Ubuntu",
"image": "mcr.microsoft.com/vscode/devcontainers/base:ubuntu-22.04",
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"upgradePackages": true,
"configureZshAsDefaultShell": true
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/sshd:1": {},
"ghcr.io/jakejarvis/devcontainer-features/volta": {}
},
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/common-utils"
],
"postCreateCommand": "pnpm install",
"forwardPorts": [
3000
],
"name": "Node.js & TypeScript",
"image": "mcr.microsoft.com/devcontainers/typescript-node:22-bookworm",
"postCreateCommand": "bash -i -c 'rm -rf node_modules && nvm install $(cat .node-version) -y && nvm use $(cat .node-version) && npm install -g corepack@latest && corepack enable && CI=true pnpm install'",
"customizations": {
"vscode": {
"settings": {
"typescript.tsdk": "node_modules/typescript/lib",
"telemetry.enableTelemetry": false
},
"extensions": [
"EditorConfig.EditorConfig",
"dbaeumer.vscode-eslint",
@ -33,6 +17,9 @@
]
}
},
"forwardPorts": [
3000
],
"portsAttributes": {
"3000": {
"label": "next dev",

View File

@ -31,8 +31,8 @@ updates:
eslint:
patterns:
- "eslint"
- "@typescript-eslint/eslint-plugin"
- "@typescript-eslint/parser"
- "@eslint/eslintrc"
- "@eslint/js"
ignore:
- dependency-name: "@jakejarvis/eslint-config"
- dependency-name: "@types/node"

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
# node/npm/pnpm/yarn
node_modules/
.pnpm-store/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -1 +1 @@
20.18.1
22.13.1

View File

@ -1,7 +1,7 @@
/**
* @type {import("prettier").Config}
*/
module.exports = {
const config = {
singleQuote: false,
jsxSingleQuote: false,
printWidth: 120,
@ -10,3 +10,5 @@ module.exports = {
quoteProps: "as-needed",
trailingComma: "es5",
};
export default config;

View File

@ -1,5 +1,6 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"dbaeumer.vscode-eslint",
"prisma.prisma",
"unifiedjs.vscode-mdx"

View File

@ -1,10 +1,7 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 2,
"eslint.options": {
"extensions": [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]
},
"eslint.runtime": "node", // https://github.com/mdx-js/vscode-mdx#known-vscode-eslint-issues
"eslint.runtime": "node",
"eslint.validate": [
"javascript",
"javascriptreact",
@ -16,10 +13,6 @@
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"search.exclude": {
"**/.next": true,
"**/node_modules": true
},
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@ -1,35 +1,32 @@
import nodemailer from "nodemailer";
import fetcher from "../../lib/helpers/fetcher";
import config from "../../lib/config";
import type { NextApiHandler } from "next";
import fetcher from "../../../lib/helpers/fetcher";
import config from "../../../lib/config";
import { headers } from "next/headers";
import { NextResponse, NextRequest } from "next/server";
const handler: NextApiHandler<
{
export async function POST(req: NextRequest): Promise<
NextResponse<{
success?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error?: any;
} | null
> = async (req, res) => {
// only allow POST requests, otherwise return a 405 Method Not Allowed
if (req.method !== "POST") {
return res.status(405).send(null);
}
} | null>
> {
try {
// possible weirdness? https://github.com/orgs/vercel/discussions/78#discussioncomment-5089059
const data = req.body;
const data = await req.formData();
const headersList = await headers();
// these are both backups to client-side validations just in case someone squeezes through without them. the codes
// are identical so they're caught in the same fashion.
if (!data.name || !data.email || !data.message) {
if (!data.get("name") || !data.get("email") || !data.get("message")) {
// all fields are required
throw new Error("missing_data");
}
if (
!data["cf-turnstile-response"] ||
!data.get("cf-turnstile-response") ||
!(await validateCaptcha(
data["cf-turnstile-response"],
(req.headers["x-forwarded-for"] as string) || (req.headers["x-real-ip"] as string) || ""
data.get("cf-turnstile-response"),
headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || ""
))
) {
// either the captcha is wrong or completely missing
@ -41,19 +38,15 @@ const handler: NextApiHandler<
throw new Error("nodemailer_error");
}
// disable caching on both ends. see:
// https://vercel.com/docs/concepts/functions/edge-functions/edge-caching
res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate");
// success! let the client know
return res.status(201).json({ success: true });
return NextResponse.json({ success: true }, { status: 201 });
} catch (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any
) {
return res.status(400).json({ error: error.message ?? "Bad request." });
return NextResponse.json({ error: error.message ?? "Bad request." }, { status: 400 });
}
};
}
const validateCaptcha = async (formResponse: unknown, ip: string): Promise<unknown> => {
const response = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
@ -72,7 +65,7 @@ const validateCaptcha = async (formResponse: unknown, ip: string): Promise<unkno
return response?.success;
};
const sendMessage = async (data: Record<string, unknown>): Promise<boolean> => {
const sendMessage = async (data: FormData): Promise<boolean> => {
try {
const transporter = nodemailer.createTransport({
// https://resend.com/docs/send-with-nodemailer-smtp
@ -86,13 +79,13 @@ const sendMessage = async (data: Record<string, unknown>): Promise<boolean> => {
});
await transporter.sendMail({
from: `${data.name} <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
from: `${data.get("name")} <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
sender: `nodemailer <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
replyTo: `${data.name} <${data.email}>`,
replyTo: `${data.get("name")} <${data.get("email")}>`,
to: `<${config.authorEmail}>`,
subject: `[${config.siteDomain}] Contact Form Submission`,
// TODO: add markdown parsing as promised on the form.
text: `${data.message}`,
text: `${data.get("message")}`,
});
} catch (error) {
console.error(error);
@ -101,5 +94,3 @@ const sendMessage = async (data: Record<string, unknown>): Promise<boolean> => {
return true;
};
export default handler;

31
app/api/count/route.ts Normal file
View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/helpers/prisma";
import type { PageStats } from "../../../types";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET(req: NextRequest): Promise<NextResponse<PageStats>> {
const slug = req.nextUrl.searchParams.get("slug");
// extremely basic input validation.
// TODO: actually check if the note exists before continuing (and allow pages other than notes).
if (typeof slug !== "string" || !new RegExp(/^notes\/([A-Za-z0-9-]+)$/i).test(slug)) {
// @ts-expect-error
return NextResponse.json({ error: "Missing or invalid 'slug' parameter." }, { status: 400 });
}
// +1 hit!
const { hits } = await prisma.hits.upsert({
where: { slug },
create: { slug },
update: {
hits: {
increment: 1,
},
},
});
// add one to this page's count and return the new number
return NextResponse.json({ hits });
}

26
app/api/hits/route.ts Normal file
View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/helpers/prisma";
import type { SiteStats } from "../../../types";
export const revalidate = 900; // 15 mins
export async function GET(): Promise<NextResponse<SiteStats>> {
// fetch all rows from db sorted by most hits
const pages = await prisma.hits.findMany({
orderBy: [
{
hits: "desc",
},
],
});
const total = { hits: 0 };
// calculate total hits
pages.forEach((page) => {
// add these hits to running tally
total.hits += page.hits;
});
return NextResponse.json({ total, pages });
}

BIN
app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

40
app/birthday/page.tsx Normal file
View File

@ -0,0 +1,40 @@
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Video from "../../components/Video";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
import thumbnail from "../../public/static/images/birthday/thumb.png";
export const metadata: Metadata = {
title: "🎉 Cranky Birthday Boy on VHS Tape 📼",
description: "The origin of my hatred for the Happy Birthday song.",
openGraph: {
...defaultMetadata.openGraph,
title: "🎉 Cranky Birthday Boy on VHS Tape 📼",
images: [thumbnail.src],
url: "/birthday",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/birthday",
},
};
export default function Page() {
return (
<>
<PageTitle>📼 1996.MOV</PageTitle>
<Content>
<Video
src={{
webm: "/static/images/birthday/birthday.webm",
mp4: "/static/images/birthday/birthday.mp4",
image: thumbnail.src,
}}
/>
</Content>
</>
);
}

View File

@ -1,26 +1,34 @@
import { NextSeo } from "next-seo";
import Content from "../components/Content";
import PageTitle from "../components/PageTitle";
import Link from "../components/Link";
import Image from "../components/Image";
import Blockquote from "../components/Blockquote";
import CodeBlock from "../components/CodeBlock";
import { H2 } from "../components/Heading";
import { UnorderedList, ListItem } from "../components/List";
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import Image from "../../components/Image";
import Blockquote from "../../components/Blockquote";
import CodeBlock from "../../components/CodeBlock";
import { H2 } from "../../components/Heading";
import { UnorderedList, ListItem } from "../../components/List";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
import cliImg from "../public/static/images/cli/screenshot.png";
import cliImg from "../../public/static/images/cli/screenshot.png";
const CLI = () => {
export const metadata: Metadata = {
title: "CLI",
description: "AKA, the most useless Node module ever published, in history, by anyone, ever.",
openGraph: {
...defaultMetadata.openGraph,
title: "CLI",
images: [cliImg.src],
url: "/cli",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/cli",
},
};
export default function Page() {
return (
<>
<NextSeo
title="CLI"
description="AKA, the most useless Node module ever published, in history, by anyone, ever."
openGraph={{
title: "CLI",
}}
/>
<PageTitle>🤖 CLI</PageTitle>
<Content>
@ -64,6 +72,4 @@ const CLI = () => {
</Content>
</>
);
};
export default CLI;
}

View File

@ -0,0 +1,95 @@
.input {
width: 100%;
padding: 0.8em;
margin: 0.6em 0;
border: 2px solid var(--colors-light);
border-radius: var(--radii-corner);
color: var(--colors-text);
background-color: var(--colors-superDuperLight);
transition: background var(--transitions-fade);
}
.input:focus {
outline: none;
border-color: var(--colors-link);
}
.input.missing {
border-color: var(--colors-error);
}
.input.textarea {
margin-bottom: 0;
line-height: 1.5;
min-height: 10em;
resize: vertical;
}
.markdownTip {
font-size: 0.825em;
line-height: 1.75;
}
.markdownIcon {
display: inline;
width: 1.25em;
height: 1.25em;
vertical-align: -0.25em;
margin-right: 0.25em;
}
.captcha {
margin: 1em 0;
}
.actionRow {
display: flex;
align-items: center;
min-height: 3.75em;
}
.submitButton {
flex-shrink: 0;
height: 3.25em;
padding: 1em 1.25em;
margin-right: 1.5em;
border: 0;
border-radius: var(--radii-corner);
cursor: pointer;
user-select: none;
font-weight: 500;
color: var(--colors-text);
background-color: var(--colors-kindaLight);
}
.submitButton:hover,
.submitButton:focus-visible {
color: var(--colors-superDuperLight);
background-color: var(--colors-link);
}
.submitIcon {
font-size: 1.3em;
margin-right: 0.3em;
line-height: 1;
}
.result {
font-weight: 600;
line-height: 1.5;
}
.result.success {
color: var(--colors-success);
}
.result.error {
color: var(--colors-error);
}
.resultIcon {
display: inline;
width: 1.3em;
height: 1.3em;
vertical-align: -0.3em;
fill: currentColor;
}

View File

@ -1,135 +1,17 @@
"use client";
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 Turnstile from "react-turnstile";
import clsx from "clsx";
import Link from "../../components/Link";
import useTheme from "../../hooks/useTheme";
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",
});
import styles from "./form.module.css";
type FormValues = {
name: string;
@ -143,6 +25,8 @@ export type ContactFormProps = {
};
const ContactForm = ({ className }: ContactFormProps) => {
const { activeTheme } = useTheme();
// status/feedback:
const [submitted, setSubmitted] = useState(false);
const [success, setSuccess] = useState(false);
@ -152,14 +36,19 @@ const ContactForm = ({ className }: ContactFormProps) => {
// once a user attempts a submission, this is true and stays true whether or not the next attempt(s) are successful
setSubmitted(true);
// https://stackoverflow.com/a/68372184
const formData = new FormData();
for (const key in values) {
formData.append(key, values[key as keyof FormValues]);
}
// 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),
body: formData,
})
.then((response) => response.json())
.then((data) => {
@ -222,11 +111,11 @@ const ContactForm = ({ className }: ContactFormProps) => {
<Form className={className} name="contact">
<Field name="name">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<Input
<input
type="text"
placeholder="Name"
disabled={success}
missing={!!(meta.error && meta.touched)}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
)}
@ -234,12 +123,12 @@ const ContactForm = ({ className }: ContactFormProps) => {
<Field name="email">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<Input
<input
type="email"
inputMode="email"
placeholder="Email"
disabled={success}
missing={!!(meta.error && meta.touched)}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
)}
@ -247,19 +136,19 @@ const ContactForm = ({ className }: ContactFormProps) => {
<Field name="message">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<TextArea
<TextareaAutosize
placeholder="Write something..."
minRows={5}
disabled={success}
missing={!!(meta.error && meta.touched)}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
)}
</Field>
<MarkdownTip>
<MarkdownTipIcon /> Basic{" "}
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" css={{ fontWeight: 600 }}>
<div className={styles.markdownTip}>
<SiMarkdown className={styles.markdownIcon} /> Basic{" "}
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" style={{ fontWeight: 600 }}>
Markdown syntax
</Link>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
@ -267,32 +156,41 @@ const ContactForm = ({ className }: ContactFormProps) => {
links
</Link>
](https://jarv.is), and <code>`code`</code>.
</MarkdownTip>
</div>
<Turnstile onVerify={(token) => setFieldValue("cf-turnstile-response", token)} />
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
onVerify={(token) => setFieldValue("cf-turnstile-response", token)}
className={styles.captcha}
theme={activeTheme === "dark" ? activeTheme : "light"}
/>
<ActionRow>
<SubmitButton
<div className={styles.actionRow}>
<button
type="submit"
title="Send Message"
aria-label="Send Message"
onClick={() => setSubmitted(true)}
disabled={isSubmitting}
hidden={success}
className={styles.submitButton}
style={{ display: success ? "none" : "inline-flex" }}
>
{isSubmitting ? (
<span>Sending...</span>
) : (
<>
<SubmitIcon>📤</SubmitIcon> <span>Send</span>
<span className={styles.submitIcon}>📤</span> <span>Send</span>
</>
)}
</SubmitButton>
</button>
<Result status={success ? "success" : "error"} hidden={!submitted || !feedback || isSubmitting}>
<ResultIcon as={success ? GoCheck : GoX} /> {feedback}
</Result>
</ActionRow>
<div
className={clsx(styles.result, success ? styles.success : styles.error)}
style={{ display: submitted && feedback && !isSubmitting ? "block" : "none" }}
>
{success ? <GoCheck className={styles.resultIcon} /> : <GoX className={styles.resultIcon} />} {feedback}
</div>
</div>
</Form>
)}
</Formik>

52
app/contact/page.tsx Normal file
View File

@ -0,0 +1,52 @@
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import ContactForm from "./form";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata, Route } from "next";
export const metadata: Metadata = {
title: "Contact Me",
openGraph: {
...defaultMetadata.openGraph,
title: "Contact Me",
url: "/contact",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/contact",
},
};
export default function Page() {
return (
<>
<PageTitle>📬 Contact Me</PageTitle>
<Content
style={{
maxWidth: "600px",
margin: "0 auto",
}}
>
<p>
Fill out this quick form and I'll get back to you as soon as I can! You can also{" "}
<Link href="mailto:jake@jarv.is">email me directly</Link>, send me a{" "}
<Link href="https://fediverse.jarv.is/@jake">direct message on Mastodon</Link>, or{" "}
<Link href="sms:+1-617-917-3737">text me</Link>.
</p>
<p>
🔐 You can grab my public key here:{" "}
<Link href={"/pubkey.asc" as Route} title="My Public PGP Key" rel="pgpkey authn" openInNewTab>
<code style={{ fontSize: "0.925em", letterSpacing: "0.075em", wordSpacing: "-0.3em" }}>
6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39
</code>
</Link>
.
</p>
<ContactForm />
</Content>
</>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

11
app/feed.atom/route.ts Normal file
View File

@ -0,0 +1,11 @@
import { buildFeed } from "../../lib/helpers/build-feed";
export const dynamic = "force-static";
export const GET = async () => {
return new Response(await buildFeed({ type: "atom" }), {
headers: {
"content-type": "application/atom+xml; charset=utf-8",
},
});
};

11
app/feed.xml/route.ts Normal file
View File

@ -0,0 +1,11 @@
import { buildFeed } from "../../lib/helpers/build-feed";
export const dynamic = "force-static";
export const GET = async () => {
return new Response(await buildFeed({ type: "rss" }), {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},
});
};

21
app/global.css Normal file
View File

@ -0,0 +1,21 @@
body {
font-family: var(--fonts-sans);
background-color: var(--colors-backgroundInner);
transition: background var(--transitions-fade);
}
code,
kbd,
samp,
pre {
font-family: var(--fonts-mono);
}
/* https://css-tricks.com/almanac/rules/m/media/prefers-reduced-motion/ */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}

66
app/hillary/page.tsx Normal file
View File

@ -0,0 +1,66 @@
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import Video from "../../components/Video";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
import thumbnail from "../../public/static/images/hillary/thumb.png";
export const metadata: Metadata = {
title: "My Brief Apperance in Hillary Clinton's DNC Video",
description: "My brief apperance in one of Hillary Clinton's 2016 DNC convention videos on substance abuse.",
openGraph: {
...defaultMetadata.openGraph,
title: "My Brief Apperance in Hillary Clinton's DNC Video",
images: [thumbnail.src],
url: "/hillary",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/hillary",
},
};
export default function Page() {
return (
<>
<PageTitle>My Brief Apperance in Hillary Clinton's DNC Video</PageTitle>
<Content>
<Video
src={{
webm: "/static/images/hillary/convention-720p.webm",
mp4: "/static/images/hillary/convention-720p.mp4",
vtt: "/static/images/hillary/subs.en.vtt",
image: thumbnail.src,
}}
/>
<p
style={{
textAlign: "center",
fontSize: "0.9em",
lineHeight: 1.8,
margin: "1.25em 1em 0 1em",
color: "var(--colors-mediumLight)",
}}
>
Video is property of{" "}
<Link href="https://www.hillaryclinton.com/" style={{ fontWeight: 700 }}>
Hillary for America
</Link>
, the{" "}
<Link href="https://democrats.org/" style={{ fontWeight: 700 }}>
Democratic National Committee
</Link>
, and{" "}
<Link href="https://cnnpressroom.blogs.cnn.com/" style={{ fontWeight: 700 }}>
CNN / WarnerMedia
</Link>
. &copy; 2016.
</p>
</Content>
</>
);
}

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

95
app/layout.tsx Normal file
View File

@ -0,0 +1,95 @@
import clsx from "clsx";
import { Analytics } from "@vercel/analytics/react";
import { ThemeProvider } from "../contexts/ThemeContext";
import Layout from "../components/Layout";
import config from "../lib/config";
import type { Metadata } from "next";
import type { Person, WithContext } from "schema-dts";
import { GeistMono, GeistSans } from "../lib/styles/fonts";
import "modern-normalize/modern-normalize.css"; // https://github.com/sindresorhus/modern-normalize/blob/main/modern-normalize.css
import "./themes.css";
import "./global.css";
import { meJpg } from "../lib/config/favicons";
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || `https://${config.siteDomain}`),
title: {
template: `%s ${config.siteName}`,
default: `${config.siteName} ${config.shortDescription}`,
},
description: config.longDescription,
openGraph: {
siteName: config.siteName,
title: {
template: `%s ${config.siteName}`,
default: `${config.siteName} ${config.shortDescription}`,
},
url: "/",
locale: config.siteLocale?.replace("-", "_"),
type: "website",
images: [
{
url: meJpg.src,
alt: `${config.siteName} ${config.shortDescription}`,
},
],
},
alternates: {
types: {
"application/rss+xml": "/feed.xml",
"application/atom+xml": "/feed.atom",
},
canonical: "/",
},
other: {
humans: "/humans.txt",
},
};
// https://nextjs.org/docs/app/building-your-application/optimizing/metadata#json-ld
const jsonLd: WithContext<Person> = {
"@context": "https://schema.org",
"@type": "Person",
name: config.authorName,
url: metadata.metadataBase?.href || `https://${config.siteDomain}/`,
image: new URL(meJpg.src, metadata.metadataBase || `https://${config.siteDomain}`).href,
sameAs: [
metadata.metadataBase?.href || `https://${config.siteDomain}/`,
`https://github.com/${config.authorSocial?.github}`,
`https://keybase.io/${config.authorSocial?.keybase}`,
`https://twitter.com/${config.authorSocial?.twitter}`,
`https://medium.com/@${config.authorSocial?.medium}`,
`https://www.linkedin.com/in/${config.authorSocial?.linkedin}/`,
`https://www.facebook.com/${config.authorSocial?.facebook}`,
`https://www.instagram.com/${config.authorSocial?.instagram}/`,
`https://${config.authorSocial?.mastodon}`,
`https://bsky.app/profile/${config.authorSocial?.bluesky}`,
],
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang={config.siteLocale} suppressHydrationWarning>
<head>
<script
// unminified: https://gist.github.com/jakejarvis/79b0ec8506bc843023546d0d29861bf0
dangerouslySetInnerHTML={{
__html: `(()=>{try{const e=document.documentElement,t="undefined"!=typeof Storage?window.localStorage.getItem("theme"):null,a=(t&&"dark"===t)??window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";e.dataset.theme=a,e.style.colorScheme=a}catch(e){}})()`,
}}
/>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
</head>
<body className={clsx(GeistMono.variable, GeistSans.variable)}>
<ThemeProvider>
<Layout>{children}</Layout>
</ThemeProvider>
<Analytics />
</body>
</html>
);
}

62
app/leo/page.tsx Normal file
View File

@ -0,0 +1,62 @@
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import Video from "../../components/Video";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
import thumbnail from "../../public/static/images/leo/thumb.png";
export const metadata: Metadata = {
title: 'Facebook App on "The Lab with Leo Laporte"',
description: "Powncer app featured in Leo Laporte's TechTV show.",
openGraph: {
...defaultMetadata.openGraph,
title: 'Facebook App on "The Lab with Leo Laporte"',
images: [thumbnail.src],
url: "/leo",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/leo",
},
};
export default function Page() {
return (
<>
<PageTitle>Facebook App on "The Lab with Leo Laporte"</PageTitle>
<Content>
<Video
src={{
webm: "/static/images/leo/leo.webm",
mp4: "/static/images/leo/leo.mp4",
vtt: "/static/images/leo/subs.en.vtt",
image: thumbnail.src,
}}
/>
<p
style={{
textAlign: "center",
fontSize: "0.9em",
lineHeight: 1.8,
margin: "1.25em 1em 0 1em",
color: "var(--colors-mediumLight)",
}}
>
Video is property of{" "}
<Link href="https://web.archive.org/web/20070511004304/www.g4techtv.ca" style={{ fontWeight: 700 }}>
G4techTV Canada
</Link>{" "}
&amp;{" "}
<Link href="https://leolaporte.com/" style={{ fontWeight: 700 }}>
Leo Laporte
</Link>
. &copy; 2007 G4 Media, Inc.
</p>
</Content>
</>
);
}

View File

@ -1,22 +1,29 @@
import { NextSeo } from "next-seo";
import Content from "../components/Content";
import PageTitle from "../components/PageTitle";
import Link from "../components/Link";
import HorizontalRule from "../components/HorizontalRule";
import Blockquote from "../components/Blockquote";
import { H2, H3 } from "../components/Heading";
import { UnorderedList, OrderedList, ListItem } from "../components/List";
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import HorizontalRule from "../../components/HorizontalRule";
import Blockquote from "../../components/Blockquote";
import { H2, H3 } from "../../components/Heading";
import { UnorderedList, OrderedList, ListItem } from "../../components/List";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
const License = () => {
export const metadata: Metadata = {
title: "License",
openGraph: {
...defaultMetadata.openGraph,
title: "License",
url: "/license",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/license",
},
};
export default function Page() {
return (
<>
<NextSeo
title="License"
openGraph={{
title: "License",
}}
/>
<PageTitle>📜 License</PageTitle>
<Content>
@ -471,6 +478,4 @@ const License = () => {
</Content>
</>
);
};
export default License;
}

View File

@ -1,16 +1,9 @@
import config from "../lib/config";
import { chrome512Png, chrome192Png, maskable512Png, maskable192Png } from "../lib/config/favicons";
import type { GetServerSideProps } from "next";
import type { MetadataRoute } from "next";
export const getServerSideProps: GetServerSideProps<Record<string, never>> = async (context) => {
const { res } = context;
// https://developer.mozilla.org/en-US/docs/Web/Manifest#deploying_a_manifest
res.setHeader("content-type", "application/manifest+json; charset=utf-8");
// cache on edge for one week
res.setHeader("cache-control", "public, max-age=0, s-maxage=604800, stale-while-revalidate");
const manifest = {
const manifest = (): MetadataRoute.Manifest => {
return {
name: config.siteName,
short_name: config.siteDomain,
description: config.longDescription,
@ -44,14 +37,6 @@ export const getServerSideProps: GetServerSideProps<Record<string, never>> = asy
display: "browser",
start_url: "/",
};
res.write(JSON.stringify(manifest));
res.end();
return {
props: {},
};
};
// eslint-disable-next-line import/no-anonymous-default-export
export default () => null;
export default manifest;

29
app/not-found.tsx Normal file
View File

@ -0,0 +1,29 @@
import Link from "../components/Link";
import Video from "../components/Video";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "404 Not Found",
};
export default async function Page() {
return (
<div style={{ textAlign: "center" }}>
<Video
src={{
webm: "/static/images/angry-panda.webm",
mp4: "/static/images/angry-panda.mp4",
}}
autoplay
responsive={false}
style={{
maxWidth: "400px",
}}
/>
<h1>404: Page Not Found 😢</h1>
<Link href="/">Go home?</Link>
</div>
);
}

View File

@ -0,0 +1,67 @@
.meta {
display: inline-flex;
flex-wrap: wrap;
font-size: 0.825em;
line-height: 2.3;
letter-spacing: 0.04em;
color: var(--colors-medium);
}
.meta .item {
margin-right: 1.6em;
white-space: nowrap;
}
.meta .link {
color: inherit !important;
}
.meta .icon {
display: inline;
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
margin-right: 0.6em;
}
.meta .tags {
white-space: normal;
display: inline-flex;
flex-wrap: wrap;
}
.meta .tag {
text-transform: lowercase;
white-space: nowrap;
margin-right: 0.75em;
}
.meta .tag:before {
content: "\0023"; /* cosmetically hashtagify tags */
padding-right: 0.125em;
color: var(--colors-light);
}
.meta .tag:last-of-type {
margin-right: 0;
}
.title {
margin: 0.3em 0 0.5em -1px; /* misaligned left margin, super nitpicky */
font-size: 2.1em;
line-height: 1.3;
font-weight: 700;
}
.title code {
margin: 0 0.075em;
}
.title .link {
color: var(--colors-text) !important;
}
@media (max-width: 768px) {
.title {
font-size: 1.8em;
}
}

178
app/notes/[slug]/page.tsx Normal file
View File

@ -0,0 +1,178 @@
import * as runtime from "react/jsx-runtime";
import { ErrorBoundary } from "react-error-boundary";
import { evaluate } from "@mdx-js/mdx";
import Content from "../../../components/Content";
import Link from "../../../components/Link";
import Time from "../../../components/Time";
import HitCounter from "../../../components/HitCounter";
import Comments from "../../../components/Comments";
import { getPostSlugs, getPostData } from "../../../lib/helpers/posts";
import * as mdxComponents from "../../../lib/helpers/mdx-components";
import { metadata as defaultMetadata } from "../../layout";
import config from "../../../lib/config";
import { FiCalendar, FiTag, FiEdit, FiEye } from "react-icons/fi";
import type { Metadata, Route } from "next";
import type { Article, WithContext } from "schema-dts";
import styles from "./page.module.css";
// https://nextjs.org/docs/app/api-reference/functions/generate-static-params#disable-rendering-for-unspecified-paths
export const dynamicParams = false;
export async function generateStaticParams() {
const slugs = await getPostSlugs();
// map slugs into a static paths object required by next.js
return slugs.map((slug: string) => ({
slug,
}));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const { frontMatter } = await getPostData(slug);
return {
title: frontMatter.title,
description: frontMatter.description,
openGraph: {
...defaultMetadata.openGraph,
title: frontMatter.title,
url: `/notes/${slug}`,
type: "article",
authors: [config.authorName],
tags: frontMatter.tags,
publishedTime: frontMatter.date,
modifiedTime: frontMatter.date,
images: frontMatter.image
? [{ url: frontMatter.image, alt: frontMatter.title }]
: defaultMetadata.openGraph?.images,
},
alternates: {
...defaultMetadata.alternates,
canonical: `/notes/${slug}`,
},
};
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { frontMatter, markdown } = await getPostData(slug);
const jsonLd: WithContext<Article> = {
"@context": "https://schema.org",
"@type": "Article",
name: frontMatter.title,
description: frontMatter.description || config.longDescription,
url: frontMatter.permalink,
image: frontMatter.image,
datePublished: frontMatter.date,
dateModified: frontMatter.date,
author: {
"@type": "Person",
name: config.authorName,
url: defaultMetadata.metadataBase?.href || `https://${config.siteDomain}`,
},
};
const { remarkGfm, remarkSmartypants, rehypeSlug, rehypeUnwrapImages, rehypePrism } = await import(
"../../../lib/helpers/remark-rehype-plugins"
);
const { default: MDXContent } = await evaluate(markdown, {
...runtime,
remarkPlugins: [
[remarkGfm, { singleTilde: false }],
[
remarkSmartypants,
{
quotes: true,
dashes: "oldschool",
backticks: false,
ellipses: false,
},
],
],
rehypePlugins: [rehypeSlug, rehypeUnwrapImages, [rehypePrism, { ignoreMissing: true }]],
});
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<div className={styles.meta}>
<div className={styles.item}>
<Link href={`/notes/${frontMatter.slug}` as Route} underline={false} className={styles.link}>
<FiCalendar className={styles.icon} />
<Time date={frontMatter.date} format="MMMM D, YYYY" />
</Link>
</div>
{frontMatter.tags && (
<div className={styles.item}>
<FiTag className={styles.icon} />
<span className={styles.tags}>
{frontMatter.tags.map((tag) => (
<span key={tag} title={tag} className={styles.tag} aria-label={`Tagged with ${tag}`}>
{tag}
</span>
))}
</span>
</div>
)}
<div className={styles.item}>
<Link
href={`https://github.com/${config.githubRepo}/blob/main/notes/${frontMatter.slug}.mdx`}
title={`Edit "${frontMatter.title}" on GitHub`}
underline={false}
className={styles.link}
>
<FiEdit className={styles.icon} />
<span>Improve This Post</span>
</Link>
</div>
{/* only count hits on production site */}
{process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
<div
className={styles.item}
style={{
// 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}>
<FiEye className={styles.icon} />
<HitCounter slug={`notes/${frontMatter.slug}`} />
</ErrorBoundary>
</div>
)}
</div>
<h1 className={styles.title}>
<Link
href={`/notes/${frontMatter.slug}` as Route}
dangerouslySetInnerHTML={{ __html: frontMatter.htmlTitle || frontMatter.title }}
underline={false}
className={styles.link}
/>
</h1>
<Content>
<MDXContent
// @ts-ignore
components={{ ...mdxComponents }}
/>
</Content>
{!frontMatter.noComments && (
<div id="comments">
<Comments title={frontMatter.title} />
</div>
)}
</>
);
}

51
app/notes/page.module.css Normal file
View File

@ -0,0 +1,51 @@
.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;
font-weight: 700;
margin-top: 0;
margin-bottom: 0.5em;
}
.list {
list-style-type: none;
margin: 0;
padding: 0;
}
.post {
display: flex;
line-height: 1.75;
margin-bottom: 1em;
}
.post:last-of-type {
margin-bottom: 0;
}
.postDate {
width: 5.25em;
flex-shrink: 0;
color: var(--colors-medium);
}
@media (max-width: 768px) {
.section {
margin: 1.8em 0;
}
.year {
font-size: 2em;
}
}

65
app/notes/page.tsx Normal file
View File

@ -0,0 +1,65 @@
import Content from "../../components/Content";
import Link from "../../components/Link";
import Time from "../../components/Time";
import { getAllPosts } from "../../lib/helpers/posts";
import config from "../../lib/config";
import { metadata as defaultMetadata } from "../layout";
import type { ReactElement } from "react";
import type { Metadata, Route } from "next";
import type { PostsByYear } from "../../types";
import styles from "./page.module.css";
export const metadata: Metadata = {
title: "Notes",
description: `Recent posts by ${config.authorName}.`,
openGraph: {
...defaultMetadata.openGraph,
title: "Notes",
url: "/notes",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/notes",
},
};
export default async function Page() {
// parse the year of each note and group them together
const notes = await getAllPosts();
const notesByYear: PostsByYear = {};
notes.forEach((note) => {
const year = new Date(note.date).getUTCFullYear();
(notesByYear[year] || (notesByYear[year] = [])).push(note);
});
const sections: ReactElement[] = [];
Object.entries(notesByYear).forEach(([year, posts]) => {
sections.push(
<section className={styles.section} key={year}>
<h2 className={styles.year}>{year}</h2>
<ul className={styles.list}>
{posts.map(({ slug, date, title, htmlTitle }) => (
<li className={styles.post} key={slug}>
<Time date={date} format="MMM D" className={styles.postDate} />
<span>
<Link href={`/notes/${slug}` as Route} dangerouslySetInnerHTML={{ __html: htmlTitle || title }} />
</span>
</li>
))}
</ul>
</section>
);
});
// grouped posts enter this component ordered chronologically -- we want reverse chronological
const reversed = sections.reverse();
return (
<>
<Content>{reversed}</Content>
</>
);
}

100
app/page.module.css Normal file
View File

@ -0,0 +1,100 @@
.page h1 {
margin: 0 0 0.5em -1px; /* misaligned left margin, super nitpicky */
font-size: 1.75em;
font-weight: 500;
line-height: 1.1;
color: var(--colors-text);
}
.page h2 {
margin: 0.5em 0 0.5em -1px;
font-size: 1.2em;
font-weight: 400;
line-height: 1.4;
color: var(--colors-text);
}
.page p {
margin: 0.85em 0;
font-size: 0.95em;
line-height: 1.7;
color: var(--colors-text);
}
.page p:last-of-type {
margin-bottom: 0;
}
.page sup {
margin: 0 0.1em;
font-size: 0.6em;
}
.pgpIcon {
vertical-align: -0.25em;
stroke-width: 0.5;
}
.pgpKey {
margin: 0 0.15em;
font-family: var(--fonts-mono);
letter-spacing: 0.075em;
word-spacing: -0.4em;
}
.wave {
display: inline-block;
margin-left: 0.1em;
font-size: 1.2em;
}
@media (prefers-reduced-motion: no-preference) {
.wave {
animation: wave 5s ease 1s infinite;
transform-origin: 65% 80%;
will-change: transform;
}
@keyframes wave {
0% {
transform: rotate(0deg);
}
5% {
transform: rotate(14deg);
}
10% {
transform: rotate(-8deg);
}
15% {
transform: rotate(14deg);
}
20% {
transform: rotate(-4deg);
}
25% {
transform: rotate(10deg);
}
30% {
transform: rotate(0deg);
}
/* pause for ~9 out of 10 seconds */
100% {
transform: rotate(0deg);
}
}
}
@media (max-width: 768px) {
.h1 {
font-size: 1.6em;
}
.h2 {
font-size: 1.25em;
}
.paragraph {
font-size: 0.925em;
line-height: 1.825;
}
}

View File

@ -1,125 +1,42 @@
import Link from "../components/Link";
import { useId } from "react";
import { GoLock } from "react-icons/go";
import { styled, theme, darkTheme, keyframes, stitchesConfig } from "../lib/styles/stitches.config";
import { rgba } from "polished";
import Link from "../components/Link";
import type { ComponentPropsWithoutRef } from "react";
import type { Route } from "next";
import styles from "./page.module.css";
const ColorfulLink = ({
lightColor,
darkColor,
css,
children,
...rest
}: ComponentPropsWithoutRef<typeof Link> & {
lightColor: string;
darkColor: string;
}) => {
const uniqueId = `Link_themed__${useId().replace(/\W/g, "")}`;
return (
<Link
css={{
color: lightColor,
...stitchesConfig.utils.setUnderlineColor({ color: lightColor }),
<>
<Link id={uniqueId} {...rest}>
{children}
</Link>
[`.${darkTheme} &`]: {
color: darkColor,
...stitchesConfig.utils.setUnderlineColor({ color: darkColor }),
},
...css,
}}
{...rest}
/>
<style>{`.${styles.page} #${uniqueId}{color:${lightColor};--colors-linkUnderline:${rgba(lightColor, 0.4)}}[data-theme="dark"] .${styles.page} #${uniqueId}{color:${darkColor};--colors-linkUnderline:${rgba(darkColor, 0.4)}}`}</style>
</>
);
};
const H1 = styled("h1", {
margin: "0 0 0.5em -1px", // misaligned left margin, super nitpicky
fontSize: "1.75em",
fontWeight: 500,
lineHeight: 1.1,
color: theme.colors.text,
"@medium": {
fontSize: "1.6em",
},
});
const H2 = styled("h2", {
margin: "0.5em 0 0.5em -1px", // misaligned left margin, super nitpicky
fontSize: "1.2em",
fontWeight: 400,
lineHeight: 1.4,
color: theme.colors.text,
"@medium": {
fontSize: "1.25em",
},
});
const Paragraph = styled("p", {
margin: "0.85em 0",
fontSize: "0.95em",
lineHeight: 1.7,
color: theme.colors.text,
"&:last-of-type": {
marginBottom: 0,
},
"@medium": {
fontSize: "0.925em",
lineHeight: 1.825,
},
});
const Wave = styled("span", {
display: "inline-block",
marginLeft: "0.1em",
fontSize: "1.2em",
"@media (prefers-reduced-motion: no-preference)": {
animation: `${keyframes({
"0%": { transform: "rotate(0deg)" },
"5%": { transform: "rotate(14deg)" },
"10%": { transform: "rotate(-8deg)" },
"15%": { transform: "rotate(14deg)" },
"20%": { transform: "rotate(-4deg)" },
"25%": { transform: "rotate(10deg)" },
"30%": { transform: "rotate(0deg)" },
// pause for ~9 out of 10 seconds
"100%": { transform: "rotate(0deg)" },
})} 5s ease 1s infinite`,
transformOrigin: "65% 80%",
willChange: "transform",
},
});
const Sup = styled("sup", {
margin: "0 0.1em",
fontSize: "0.6em",
});
const PGPIcon = styled(GoLock, {
verticalAlign: "-0.25em",
strokeWidth: 0.5,
});
const PGPKey = styled("code", {
margin: "0 0.15em",
letterSpacing: "0.075em",
wordSpacing: "-0.4em",
});
const Quiet = styled("span", {
color: theme.colors.mediumLight,
});
const Index = () => {
export default function Page() {
return (
<>
<H1>
Hi there! I'm Jake. <Wave>👋</Wave>
</H1>
<div className={styles.page}>
<h1>
Hi there! I'm Jake. <span className={styles.wave}>👋</span>
</h1>
<H2>
<h2>
I'm a frontend web developer based in the{" "}
<ColorfulLink
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&amp;fs=1&amp;showinfo=1&amp;rel=0&amp;iv_load_policy=3"
@ -130,9 +47,9 @@ const Index = () => {
Boston
</ColorfulLink>{" "}
area.
</H2>
</h2>
<Paragraph>
<p>
I specialize in{" "}
<ColorfulLink
href="https://reactjs.org/"
@ -174,9 +91,9 @@ const Index = () => {
LAMP
</ColorfulLink>
, too.
</Paragraph>
</p>
<Paragraph>
<p>
Whenever possible, I also apply my experience in{" "}
<ColorfulLink
href="https://bugcrowd.com/jakejarvis"
@ -205,12 +122,12 @@ const Index = () => {
DevOps automation
</ColorfulLink>
.
</Paragraph>
</p>
<Paragraph>
<p>
I fell in love with{" "}
<ColorfulLink
href="/previously/"
href="/previously"
title="My Terrible, Horrible, No Good, Very Bad First Websites"
lightColor="#4169e1"
darkColor="#8ca9ff"
@ -219,7 +136,7 @@ const Index = () => {
</ColorfulLink>{" "}
and{" "}
<ColorfulLink
href="/notes/my-first-code/"
href={"/notes/my-first-code" as Route}
title="Jake's Bulletin Board, circa 2003"
lightColor="#9932cc"
darkColor="#d588fb"
@ -228,26 +145,23 @@ const Index = () => {
</ColorfulLink>{" "}
when my only source of income was{" "}
<ColorfulLink
href="/birthday/"
href="/birthday"
title="🎉 Cranky Birthday Boy on VHS Tape 📼"
lightColor="#e40088"
darkColor="#fd40b1"
css={{
// rotated 🪄 emoji on hover
"&:hover": {
cursor: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='30' style='font-size:24px'><text y='50%' transform='rotate(-70 0 0) translate(-20, 6)'>🪄</text></svg>") 5 5, auto`,
},
style={{
cursor: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='30' style='font-size:24px'><text y='50%' transform='rotate(-70 0 0) translate(-20, 6)'>🪄</text></svg>") 5 5, auto`,
}}
>
the Tooth Fairy
</ColorfulLink>
. <Quiet>I've improved a bit since then, I think? 🤷</Quiet>
</Paragraph>
. <span style={{ color: "var(--colors-mediumLight)" }}>I've improved a bit since then, I think? 🤷</span>
</p>
<Paragraph>
<p>
Over the years, some of my side projects{" "}
<ColorfulLink
href="/leo/"
href="/leo"
title="Powncer segment on The Lab with Leo Laporte (G4techTV)"
lightColor="#ff1b1b"
darkColor="#f06060"
@ -303,9 +217,9 @@ const Index = () => {
outlets
</ColorfulLink>
.
</Paragraph>
</p>
<Paragraph>
<p>
You can find my work on{" "}
<ColorfulLink
href="https://github.com/jakejarvis"
@ -327,12 +241,12 @@ const Index = () => {
LinkedIn
</ColorfulLink>
. I'm always available to connect over{" "}
<ColorfulLink href="/contact/" title="Send an email" lightColor="#de0c0c" darkColor="#ff5050">
<ColorfulLink href="/contact" title="Send an email" lightColor="#de0c0c" darkColor="#ff5050">
email
</ColorfulLink>{" "}
<Sup>
<sup>
<ColorfulLink
href="/pubkey.asc"
href={"/pubkey.asc" as Route}
rel="pgpkey authn"
title="My Public Key"
lightColor="#757575"
@ -340,9 +254,20 @@ const Index = () => {
underline={false}
openInNewTab
>
<PGPIcon size="1.25em" /> <PGPKey>2B0C 9CF2 51E6 9A39</PGPKey>
<GoLock size="1.25em" className={styles.pgpIcon} />{" "}
<span className={styles.pgpKey}>2B0C 9CF2 51E6 9A39</span>
</ColorfulLink>
</Sup>
</sup>
,{" "}
<ColorfulLink
href="https://bsky.app/profile/jarv.is"
rel="me"
title="Jake Jarvis on Bluesky"
lightColor="#0085FF"
darkColor="#208BFE"
>
Bluesky
</ColorfulLink>
,{" "}
<ColorfulLink
href="https://fediverse.jarv.is/@jake"
@ -363,9 +288,7 @@ const Index = () => {
SMS
</ColorfulLink>{" "}
as well!
</Paragraph>
</>
</p>
</div>
);
};
export default Index;
}

View File

@ -0,0 +1,39 @@
.wackyWrapper {
font-weight: 700;
font-size: 1em;
text-align: center;
/* classic windows 9x cursor easter egg */
cursor:
url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAZklEQVR4AWIAgn/uBT6A9uoAAwAQiIJo97/0Rgy0ANoJH8MPeEgtqwPQEACqCoQHAKECQKgAECoAhAoAoQJAqAAQxh1oPQfcW3kJpxHtL1AAHAwEwwdYiH8BIEgBTBRAAAEEEEAAG7mRt30hEhoLAAAAAElFTkSuQmCC")
2 1,
auto;
}
.wackyWrapper a {
/* windows 9x hand cursor */
cursor:
url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgAgMAAAAOFJJnAAAACVBMVEVHcEwAAAD///8W1S+BAAAAAXRSTlMAQObYZgAAAEdJREFUeAFjoAVghTGkHIhghMAYmQEwxlIYYxlYlSiQMQEsELUKyli1ahWYwQZjMGIwGLKQGA4QA1EYEP0rGVAZrKGhSF4BAHw/HsVwshytAAAAAElFTkSuQmCC")
16 12,
auto;
}
.screenshot,
.divider {
margin: 1em auto;
}
.screenshot figcaption {
font-size: 0.9em;
line-height: 1.5;
text-align: center;
color: var(--colors-medium);
}
.screenshot:last-of-type {
margin-bottom: 0;
}
.divider {
margin: 1em auto;
}

220
app/previously/page.tsx Normal file
View File

@ -0,0 +1,220 @@
// import Layout from "../../components/Layout";
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import Figure from "../../components/Figure";
import IFrame from "../../components/IFrame";
import CodeInline from "../../components/CodeInline";
import HorizontalRule from "../../components/HorizontalRule";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
import { ComicNeue } from "../../lib/styles/fonts";
import styles from "./page.module.css";
import img_wayback from "../../public/static/images/previously/wayback.png";
import img_2002_02 from "../../public/static/images/previously/2002_02.png";
import img_2002_10 from "../../public/static/images/previously/2002_10.png";
import img_2003_08 from "../../public/static/images/previously/2003_08.png";
import img_2004_11 from "../../public/static/images/previously/2004_11.png";
import img_2006_04 from "../../public/static/images/previously/2006_04.png";
import img_2006_05 from "../../public/static/images/previously/2006_05.png";
import img_2007_01 from "../../public/static/images/previously/2007_01.png";
import img_2007_04 from "../../public/static/images/previously/2007_04.png";
import img_2007_05 from "../../public/static/images/previously/2007_05.png";
import img_2009_07 from "../../public/static/images/previously/2009_07.png";
import img_2012_09 from "../../public/static/images/previously/2012_09.png";
import img_2018_04 from "../../public/static/images/previously/2018_04.png";
import img_2020_03 from "../../public/static/images/previously/2020_03.png";
export const metadata: Metadata = {
title: "Previously on...",
description: "An incredibly embarrassing and somewhat painful trip down this site's memory lane...",
openGraph: {
...defaultMetadata.openGraph,
title: "Previously on...",
url: "/previously",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/previously",
},
};
export default async function Page() {
return (
<>
<PageTitle>🕰 Previously on...</PageTitle>
<Content
className={styles.wackyWrapper}
style={{
fontFamily: `${ComicNeue.style.fontFamily}, var(--fonts-sans)`,
}}
>
<Figure
src={img_wayback}
href="https://web.archive.org/web/20010501000000*/jakejarvis.com"
alt="Timeline of this website's past."
priority
className={styles.screenshot}
>
...the{" "}
<Link href="https://web.archive.org/web/20010501000000*/jakejarvis.com">Cringey Chronicles&trade;</Link> of
this website's past.
</Figure>
<HorizontalRule className={styles.divider} />
<p style={{ marginBottom: "0.5em" }}>
🚨 Trigger warning: excessive marquees, animated GIFs, Comic Sans, popups,{" "}
<CodeInline
style={{
fontSize: "0.8em",
fontWeight: 400,
}}
>
color: <span style={{ color: "#32cd32" }}>limegreen</span>
</CodeInline>{" "}
ahead...
</p>
<p style={{ fontSize: "0.95em", marginBottom: "0.5em" }}>
<Link href="/y2k">
<svg
fill="currentColor"
stroke="currentColor"
strokeWidth="0"
viewBox="0 0 24 24"
role="img"
style={{
display: "inline",
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.15em",
marginRight: "0.1em",
fill: "currentColor",
stroke: "currentcolor",
strokeWidth: 0,
}}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.712 1.596l-.756.068-.238.55.734-.017zm1.39.927l-.978.137-.326.807.96-.12.345-.824zM4.89 3.535l-.72.05-.24.567.721-.017zm3.724.309l-1.287.068-.394.96 1.27-.052zm1.87.566l-1.579.069-.566 1.357 1.596-.088.548-1.338zm-4.188.037l-.977.153-.343.806.976-.12zm6.144.668l-1.87.135-.637 1.527 1.87-.154zm2.925.219c-.11 0-.222 0-.334.002l-.767 1.85c1.394-.03 2.52.089 3.373.38l-1.748 4.201c-.955-.304-2.082-.444-3.36-.394l-.54 1.305a8.762 8.762 0 0 1 3.365.396l-1.663 4.014c-1.257-.27-2.382-.395-3.387-.344l-.782 1.887c3.363-.446 6.348.822 9.009 3.773L24 9.23c-2.325-2.575-5.2-3.88-8.637-3.896zm-.644.002l-2.024.12-.687 1.68 2.025-.19zm-10.603.05l-.719.036-.224.566h.703l.24-.601zm3.69.397l-1.287.069-.395.959 1.27-.05zM5.54 6.3l-.994.154-.344.807.98-.121zm4.137.066l-1.58.069L7.53 7.77l1.596-.085.55-1.32zm1.955.688l-1.87.135-.636 1.527 1.887-.154zm2.282.19l-2.01.136-.7 1.682 2.04-.19.67-1.63zm-10.57.066l-.739.035-.238.564h.72l.257-.6zm3.705.293l-1.303.085-.394.96 1.287-.034zm11.839.255a6.718 6.718 0 0 1 2.777 1.717l-1.75 4.237c-.617-.584-1.15-.961-1.611-1.149l-1.201-.498zM4.733 8.22l-.976.154-.344.807.961-.12.36-.841zm4.186 0l-1.594.052-.549 1.354L8.37 9.54zm1.957.668L8.99 9.04l-.619 1.508 1.87-.135.636-1.527zm2.247.275l-2.007.12-.703 1.665 2.042-.156zM2.52 9.267l-.718.033-.24.549.718-.016zm3.725.273l-1.289.07-.41.96 1.287-.03.412-1zm1.87.6l-1.596.05-.55 1.356 1.598-.084.547-1.322zm-4.186.037l-.979.136-.324.805.96-.119zm6.14.633l-1.87.154-.653 1.527 1.906-.154zm2.267.275l-2.026.12-.686 1.663 2.025-.172zm-10.569.031l-.739.037-.238.565.72-.016zm3.673.362l-1.289.068-.41.978 1.305-.05zm-2.285.533l-.976.154-.326.805.96-.12.342-.84zm4.153.07l-1.596.066-.565 1.356 1.612-.084zm1.957.666l-1.889.154-.617 1.526 1.886-.15zm2.28.223l-2.025.12-.685 1.665 2.041-.172.67-1.613zm-10.584.05l-.738.053L0 13.64l.72-.02.24-.6zm3.705.31l-1.285.07-.395.976 1.287-.05.393-.997zm11.923.07c1.08.29 2.024.821 2.814 1.613l-1.715 4.183c-.892-.754-1.82-1.32-2.814-1.664l1.715-4.133zm-10.036.515L4.956 14l-.549 1.32 1.578-.066.567-1.338zm-4.184.014l-.996.156-.309.79.961-.106zm6.14.67l-1.904.154-.617 1.527 1.89-.154.632-1.527zm2.231.324l-2.025.123-.686 1.682 2.026-.174zm-6.863.328l-1.3.068-.397.98 1.285-.054zm1.871.584l-1.578.068-.566 1.334 1.595-.064zm1.953.701l-1.867.137-.635 1.51 1.87-.137zm2.23.31l-2.005.122-.703 1.68 2.04-.19.67-1.61z"></path>
</svg>{" "}
Click here for the <em>full</em> experience anyway.
</Link>
</p>
<figure className={styles.screenshot}>
<IFrame
src="https://jakejarvis.github.io/my-first-website/"
title="My Terrible, Horrible, No Good, Very Bad First Website"
height={500}
allowScripts
style={{ margin: "0.6em 0" }}
/>
<figcaption>
<Link href="https://jakejarvis.github.io/my-first-website/">November 2001</Link> (
<Link href="https://github.com/jakejarvis/my-first-website">view source</Link>)
</figcaption>
</figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2002_02} className={styles.screenshot}>
February 2002
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2002_10} className={styles.screenshot}>
October 2002
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2003_08} className={styles.screenshot}>
August 2003
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2004_11} className={styles.screenshot}>
November 2004
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2006_04} className={styles.screenshot}>
April 2006
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2006_05} className={styles.screenshot}>
May 2006
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2007_01} className={styles.screenshot}>
January 2007
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2007_04} className={styles.screenshot}>
April 2007
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2007_05} className={styles.screenshot}>
May 2007
</Figure>
<HorizontalRule className={styles.divider} />
<Figure src={img_2009_07} className={styles.screenshot}>
July 2009
</Figure>
<HorizontalRule className={styles.divider} />
<Figure
src={img_2012_09}
href="https://focused-knuth-7bc10d.netlify.app/"
alt="September 2012"
className={styles.screenshot}
>
<Link href="https://focused-knuth-7bc10d.netlify.app/">September 2012</Link> (
<Link href="https://github.com/jakejarvis/jarv.is/tree/v1">view source</Link>)
</Figure>
<HorizontalRule className={styles.divider} />
<Figure
src={img_2018_04}
href="https://hungry-mayer-40e790.netlify.app/"
alt="April 2018"
className={styles.screenshot}
>
<Link href="https://hungry-mayer-40e790.netlify.app/">April 2018</Link> (
<Link href="https://github.com/jakejarvis/jarv.is/tree/v2">view source</Link>)
</Figure>
<HorizontalRule className={styles.divider} />
<Figure
src={img_2020_03}
href="https://quiet-truffle-92842d.netlify.app/"
alt="March 2020"
className={styles.screenshot}
>
<Link href="https://quiet-truffle-92842d.netlify.app/">March 2020</Link> (
<Link href="https://github.com/jakejarvis/jarv.is-hugo">view source</Link>)
</Figure>
</Content>
</>
);
}

View File

@ -1,22 +1,29 @@
import { NextSeo } from "next-seo";
import Content from "../components/Content";
import PageTitle from "../components/PageTitle";
import Link from "../components/Link";
import Blockquote from "../components/Blockquote";
import CodeInline from "../components/CodeInline";
import { H2 } from "../components/Heading";
import { UnorderedList, ListItem } from "../components/List";
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import Blockquote from "../../components/Blockquote";
import CodeInline from "../../components/CodeInline";
import { H2 } from "../../components/Heading";
import { UnorderedList, ListItem } from "../../components/List";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
const Privacy = () => {
export const metadata: Metadata = {
title: "Privacy",
openGraph: {
...defaultMetadata.openGraph,
title: "Privacy",
url: "/privacy",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/privacy",
},
};
export default function Page() {
return (
<>
<NextSeo
title="Privacy"
openGraph={{
title: "Privacy",
}}
/>
<PageTitle>🕵 Privacy</PageTitle>
<Content>
@ -38,7 +45,7 @@ const Privacy = () => {
<Link href="https://www.torproject.org/">🧅 Tor network</Link> at:
</p>
<Blockquote css={{ overflowWrap: "break-word" }}>
<Blockquote style={{ overflowWrap: "break-word" }}>
<Link href="http://jarvis2i2vp4j4tbxjogsnqdemnte5xhzyi7hziiyzxwge3hzmh57zad.onion">
<strong>jarvis2i2vp4j4tbxjogsnqdemnte5xhzyi7hziiyzxwge3hzmh57zad.onion</strong>
</Link>
@ -111,7 +118,7 @@ const Privacy = () => {
<Link href="https://www.cloudflare.com/products/turnstile/">
<strong>Cloudflare Turnstile</strong>
</Link>{" "}
to fight bot spam on the <Link href="/contact/">contact form</Link> was an easy choice over seemingly
to fight bot spam on the <Link href="/contact">contact form</Link> was an easy choice over seemingly
unavoidable alternatives like <Link href="https://developers.google.com/recaptcha/">reCAPTCHA</Link>.
</p>
@ -128,6 +135,4 @@ const Privacy = () => {
</Content>
</>
);
};
export default Privacy;
}

View File

@ -0,0 +1,86 @@
.grid {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: flex-start;
width: 100%;
line-height: 1.1;
}
.card {
flex-grow: 1;
margin: 0.6em;
width: 370px;
padding: 1.2em 1.2em 0.8em 1.2em;
border: 1px solid var(--colors-kindaLight);
border-radius: var(--radii-corner);
font-size: 0.85em;
color: var(--colors-mediumDark);
transition: border var(--transitions-fade);
}
.card .name {
font-size: 1.2em;
font-weight: 600;
}
.card .description {
margin-top: 0.7em;
margin-bottom: 0.5em;
line-height: 1.7;
}
.card .meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
}
.card .metaItem {
margin-right: 1.5em;
line-height: 2;
color: var(--colors-medium);
}
.card .metaLink {
color: inherit !important;
}
.card .metaLink:hover,
.card .metaLink:focus-visible {
color: var(--colors-link) !important;
}
.card .metaIcon {
display: inline;
width: 16px;
height: 16px;
vertical-align: -0.3em;
margin-right: 0.5em;
stroke-width: 0.75;
}
.card .metaLanguage {
display: inline-block;
position: relative;
width: 1.15em;
height: 1.15em;
margin-right: 0.5em;
border-radius: 50%;
vertical-align: text-top;
}
.viewMore {
text-align: center;
margin-bottom: 0;
font-weight: 500px;
}
.githubIcon {
display: inline;
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
margin: 0 0.15em;
fill: var(--colors-text);
}

175
app/projects/page.tsx Normal file
View File

@ -0,0 +1,175 @@
import { graphql } from "@octokit/graphql";
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import RelativeTime from "../../components/RelativeTime";
import commaNumber from "comma-number";
import config from "../../lib/config";
import { metadata as defaultMetadata } from "../layout";
import { GoStar, GoRepoForked } from "react-icons/go";
import { SiGithub } from "react-icons/si";
import type { Metadata } from "next";
import type { User, Repository } from "@octokit/graphql-schema";
import type { Project } from "../../types";
import styles from "./page.module.css";
export const revalidate = 600; // 10 minutes
export const metadata: Metadata = {
title: "Projects",
openGraph: {
...defaultMetadata.openGraph,
title: "Projects",
url: "/projects",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/projects",
},
};
async function getRepos(): Promise<Project[] | null> {
// don't fail the entire site build if the required API key for this page is missing
if (!process.env.GH_PUBLIC_TOKEN || process.env.GH_PUBLIC_TOKEN === "") {
console.warn(`ERROR: I can't fetch any GitHub projects without "GH_PUBLIC_TOKEN" set! Skipping for now...`);
return null;
}
// https://docs.github.com/en/graphql/reference/objects#repository
const { user } = await graphql<{ user: User }>(
`
query ($username: String!, $sort: RepositoryOrderField!, $limit: Int) {
user(login: $username) {
repositories(
first: $limit
isLocked: false
isFork: false
ownerAffiliations: OWNER
privacy: PUBLIC
orderBy: { field: $sort, direction: DESC }
) {
edges {
node {
name
url
description
pushedAt
stargazerCount
forkCount
primaryLanguage {
name
color
}
}
}
}
}
}
`,
{
username: config.authorSocial.github,
sort: "STARGAZERS",
limit: 12,
headers: {
accept: "application/vnd.github.v3+json",
authorization: `token ${process.env.GH_PUBLIC_TOKEN}`,
},
}
);
const results = user.repositories.edges as Array<{ node: Repository }>;
const repos = results.map<Project>(({ node: repo }) => ({
name: repo.name,
url: repo.url,
description: repo.description as string,
updatedAt: repo.pushedAt,
stars: repo.stargazerCount,
forks: repo.forkCount,
language: repo.primaryLanguage as Project["language"],
}));
return repos;
}
export default async function Page() {
const repos = await getRepos();
return (
<>
<PageTitle>💾 Projects</PageTitle>
<Content>
<div className={styles.grid}>
{repos?.map((repo) => (
<div key={repo.name} className={styles.card}>
<Link
// @ts-ignore
href={repo.url}
className={styles.name}
>
{repo.name}
</Link>
{repo.description && <p className={styles.description}>{repo.description}</p>}
<div className={styles.meta}>
{repo.language && (
<div className={styles.metaItem}>
{repo.language.color && (
<span className={styles.metaLanguage} style={{ backgroundColor: repo.language.color }} />
)}
{repo.language.name}
</div>
)}
{repo.stars && repo.stars > 0 && (
<div className={styles.metaItem}>
<Link
// @ts-ignore
href={`${repo.url}/stargazers`}
title={`${commaNumber(repo.stars)} ${repo.stars === 1 ? "star" : "stars"}`}
underline={false}
className={styles.metaLink}
>
<GoStar className={styles.metaIcon} />
{commaNumber(repo.stars)}
</Link>
</div>
)}
{repo.forks && repo.forks > 0 && (
<div className={styles.metaItem}>
<Link
// @ts-ignore
href={`${repo.url}/network/members`}
title={`${commaNumber(repo.forks)} ${repo.forks === 1 ? "fork" : "forks"}`}
underline={false}
className={styles.metaLink}
>
<GoRepoForked className={styles.metaIcon} />
{commaNumber(repo.forks)}
</Link>
</div>
)}
{/* only use relative "time ago" on client side, since it'll be outdated via SSG and cause hydration errors */}
<div className={styles.metaItem}>
<RelativeTime date={repo.updatedAt} verb="Updated" staticFormat="MMM D, YYYY" />
</div>
</div>
</div>
))}
</div>
<p className={styles.viewMore}>
<Link href={`https://github.com/${config.authorSocial.github}`}>
View more on <SiGithub className={styles.githubIcon} /> GitHub...
</Link>
</p>
</Content>
</>
);
}

80
app/robots.ts Normal file
View File

@ -0,0 +1,80 @@
import config from "../lib/config";
import { metadata } from "./layout";
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const robots = (): MetadataRoute.Robots => {
// I'm already _so_ over this shit...
// https://github.com/ai-robots-txt/ai.robots.txt/blob/main/robots.txt
const naughtySpiders = [
"AI2Bot",
"Ai2Bot-Dolma",
"Amazonbot",
"anthropic-ai",
"Applebot",
"Applebot-Extended",
"Bytespider",
"CCBot",
"ChatGPT-User",
"Claude-Web",
"ClaudeBot",
"cohere-ai",
"cohere-training-data-crawler",
"Crawlspace",
"Diffbot",
"DuckAssistBot",
"FacebookBot",
"FriendlyCrawler",
"Google-Extended",
"GoogleOther",
"GoogleOther-Image",
"GoogleOther-Video",
"GPTBot",
"iaskspider/2.0",
"ICC-Crawler",
"ImagesiftBot",
"img2dataset",
"ISSCyberRiskCrawler",
"Kangaroo Bot",
"Meta-ExternalAgent",
"Meta-ExternalFetcher",
"OAI-SearchBot",
"omgili",
"omgilibot",
"PanguBot",
"PerplexityBot",
"PetalBot",
"Scrapy",
"SemrushBot-OCOB",
"SemrushBot-SWA",
"Sidetrade indexer bot",
"Timpibot",
"VelenPublicWebCrawler",
"Webzio-Extended",
"YouBot",
"AhrefsBot",
"BLEXBot",
"DataForSeoBot",
"magpie-crawler",
"MJ12bot",
"TurnitinBot",
];
return {
rules: [
{
userAgent: "*",
// block access to staging sites
[process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "allow" : "disallow"]: "/",
},
{
userAgent: naughtySpiders,
disallow: "/",
},
],
sitemap: new URL("sitemap.xml", metadata.metadataBase?.href || `https://${config.siteDomain}`).href,
};
};
export default robots;

60
app/sitemap.ts Normal file
View File

@ -0,0 +1,60 @@
import path from "path";
import glob from "fast-glob";
import { getAllPosts } from "../lib/helpers/posts";
import { metadata } from "./layout";
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const sitemap = async (): Promise<MetadataRoute.Sitemap> => {
// start with manual routes
const routes: MetadataRoute.Sitemap = [
{
// homepage
url: "/",
priority: 1.0,
changeFrequency: "weekly",
lastModified: new Date(process.env.RELEASE_DATE || Date.now()), // timestamp frozen when a new build is deployed
},
{
url: "/tweets/",
changeFrequency: "yearly",
},
];
// add each directory in the app folder as a route (excluding special routes)
(
await glob("*", {
cwd: path.join(process.cwd(), "app"),
deep: 0,
onlyDirectories: true,
markDirectories: true,
ignore: [
// don't include special routes, see: https://nextjs.org/docs/app/api-reference/file-conventions/metadata
"api",
"feed.atom",
"feed.xml",
],
})
).forEach((route) => {
routes.push({
// make all URLs absolute
url: route,
});
});
(await getAllPosts()).forEach((post) => {
routes.push({
url: post.permalink,
// pull lastModified from front matter date
lastModified: new Date(post.date),
});
});
// make all URLs absolute
routes.forEach((page) => (page.url = new URL(page.url, metadata.metadataBase || "").href));
return routes;
};
export default sitemap;

63
app/themes.css Normal file
View File

@ -0,0 +1,63 @@
:root {
--colors-backgroundInner: #ffffff;
--colors-backgroundOuter: #fcfcfc;
--colors-backgroundHeader: rgba(252, 252, 252, 0.7);
--colors-text: #202020;
--colors-mediumDark: #515151;
--colors-medium: #5e5e5e;
--colors-mediumLight: #757575;
--colors-light: #d2d2d2;
--colors-kindaLight: #e3e3e3;
--colors-superLight: #f4f4f4;
--colors-superDuperLight: #fbfbfb;
--colors-link: #0e6dc2;
--colors-linkUnderline: rgba(14, 109, 194, 0.4);
--colors-success: #44a248;
--colors-error: #ff1b1b;
--colors-warning: #f78200;
--colors-codeText: #313131;
--colors-codeBackground: #fdfdfd;
--colors-codeComment: #656e77;
--colors-codeKeyword: #029cb9;
--colors-codeAttribute: #70a800;
--colors-codeNamespace: #f92672;
--colors-codeLiteral: #ae81ff;
--colors-codePunctuation: #111111;
--colors-codeVariable: #d88200;
--colors-codeAddition: #44a248;
--colors-codeDeletion: #ff1b1b;
--sizes-maxLayoutWidth: 865px;
--radii-corner: 0.6rem;
--transitions-fade: 0.25s ease;
--transitions-linkHover: 0.2s ease-in-out;
}
[data-theme="dark"] {
--colors-backgroundInner: #1e1e1e;
--colors-backgroundOuter: #252525;
--colors-backgroundHeader: rgba(37, 37, 37, 0.85);
--colors-text: #f1f1f1;
--colors-mediumDark: #d7d7d7;
--colors-medium: #b1b1b1;
--colors-mediumLight: #959595;
--colors-light: #646464;
--colors-kindaLight: #535353;
--colors-superLight: #272727;
--colors-superDuperLight: #1f1f1f;
--colors-link: #88c7ff;
--colors-linkUnderline: rgba(136, 199, 255, 0.4);
--colors-success: #78df55;
--colors-error: #ff5151;
--colors-warning: #f2b702;
--colors-codeText: #e4e4e4;
--colors-codeBackground: #212121;
--colors-codeComment: #929292;
--colors-codeKeyword: #3b9dd2;
--colors-codeAttribute: #78df55;
--colors-codeNamespace: #f95757;
--colors-codeLiteral: #d588fb;
--colors-codePunctuation: #cccccc;
--colors-codeVariable: #fd992a;
--colors-codeAddition: #78df55;
--colors-codeDeletion: #ff5151;
}

View File

@ -1,30 +1,33 @@
import { NextSeo } from "next-seo";
import Content from "../components/Content";
import PageTitle from "../components/PageTitle";
import Link from "../components/Link";
import Image from "../components/Image";
import CodeInline from "../components/CodeInline";
import { H2 } from "../components/Heading";
import { UnorderedList, ListItem } from "../components/List";
import Content from "../../components/Content";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import Image from "../../components/Image";
import CodeInline from "../../components/CodeInline";
import { H2 } from "../../components/Heading";
import { UnorderedList, ListItem } from "../../components/List";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata, Route } from "next";
import desktopImg from "../public/static/images/uses/desktop.png";
import { styled } from "../lib/styles/stitches.config";
import desktopImg from "../../public/static/images/uses/desktop.png";
const Emoji = styled("span", {
marginRight: "0.45em",
});
export const metadata: Metadata = {
title: "/uses",
description: "Things I use daily.",
openGraph: {
...defaultMetadata.openGraph,
title: "/uses",
images: [desktopImg.src],
url: "/uses",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/uses",
},
};
const Uses = () => {
export default function Page() {
return (
<>
<NextSeo
title="/uses"
description="Things I use daily."
openGraph={{
title: "/uses",
}}
/>
<PageTitle>/uses</PageTitle>
<Content>
@ -37,10 +40,10 @@ const Uses = () => {
</p>
<Image src={desktopImg} href={desktopImg.src} alt="My mess of a desktop." priority />
<Image src={desktopImg} href={desktopImg.src as Route} alt="My mess of a desktop." priority />
<H2 id="hardware">
<Emoji>🚘</Emoji>
<span style={{ marginRight: "0.45em" }}>🚘</span>
Daily Drivers
</H2>
<UnorderedList>
@ -110,7 +113,7 @@ const Uses = () => {
</UnorderedList>
<H2 id="homelab">
<Emoji>🧪</Emoji>
<span style={{ marginRight: "0.45em" }}>🧪</span>
Homelab
</H2>
<UnorderedList>
@ -147,7 +150,7 @@ const Uses = () => {
drives
</ListItem>
<ListItem>
<Link href="">
<Link href="https://www.plex.tv/personal-media-server/">
<strong>Plex</strong>
</Link>{" "}
(installed as a package via{" "}
@ -226,7 +229,7 @@ const Uses = () => {
</UnorderedList>
<H2 id="development">
<Emoji>💾</Emoji>
<span style={{ marginRight: "0.45em" }}>💾</span>
Development
</H2>
<UnorderedList>
@ -430,7 +433,7 @@ const Uses = () => {
</UnorderedList>
<H2 id="browsing">
<Emoji>🌎</Emoji>
<span style={{ marginRight: "0.45em" }}>🌎</span>
Browsing
</H2>
<UnorderedList>
@ -515,7 +518,7 @@ const Uses = () => {
</UnorderedList>
<H2 id="macos">
<Emoji>💻</Emoji>
<span style={{ marginRight: "0.45em" }}>💻</span>
macOS
</H2>
<UnorderedList>
@ -599,7 +602,7 @@ const Uses = () => {
</UnorderedList>
<H2 id="ios">
<Emoji>📱</Emoji>
<span style={{ marginRight: "0.45em" }}>📱</span>
iOS
</H2>
<p>I have far too many apps to count, but here the essentials that have earned a spot on my home screen:</p>
@ -646,7 +649,7 @@ const Uses = () => {
</UnorderedList>
<H2 id="cloud">
<Emoji></Emoji>
<span style={{ marginRight: "0.45em" }}></span>
Cloud
</H2>
<p>
@ -671,7 +674,7 @@ const Uses = () => {
</Link>
<UnorderedList>
<ListItem>
<Link href="/notes/dropping-dropbox/">Read why.</Link>
<Link href={"/notes/dropping-dropbox/" as Route}>Read why.</Link>
</ListItem>
</UnorderedList>
</ListItem>
@ -773,8 +776,8 @@ const Uses = () => {
</UnorderedList>
<H2 id="iot">
<Emoji>🏠</Emoji>
Internet of <del>Things</del> <Link href="/notes/shodan-search-queries/">Crap</Link>
<span style={{ marginRight: "0.45em" }}>🏠</span>
Internet of <del>Things</del> <Link href={"/notes/shodan-search-queries/" as Route}>Crap</Link>
</H2>
<UnorderedList>
<ListItem>
@ -806,6 +809,4 @@ const Uses = () => {
</Content>
</>
);
};
export default Uses;
}

77
app/zip/page.tsx Normal file
View File

@ -0,0 +1,77 @@
import Content from "../../components/Content";
import Link from "../../components/Link";
import CodeBlock from "../../components/CodeBlock/CodeBlock";
import { metadata as defaultMetadata } from "../layout";
import type { Metadata } from "next";
import backgroundImg from "../../public/static/images/zip/bg.jpg";
export const metadata: Metadata = {
title: "fuckyougoogle.zip",
description: "This is a horrible idea.",
openGraph: {
...defaultMetadata.openGraph,
title: "fuckyougoogle.zip",
url: "/zip",
},
alternates: {
...defaultMetadata.alternates,
canonical: "/zip",
},
};
export default async function Page() {
return (
<Content
style={{
backgroundImage: `url(${backgroundImg.src})`,
backgroundRepeat: "repeat",
backgroundPosition: "center",
borderRadius: "var(--radii-corner)",
}}
>
<CodeBlock
style={{
backgroundColor: "var(--colors-backgroundHeader)",
backdropFilter: "saturate(180%) blur(5px))",
}}
>
<span style={{ color: "var(--colors-codeNamespace)" }}>sundar</span>@
<span style={{ color: "var(--colors-codeKeyword)" }}>google</span>:
<span style={{ color: "var(--colors-codeAttribute)" }}>~</span>${" "}
<span style={{ color: "var(--colors-codeLiteral)" }}>mv</span> /root
<Link href="https://killedbygoogle.com/" style={{ color: "inherit" }} underline={false}>
/stable_products_that_people_rely_on/
</Link>
googledomains.zip /tmp/
<br />
<span style={{ color: "var(--colors-codeNamespace)" }}>sundar</span>@
<span style={{ color: "var(--colors-codeKeyword)" }}>google</span>:
<span style={{ color: "var(--colors-codeAttribute)" }}>~</span>${" "}
<span style={{ color: "var(--colors-codeLiteral)" }}>crontab</span>{" "}
<span style={{ color: "var(--colors-codeVariable)" }}>-l</span>
<br />
<br />
<span style={{ color: "var(--colors-codeComment)" }}>
# TODO(someone else): make super duper sure this only deletes actual zip files and *NOT* the sketchy domains
ending with file extensions released by us & purchased on our registrar (which i just yeeted btw cuz i'm bored
& also my evil superpowers are fueled by my reckless disregard for the greater good of the internet). - xoxo
sundar <span style={{ color: "var(--colors-codeNamespace)" }}>&lt;3</span>
</span>
<br />
<span style={{ color: "var(--colors-codeAttribute)" }}>@monthly</span>&nbsp;&nbsp;&nbsp;&nbsp;
<span style={{ color: "var(--colors-codeLiteral)" }}>rm</span>{" "}
<span style={{ color: "var(--colors-codeVariable )" }}>-f</span> /tmp/
<Link href="https://fuckyougoogle.zip/" style={{ color: "inherit" }} underline={false}>
*.zip
</Link>
<br />
<br />
<span style={{ color: "var(--colors-codeNamespace)" }}>sundar</span>@
<span style={{ color: "var(--colors-codeKeyword)" }}>google</span>:
<span style={{ color: "var(--colors-codeAttribute)" }}>~</span>${" "}
<span style={{ color: "var(--colors-codeLiteral)" }}>reboot</span> 0
</CodeBlock>
</Content>
);
}

View File

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

View File

@ -1,10 +1,10 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Blockquote = styled("blockquote", {
marginLeft: 0,
paddingLeft: "1.25em",
borderLeft: `0.25em solid ${theme.colors.link}`,
color: theme.colors.mediumDark,
});
import styles from "./Blockquote.module.css";
const Blockquote = ({ className, ...rest }: ComponentPropsWithoutRef<"blockquote">) => (
<blockquote className={clsx(styles.blockquote, className)} {...rest} />
);
export default Blockquote;

View File

@ -1,27 +0,0 @@
import Turnstile from "react-turnstile";
import useHasMounted from "../../hooks/useHasMounted";
import useTheme from "../../hooks/useTheme";
import type { ComponentPropsWithoutRef } from "react";
export type CaptchaProps = Omit<ComponentPropsWithoutRef<typeof Turnstile>, "sitekey"> & {
className?: string;
};
const Captcha = ({ theme, className, ...rest }: CaptchaProps) => {
const hasMounted = useHasMounted();
const { activeTheme } = useTheme();
return (
<div className={className}>
{hasMounted && (
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
theme={theme || (activeTheme === "dark" ? activeTheme : "light")}
{...rest}
/>
)}
</div>
);
};
export default Captcha;

View File

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

View File

@ -1,10 +1,40 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import CodeBlock from "../CodeBlock";
import CodeInline from "../CodeInline";
import type { PropsWithChildren } from "react";
const Code = styled("code", {
backgroundColor: theme.colors.codeBackground,
border: `1px solid ${theme.colors.kindaLight}`,
borderRadius: theme.radii.corner,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
});
export type CodeProps = PropsWithChildren<{
forceBlock?: boolean;
className?: string;
}>;
// a simple wrapper component that "intelligently" picks between inline code and code blocks (w/ optional syntax
// highlighting & a clipboard button)
const Code = ({ forceBlock, className, children, ...rest }: CodeProps) => {
// detect if this input has already been touched by prism.js via rehype
const classNames = className?.split(" ");
const prismEnabled = classNames?.includes("code-highlight");
if (prismEnabled || forceBlock) {
// full multi-line code blocks with copy-to-clipboard button
// automatic if highlighted by prism, otherwise can be forced (rather than inlined) with `forceBlock={true}`
return (
<CodeBlock
highlight={prismEnabled && !classNames?.includes("language-plaintext")}
withCopyButton
className={className}
{...rest}
>
{children}
</CodeBlock>
);
}
// simple inline code in paragraphs, headings, etc. (never highlighted)
return (
<CodeInline className={className} {...rest}>
{children}
</CodeInline>
);
};
export default Code;

View File

@ -0,0 +1,105 @@
.codeBlock {
position: relative;
width: 100%;
margin: 1em auto;
color: var(--colors-codeText);
}
.codeBlock .code {
display: block;
overflow-x: auto;
padding: 1em;
font-size: 0.9em;
tab-size: 2px;
background-color: var(--colors-codeBackground);
border: 1px solid var(--colors-kindaLight);
border-radius: var(--radii-corner);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
}
.codeBlock :global(.line-number)::before {
display: inline-block;
width: 1.5em;
margin-right: 1.5em;
text-align: right;
color: var(--colors-codeComment);
content: attr(line);
font-variant-numeric: tabular-nums;
user-select: none;
}
.codeBlock :global(.code-line):first-of-type {
margin-right: 3em;
}
.codeBlock.highlight :global(.token.comment),
.codeBlock.highlight :global(.token.prolog),
.codeBlock.highlight :global(.token.cdata) {
color: var(--colors-codeComment);
}
.codeBlock.highlight :global(.token.delimiter),
.codeBlock.highlight :global(.token.boolean),
.codeBlock.highlight :global(.token.keyword),
.codeBlock.highlight :global(.token.selector),
.codeBlock.highlight :global(.token.important),
.codeBlock.highlight :global(.token.doctype),
.codeBlock.highlight :global(.token.atrule),
.codeBlock.highlight :global(.token.url) {
color: var(--colors-codeKeyword);
}
.codeBlock.highlight :global(.token.tag),
.codeBlock.highlight :global(.token.builtin),
.codeBlock.highlight :global(.token.regex) {
color: var(--colors-codeNamespace);
}
.codeBlock.highlight :global(.token.property),
.codeBlock.highlight :global(.token.constant),
.codeBlock.highlight :global(.token.variable),
.codeBlock.highlight :global(.token.attr-value),
.codeBlock.highlight :global(.token.class-name),
.codeBlock.highlight :global(.token.string),
.codeBlock.highlight :global(.token.char) {
color: var(--colors-codeVariable);
}
.codeBlock.highlight :global(.token.literal-property),
.codeBlock.highlight :global(.token.attr-name) {
color: var(--colors-codeAttribute);
}
.codeBlock.highlight :global(.token.function) {
color: var(--colors-codeLiteral);
}
.codeBlock.highlight :global(.token.tag .punctuation),
.codeBlock.highlight :global(.token.attr-value .punctuation) {
color: var(--colors-codePunctuation);
}
.codeBlock.highlight :global(.token.inserted) {
color: var(--colors-codeAddition);
}
.codeBlock.highlight :global(.token.deleted) {
color: var(--colors-codeDeletion);
}
.codeBlock.highlight :global(.token.url) {
text-decoration: underline;
}
.codeBlock.highlight :global(.token.bold) {
font-weight: bold;
}
.codeBlock.highlight :global(.token.italic) {
font-style: italic;
}
.cornerCopyButton {
position: absolute;
top: 0;
right: 0;
padding: 0.65em;
background-color: var(--colors-backgroundInner);
border: 1px solid var(--colors-kindaLight);
border-top-right-radius: var(--radii-corner);
border-bottom-left-radius: var(--radii-corner);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
}

View File

@ -1,105 +1,22 @@
import Code from "../Code";
import clsx from "clsx";
import CopyButton from "../CopyButton";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const Block = styled("div", {
position: "relative",
width: "100%",
margin: "1em auto",
color: theme.colors.codeText,
import styles from "./CodeBlock.module.css";
[`& ${Code}`]: {
display: "block",
overflowX: "auto",
padding: "1em",
fontSize: "0.9em",
tabSize: 2,
// optional line numbers added at time of prism compilation
".line-number::before": {
display: "inline-block",
width: "1.5em",
marginRight: "1.5em",
textAlign: "right",
color: theme.colors.codeComment,
content: "attr(line)", // added as spans by prism
fontVariantNumeric: "tabular-nums",
userSelect: "none",
},
// leave room for clipboard button to the right of the first line
".code-line:first-of-type": {
marginRight: "3em",
},
},
variants: {
highlight: {
true: {
// the following sub-classes MUST be global -- the prism rehype plugin isn't aware of this file
".token": {
"&.comment, &.prolog, &.cdata": {
color: theme.colors.codeComment,
},
"&.delimiter, &.boolean, &.keyword, &.selector, &.important, &.doctype, &.atrule, &.url": {
color: theme.colors.codeKeyword,
},
"&.tag, &.builtin, &.regex": {
color: theme.colors.codeNamespace,
},
"&.property, &.constant, &.variable, &.attr-value, &.class-name, &.string, &.char": {
color: theme.colors.codeVariable,
},
"&.literal-property, &.attr-name": {
color: theme.colors.codeAttribute,
},
"&.function": {
color: theme.colors.codeLiteral,
},
"&.tag .punctuation, &.attr-value .punctuation": {
color: theme.colors.codePunctuation,
},
"&.inserted": {
color: theme.colors.codeAddition,
},
"&.deleted": {
color: theme.colors.codeDeletion,
},
"&.url": { textDecoration: "underline" },
"&.bold": { fontWeight: "bold" },
"&.italic": { fontStyle: "italic" },
},
},
},
},
});
const CornerCopyButton = styled(CopyButton, {
position: "absolute",
top: 0,
right: 0,
padding: "0.65em",
backgroundColor: theme.colors.backgroundInner,
border: `1px solid ${theme.colors.kindaLight}`,
borderTopRightRadius: theme.radii.corner,
borderBottomLeftRadius: theme.radii.corner,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
});
export type CodeBlockProps = ComponentPropsWithoutRef<typeof Code> & {
export type CodeBlockProps = ComponentPropsWithoutRef<"div"> & {
highlight?: boolean;
withCopyButton?: boolean;
};
const CodeBlock = ({ highlight, withCopyButton, className, children, ...rest }: CodeBlockProps) => {
return (
<Block highlight={highlight}>
{withCopyButton && <CornerCopyButton source={children} />}
<Code className={className?.replace("code-highlight", "").trim()} {...rest}>
<div className={clsx(styles.codeBlock, highlight && styles.highlight)}>
{withCopyButton && <CopyButton className={styles.cornerCopyButton} source={children} />}
<code className={clsx(styles.code, className)} {...rest}>
{children}
</Code>
</Block>
</code>
</div>
);
};

View File

@ -1,40 +0,0 @@
import CodeBlock from "../CodeBlock";
import CodeInline from "../CodeInline";
import type { PropsWithChildren } from "react";
export type CodeHybridProps = PropsWithChildren<{
forceBlock?: boolean;
className?: string;
}>;
// a simple wrapper component that "intelligently" picks between inline code and code blocks (w/ optional syntax
// highlighting & a clipboard button)
const CodeHybrid = ({ forceBlock, className, children, ...rest }: CodeHybridProps) => {
// detect if this input has already been touched by prism.js via rehype
const classNames = className?.split(" ");
const prismEnabled = classNames?.includes("code-highlight");
if (prismEnabled || forceBlock) {
// full multi-line code blocks with copy-to-clipboard button
// automatic if highlighted by prism, otherwise can be forced (rather than inlined) with `forceBlock={true}`
return (
<CodeBlock
highlight={prismEnabled && !classNames?.includes("language-plaintext")}
withCopyButton
className={className}
{...rest}
>
{children}
</CodeBlock>
);
}
// simple inline code in paragraphs, headings, etc. (never highlighted)
return (
<CodeInline className={className} {...rest}>
{children}
</CodeInline>
);
};
export default CodeHybrid;

View File

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

View File

@ -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);
}

View File

@ -1,10 +1,10 @@
import Code from "../Code";
import { styled } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const CodeInline = styled(Code, {
padding: "0.175em 0.3em",
fontSize: "0.925em",
pageBreakInside: "avoid",
});
import styles from "./CodeInline.module.css";
const CodeInline = ({ className, ...rest }: ComponentPropsWithoutRef<"code">) => (
<code className={clsx(styles.codeInline, className)} {...rest} />
);
export default CodeInline;

View File

@ -0,0 +1,3 @@
.wrapper {
width: 100%;
}

View File

@ -1,11 +1,7 @@
import clsx from "clsx";
import IFrame from "../IFrame";
import useHasMounted from "../../hooks/useHasMounted";
import useTheme from "../../hooks/useTheme";
import { styled } from "../../lib/styles/stitches.config";
const Wrapper = styled("div", {
width: "100%",
});
import styles from "./CodePenEmbed.module.css";
export type CodePenEmbedProps = {
username: string;
@ -26,25 +22,19 @@ const CodePenEmbed = ({
editable = false,
className,
}: CodePenEmbedProps) => {
const hasMounted = useHasMounted();
const { activeTheme } = useTheme();
return (
<Wrapper className={className} css={{ height }}>
{hasMounted && (
<IFrame
src={`https://codepen.io/${username}/embed/${id}/?${new URLSearchParams({
"theme-id": activeTheme === "dark" ? activeTheme : "light",
"default-tab": `${defaultTab},result`,
preview: `${!!preview}`,
editable: `${!!editable}`,
})}`}
height={height}
allowScripts
noScroll
/>
)}
</Wrapper>
<div className={clsx(styles.wrapper, className)} style={{ height }}>
<IFrame
src={`https://codepen.io/${username}/embed/${id}/?${new URLSearchParams({
"default-tab": `${defaultTab},result`,
preview: `${!!preview}`,
editable: `${!!editable}`,
})}`}
height={height}
allowScripts
noScroll
/>
</div>
);
};

View File

@ -0,0 +1,6 @@
.comments {
margin-top: 2em;
padding-top: 2em;
border-top: 2px solid var(--colors-light);
min-height: 360px;
}

View File

@ -1,22 +1,19 @@
"use client";
import Giscus from "@giscus/react";
import clsx from "clsx";
import useTheme from "../../hooks/useTheme";
import { styled, theme } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { ComponentPropsWithoutRef } from "react";
import type { GiscusProps } from "@giscus/react";
const Wrapper = styled("div", {
marginTop: "2em",
paddingTop: "2em",
borderTop: `2px solid ${theme.colors.light}`,
minHeight: "360px",
});
import styles from "./Comments.module.css";
export type CommentsProps = ComponentPropsWithoutRef<typeof Wrapper> & {
export type CommentsProps = ComponentPropsWithoutRef<"div"> & {
title: string;
};
const Comments = ({ title, ...rest }: CommentsProps) => {
const Comments = ({ title, className, ...rest }: CommentsProps) => {
const { activeTheme } = useTheme();
// fail silently if giscus isn't configured
@ -27,7 +24,7 @@ const Comments = ({ title, ...rest }: CommentsProps) => {
// TODO: use custom `<Loading />` spinner component during suspense
return (
<Wrapper {...rest}>
<div className={clsx(styles.comments, className)} {...rest}>
<Giscus
repo={config.githubRepo as GiscusProps["repo"]}
repoId={config.giscusConfig.repoId}
@ -38,10 +35,10 @@ const Comments = ({ title, ...rest }: CommentsProps) => {
reactionsEnabled="1"
emitMetadata="0"
inputPosition="top"
loading="eager" // still lazily loaded with react-intersection-observer
loading="lazy"
theme={activeTheme === "dark" ? activeTheme : "light"}
/>
</Wrapper>
</div>
);
};

View File

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

View File

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

View File

@ -1,14 +1,10 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Content = styled("div", {
fontSize: "0.9em",
lineHeight: 1.7,
color: theme.colors.text,
import styles from "./Content.module.css";
"@medium": {
fontSize: "0.925em",
lineHeight: 1.85,
},
});
const Content = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => (
<div className={clsx(styles.content, className)} {...rest} />
);
export default Content;

View File

@ -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;
}

View File

@ -1,45 +1,26 @@
"use client";
import { forwardRef, useState, useEffect } from "react";
import innerText from "react-innertext";
import copy from "copy-to-clipboard";
import clsx from "clsx";
import { FiClipboard, FiCheck } from "react-icons/fi";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ReactNode, Ref, ComponentPropsWithoutRef, ElementRef, MouseEventHandler } from "react";
const Button = styled("button", {
lineHeight: 1,
cursor: "pointer",
import styles from "./CopyButton.module.css";
variants: {
copied: {
true: {
color: theme.colors.success,
},
false: {
color: theme.colors.mediumDark,
"&:hover, &:focus-visible": {
color: theme.colors.link,
},
},
},
},
});
const Icon = styled("svg", {
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.3em",
});
export type CopyButtonProps = ComponentPropsWithoutRef<typeof Button> & {
export type CopyButtonProps = ComponentPropsWithoutRef<"button"> & {
source: string | ReactNode;
timeout?: number;
};
const CopyButton = ({ source, timeout = 2000, ...rest }: CopyButtonProps, ref: Ref<ElementRef<typeof Button>>) => {
const CopyButton = (
{ source, timeout = 2000, className, ...rest }: CopyButtonProps,
ref: Ref<ElementRef<"button">>
) => {
const [copied, setCopied] = useState(false);
const handleCopy: MouseEventHandler<ElementRef<typeof Button>> = (e) => {
const handleCopy: MouseEventHandler<ElementRef<"button">> = (e) => {
// prevent unintentional double-clicks by unfocusing button
e.currentTarget.blur();
@ -67,17 +48,17 @@ const CopyButton = ({ source, timeout = 2000, ...rest }: CopyButtonProps, ref: R
}, [timeout, copied]);
return (
<Button
<button
ref={ref}
title="Copy to clipboard"
aria-label="Copy to clipboard"
onClick={handleCopy}
disabled={copied}
copied={copied}
className={clsx(styles.button, copied && styles.copied, className)}
{...rest}
>
<Icon as={copied ? FiCheck : FiClipboard} />
</Button>
{copied ? <FiCheck className={styles.icon} /> : <FiClipboard className={styles.icon} />}
</button>
);
};

View File

@ -0,0 +1,11 @@
.figure {
margin: 1em auto;
text-align: center;
}
.caption {
font-size: 0.9em;
line-height: 1.5;
color: var(--colors-medium);
margin-top: -0.4em;
}

View File

@ -1,19 +1,9 @@
import innerText from "react-innertext";
import clsx from "clsx";
import Image from "../Image";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { PropsWithChildren, ComponentPropsWithoutRef } from "react";
const Wrapper = styled("figure", {
margin: "1em auto",
textAlign: "center",
});
const Caption = styled("figcaption", {
fontSize: "0.9em",
lineHeight: 1.5,
color: theme.colors.medium,
marginTop: "-0.4em",
});
import styles from "./Figure.module.css";
export type FigureProps = Omit<ComponentPropsWithoutRef<typeof Image>, "alt"> &
PropsWithChildren<{
@ -22,10 +12,10 @@ export type FigureProps = Omit<ComponentPropsWithoutRef<typeof Image>, "alt"> &
const Figure = ({ children, alt, className, ...imageProps }: FigureProps) => {
return (
<Wrapper className={className}>
<figure className={clsx(styles.figure, className)}>
<Image alt={alt || innerText(children)} {...imageProps} />
<Caption>{children}</Caption>
</Wrapper>
<figcaption className={styles.caption}>{children}</figcaption>
</figure>
);
};

View File

@ -0,0 +1,92 @@
.footer {
width: 100%;
padding: 1.25em 1.5em;
border-top: 1px solid var(--colors-kindaLight);
background-color: var(--colors-backgroundOuter);
color: var(--colors-mediumDark);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
}
.row {
display: flex;
width: 100%;
max-width: var(--sizes-maxLayoutWidth);
margin: 0 auto;
justify-content: space-between;
font-size: 0.8em;
line-height: 2.3;
}
.link {
color: var(--colors-mediumDark) !important;
}
.link.hover:hover,
.link.hover:focus-visible {
color: var(--colors-medium) !important;
}
.link.underline {
padding-bottom: 2px;
border-bottom: 1px solid var(--colors-light);
}
.link.underline:hover,
.link.hover:focus-visible {
border-bottom-color: var(--colors-kindaLight);
}
.icon {
display: inline;
width: 1.25em;
height: 1.25em;
vertical-align: -0.25em;
margin: 0 0.075em;
}
.heart {
display: inline-block;
color: var(--colors-error);
}
@media (prefers-reduced-motion: no-preference) {
.heart {
animation: pulse 10s ease 7.5s infinite;
will-change: transform;
}
@keyframes pulse {
0% {
transform: scale(1);
}
2% {
transform: scale(1.25);
}
4% {
transform: scale(1);
}
6% {
transform: scale(1.2);
}
8% {
transform: scale(1);
}
/* pause for ~9 out of 10 seconds */
100% {
transform: scale(1);
}
}
}
@media (max-width: 768px) {
.footer {
padding: 1em 1.25em;
}
/* stack columns on left instead of flexboxing across */
.row {
display: block;
}
}

View File

@ -1,124 +1,57 @@
import clsx from "clsx";
import Link from "../Link";
import { GoHeartFill } from "react-icons/go";
import { SiNextdotjs } from "react-icons/si";
import { styled, theme, keyframes } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("footer", {
width: "100%",
padding: "1.25em 1.5em",
borderTop: `1px solid ${theme.colors.kindaLight}`,
backgroundColor: theme.colors.backgroundOuter,
color: theme.colors.mediumDark,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
import styles from "./Footer.module.css";
"@medium": {
padding: "1em 1.25em",
},
});
export type FooterProps = ComponentPropsWithoutRef<"footer">;
const Row = styled("div", {
display: "flex",
width: "100%",
maxWidth: theme.sizes.maxLayoutWidth,
margin: "0 auto",
justifyContent: "space-between",
fontSize: "0.8em",
lineHeight: 2.3,
// stack columns on left instead of flexboxing across
"@medium": {
display: "block",
},
});
const PlainLink = styled(Link, {
color: theme.colors.mediumDark,
});
const Icon = styled("svg", {
display: "inline",
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.25em",
margin: "0 0.075em",
});
const Heart = styled("span", {
display: "inline-block",
color: theme.colors.error, // somewhat ironically color the heart with the themed "error" red... </3
"@media (prefers-reduced-motion: no-preference)": {
animation: `${keyframes({
"0%": { transform: "scale(1)" },
"2%": { transform: "scale(1.25)" },
"4%": { transform: "scale(1)" },
"6%": { transform: "scale(1.2)" },
"8%": { transform: "scale(1)" },
// pause for ~9 out of 10 seconds
"100%": { transform: "scale(1)" },
})} 10s ease 7.5s infinite`,
willChange: "transform",
},
});
export type FooterProps = ComponentPropsWithoutRef<typeof Wrapper>;
const Footer = ({ ...rest }: FooterProps) => {
const Footer = ({ className, ...rest }: FooterProps) => {
return (
<Wrapper {...rest}>
<Row>
<footer className={clsx(styles.footer, className)} {...rest}>
<div className={styles.row}>
<div>
Content{" "}
<PlainLink href="/license/" title={config.license} underline={false}>
<Link href="/license" title={config.license} underline={false} className={styles.link}>
licensed under {config.licenseAbbr}
</PlainLink>
</Link>
,{" "}
<PlainLink href="/previously/" title="Previously on..." underline={false}>
<Link href="/previously" title="Previously on..." underline={false} className={styles.link}>
{config.copyrightYearStart}
</PlainLink>{" "}
</Link>{" "}
{new Date(process.env.RELEASE_DATE || Date.now()).getUTCFullYear()}.
</div>
<div>
Made with{" "}
<Heart title="Love">
<Icon as={GoHeartFill} css={{ strokeWidth: 2 }} />
</Heart>{" "}
<span className={styles.heart} title="Love">
<GoHeartFill className={styles.icon} style={{ strokeWidth: 2 }} />
</span>{" "}
and{" "}
<PlainLink
<Link
href="https://nextjs.org/"
title="Powered by Next.js"
aria-label="Next.js"
underline={false}
css={{
"&:hover, &:focus-visible": {
color: theme.colors.medium,
},
}}
className={clsx(styles.link, styles.hover)}
>
<Icon as={SiNextdotjs} />
</PlainLink>
<SiNextdotjs className={styles.icon} />
</Link>
.{" "}
<PlainLink
<Link
href={`https://github.com/${config.githubRepo}`}
title="View Source on GitHub"
underline={false}
css={{
paddingBottom: "2px",
borderBottom: `1px solid ${theme.colors.light}`,
"&:hover, &:focus-visible": {
borderColor: theme.colors.kindaLight,
},
}}
className={clsx(styles.link, styles.underline)}
>
View source.
</PlainLink>
</Link>
</div>
</Row>
</Wrapper>
</div>
</footer>
);
};

View File

@ -1,3 +1,5 @@
"use client";
import Frame from "react-frame-component";
import useHasMounted from "../../hooks/useHasMounted";

View File

@ -0,0 +1,82 @@
.header {
width: 100%;
height: 4.5em;
padding: 0.7em 1.5em;
border-bottom: 1px solid var(--colors-kindaLight);
background-color: var(--colors-backgroundHeader);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
z-index: 9999px;
/* blurry glass-like background effect (except on firefox...?) */
backdrop-filter: saturate(180%) blur(5px);
}
.selfieImage {
width: 50px;
height: 50px;
border: 1px solid var(--colors-light);
border-radius: 50%;
}
.selfieLink {
display: inline-flex;
flex-shrink: 0;
align-items: center;
color: var(--colors-mediumDark) !important;
}
.selfieLink:hover,
.selfieLink:focus-visible {
color: var(--colors-link) !important;
}
.name {
margin: 0 0.6em;
font-size: 1.15em;
font-weight: 500;
letter-spacing: 0.02em;
line-height: 1;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: var(--sizes-maxLayoutWidth);
margin: 0 auto;
}
@media (max-width: 768px) {
.header {
padding: 0.75em 1.25em;
height: 5.9em;
}
.selfieImage {
width: 70px;
height: 70px;
border-width: 2px;
}
.selfieLink:hover .selfieImage,
.selfieLink:focus-visible .selfieImage {
border-color: var(--colors-linkUnderline);
}
.name {
display: none;
}
.menu {
max-width: 325px;
}
}
@media (max-width: 380px) {
.menu {
max-width: 225px;
}
}

View File

@ -1,55 +1,38 @@
import Selfie from "../Selfie";
import clsx from "clsx";
import Link from "../Link";
import Image from "../Image";
import Menu from "../Menu";
import { styled, theme } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("header", {
width: "100%",
height: "4.5em",
padding: "0.7em 1.5em",
borderBottom: `1px solid ${theme.colors.kindaLight}`,
backgroundColor: theme.colors.backgroundHeader,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
zIndex: 9999,
import styles from "./Header.module.css";
// blurry glass-like background effect (except on firefox...?)
backdropFilter: "saturate(180%) blur(5px)",
import selfieJpg from "../../public/static/images/selfie.jpg";
"@medium": {
padding: "0.75em 1.25em",
height: "5.9em",
},
});
export type HeaderProps = ComponentPropsWithoutRef<"header">;
const Nav = styled("nav", {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
maxWidth: theme.sizes.maxLayoutWidth,
margin: "0 auto",
});
const ResponsiveMenu = styled(Menu, {
"@medium": {
maxWidth: "325px",
},
"@small": {
maxWidth: "225px",
},
});
export type HeaderProps = ComponentPropsWithoutRef<typeof Wrapper>;
const Header = ({ ...rest }: HeaderProps) => {
const Header = ({ className, ...rest }: HeaderProps) => {
return (
<Wrapper {...rest}>
<Nav>
<Selfie />
<ResponsiveMenu />
</Nav>
</Wrapper>
<header className={clsx(styles.header, className)} {...rest}>
<nav className={styles.nav}>
<Link href="/" rel="author" title={config.authorName} underline={false} className={styles.selfieLink}>
<Image
src={selfieJpg}
alt={`Photo of ${config.authorName}`}
className={styles.selfieImage}
width={70}
height={70}
quality={60}
placeholder="empty"
inline
priority
/>
<span className={styles.name}>{config.authorName}</span>
</Link>
<Menu className={styles.menu} />
</nav>
</header>
);
};

View File

@ -0,0 +1,48 @@
.h {
margin-top: 1em;
margin-bottom: 0.5em;
line-height: 1.5;
/* offset (approximately) with sticky header so jumped-to content isn't hiding behind it.
note: use rem so it isn't based on the heading's font size. */
scroll-margin-top: 5.5rem;
}
.h.divider {
padding-bottom: 0.25em;
border-bottom: 1px solid var(--colors-kindaLight);
}
.anchor {
margin: 0 0.4em;
padding: 0 0.2em;
color: var(--colors-medium) !important;
/* overridden on hover below (except on small screens) */
opacity: 0;
}
/* show anchor link when hovering anywhere over the heading line, or on keyboard tab focus */
.anchor:hover,
.anchor:focus-visible {
color: var(--colors-link) !important;
}
.h:hover .anchor,
.h:focus-visible .anchor {
opacity: 1;
}
@media (max-width: 768px) {
.h {
scroll-margin-top: 5.5rem;
}
.anchor {
margin: 0 0.2em;
padding: 0 0.4em;
/* don't require hover to show anchor link on small (likely touch) screens */
opacity: 1;
}
}

View File

@ -1,70 +1,27 @@
import innerText from "react-innertext";
import clsx from "clsx";
import HeadingAnchor from "../HeadingAnchor";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
import type { JSX, ComponentPropsWithoutRef } from "react";
const Anchor = styled(HeadingAnchor, {
margin: "0 0.4em",
padding: "0 0.2em",
color: theme.colors.medium,
opacity: 0, // overridden on hover below (except on small screens)
import styles from "./Heading.module.css";
"&:hover, &:focus-visible": {
color: theme.colors.link,
},
"@medium": {
margin: "0 0.2em",
padding: "0 0.4em",
// don't require hover to show anchor link on small (likely touch) screens
opacity: 1,
},
});
const H = styled("h1", {
marginTop: "1em",
marginBottom: "0.5em",
lineHeight: 1.5,
// offset (approximately) with sticky header so jumped-to content isn't hiding behind it.
// note: use rem so it isn't based on the heading's font size.
scrollMarginTop: "5.5rem",
"@medium": {
scrollMarginTop: "6.5rem",
},
// show anchor link when hovering anywhere over the heading line, or on keyboard tab focus
[`&:hover ${Anchor}, ${Anchor}:focus-visible`]: {
opacity: 1,
},
variants: {
// subtle horizontal rule under the heading, set by default on `<h2>`s
divider: {
true: {
paddingBottom: "0.25em",
borderBottom: `1px solid ${theme.colors.kindaLight}`,
},
false: {},
},
},
});
export type HeadingProps = ComponentPropsWithoutRef<typeof H> & {
export type HeadingProps = ComponentPropsWithoutRef<"h1"> & {
level: 1 | 2 | 3 | 4 | 5 | 6;
divider?: boolean;
};
const Heading = ({ level, id, divider, children, ...rest }: HeadingProps) => {
const Heading = ({ level, id, divider, className, children, ...rest }: HeadingProps) => {
const HWild: keyof JSX.IntrinsicElements = `h${level}`;
return (
<H as={`h${level}` as keyof JSX.IntrinsicElements} id={id} divider={divider || level === 2} {...rest}>
<HWild id={id} className={clsx(styles.h, (divider || level === 2) && styles.divider, className)} {...rest}>
{children}
{/* add anchor link to H2s and H3s. ID is either provided or automatically generated by rehype-slug. */}
{id && (level === 2 || level === 3) && <Anchor id={id} title={innerText(children)} />}
</H>
{id && (level === 2 || level === 3) && (
<HeadingAnchor id={id} title={innerText(children)} className={styles.anchor} />
)}
</HWild>
);
};

View File

@ -9,7 +9,14 @@ export type HeadingAnchorProps = Omit<ComponentPropsWithoutRef<typeof Link>, "hr
const HeadingAnchor = ({ id, title, ...rest }: HeadingAnchorProps) => {
return (
<Link href={`#${id}`} title={`Jump to "${title}"`} aria-hidden underline={false} css={{ lineHeight: 1 }} {...rest}>
<Link
href={`#${id}`}
title={`Jump to "${title}"`}
aria-hidden
underline={false}
style={{ lineHeight: 1 }}
{...rest}
>
<FiLink size="0.8em" />
</Link>
);

View File

@ -1,3 +1,5 @@
"use client";
import useSWRImmutable from "swr/immutable";
import { useErrorBoundary } from "react-error-boundary";
import commaNumber from "comma-number";

View File

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

View File

@ -1,10 +1,10 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const HorizontalRule = styled("hr", {
margin: "1.5em auto",
height: "0.175em",
border: 0,
backgroundColor: theme.colors.light,
});
import styles from "./HorizontalRule.module.css";
const HorizontalRule = ({ className, ...rest }: ComponentPropsWithoutRef<"hr">) => (
<hr className={clsx(styles.hr, className)} {...rest} />
);
export default HorizontalRule;

View File

@ -0,0 +1,7 @@
.iframe {
width: 100%;
display: block;
margin: 1em auto;
border: 2px solid var(--colors-kindaLight);
border-radius: var(--radii-corner);
}

View File

@ -1,15 +1,9 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const RoundedIFrame = styled("iframe", {
width: "100%",
display: "block",
margin: "1em auto",
border: `2px solid ${theme.colors.kindaLight}`,
borderRadius: theme.radii.corner,
});
import styles from "./IFrame.module.css";
export type IFrameProps = ComponentPropsWithoutRef<typeof RoundedIFrame> & {
export type IFrameProps = ComponentPropsWithoutRef<"iframe"> & {
src: string;
height: number;
width?: number; // defaults to 100%
@ -17,18 +11,19 @@ export type IFrameProps = ComponentPropsWithoutRef<typeof RoundedIFrame> & {
noScroll?: boolean;
};
const IFrame = ({ src, title, height, width, allowScripts, noScroll, css, ...rest }: IFrameProps) => {
const IFrame = ({ src, title, height, width, allowScripts, noScroll, className, style, ...rest }: IFrameProps) => {
return (
<RoundedIFrame
<iframe
src={src}
title={title}
sandbox={allowScripts ? "allow-same-origin allow-scripts allow-popups" : undefined}
scrolling={noScroll ? "no" : undefined}
loading="lazy"
css={{
className={clsx(styles.iframe, className)}
style={{
height: `${height}px`,
maxWidth: width ? `${width}px` : "100%",
...css,
...style,
}}
{...rest}
/>

View File

@ -0,0 +1,14 @@
.image {
height: auto;
max-width: 100%;
border-radius: var(--radii-corner);
}
.block {
display: block;
line-height: 0;
/* default to centering all images */
margin: 1em auto;
text-align: center;
}

View File

@ -1,33 +1,30 @@
import NextImage from "next/image";
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import Link, { LinkProps } from "../Link";
import type { ComponentPropsWithoutRef } from "react";
import type { ImageProps as NextImageProps, StaticImageData } from "next/image";
import styles from "./Image.module.css";
const DEFAULT_QUALITY = 60;
const DEFAULT_WIDTH = Number.parseInt(theme.sizes.maxLayoutWidth.value, 10); // see lib/styles/stitches.config.ts
const DEFAULT_WIDTH = 865;
const Block = styled("div", {
display: "block",
lineHeight: 0,
export type ImageProps = ComponentPropsWithoutRef<typeof NextImage> &
Partial<Pick<LinkProps, "href">> & {
inline?: boolean; // don't wrap everything in a `<div>` block
};
// default to centering all images
margin: "1em auto",
textAlign: "center",
});
const StyledImage = styled(NextImage, {
height: "auto",
maxWidth: "100%",
borderRadius: theme.radii.corner,
});
export type ImageProps = ComponentPropsWithoutRef<typeof StyledImage> & {
href?: string; // optionally wrap image in a link
inline?: boolean; // don't wrap everything in a `<div>` block
};
const Image = ({ src, width, height, quality = DEFAULT_QUALITY, placeholder, href, inline, ...rest }: ImageProps) => {
const Image = ({
src,
width,
height,
quality = DEFAULT_QUALITY,
placeholder,
href,
inline,
className,
...rest
}: ImageProps) => {
const imageProps: NextImageProps = {
// strip "px" from dimensions: https://stackoverflow.com/a/4860249/1438024
width: typeof width === "string" ? Number.parseInt(width, 10) : width,
@ -63,13 +60,13 @@ const Image = ({ src, width, height, quality = DEFAULT_QUALITY, placeholder, hre
const StyledImageWithProps = href ? (
<Link href={href} underline={false}>
<StyledImage {...imageProps} />
<NextImage className={clsx(styles.image, className)} {...imageProps} />
</Link>
) : (
<StyledImage {...imageProps} />
<NextImage className={clsx(styles.image, className)} {...imageProps} />
);
return inline ? StyledImageWithProps : <Block>{StyledImageWithProps}</Block>;
return inline ? StyledImageWithProps : <div className={styles.block}>{StyledImageWithProps}</div>;
};
export default Image;

View File

@ -0,0 +1,26 @@
.flex {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.default {
width: 100%;
padding: 1.5em;
}
.container {
max-width: var(--sizes-maxLayoutWidth);
margin: 0 auto;
display: block;
}
.stickyHeader {
position: sticky;
top: 0;
z-index: 1000;
}
.flexedFooter {
flex: 1;
}

View File

@ -1,64 +1,28 @@
import clsx from "clsx";
import Header from "../Header";
import Footer from "../Footer";
import { SkipToContentLink, SkipToContentTarget } from "../SkipToContent";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const Flex = styled("div", {
display: "flex",
flexDirection: "column",
minHeight: "100vh",
});
import styles from "./Layout.module.css";
const Default = styled("main", {
width: "100%",
padding: "1.5em",
});
export type LayoutProps = ComponentPropsWithoutRef<"div">;
const Container = styled("div", {
maxWidth: theme.sizes.maxLayoutWidth,
margin: "0 auto",
display: "block",
});
// stick header to the top of the page when scrolling
const StickyHeader = styled(Header, {
position: "sticky",
top: 0,
});
// footer needs to fill the remaining vertical screen space. doing it here to keep flex stuff together.
const FlexedFooter = styled(Footer, {
flex: 1,
});
export type LayoutProps = ComponentPropsWithoutRef<typeof Flex> & {
container?: boolean; // pass false to disable default `<main>` container styles with padding, etc.
};
const Layout = ({ container = true, children, ...rest }: LayoutProps) => {
const Layout = ({ className, children, ...rest }: LayoutProps) => {
return (
<>
<SkipToContentLink />
<Flex {...rest}>
<StickyHeader />
<div className={clsx(styles.flex, className)} {...rest}>
<Header className={styles.stickyHeader} />
{/* passing `container={false}` to Layout allows 100% control of the content area on a per-page basis */}
{container ? (
<Default>
<SkipToContentTarget />
<Container>{children}</Container>
</Default>
) : (
<>
<SkipToContentTarget />
{children}
</>
)}
<main className={styles.default}>
<SkipToContentTarget />
<div className={styles.container}>{children}</div>
</main>
<FlexedFooter />
</Flex>
<Footer className={styles.flexedFooter} />
</div>
</>
);
};

View File

@ -0,0 +1,23 @@
.link {
color: var(--colors-link);
text-decoration: none;
}
.link.underline {
background-image: linear-gradient(var(--colors-linkUnderline), var(--colors-linkUnderline));
background-position: 0% 100%;
background-repeat: no-repeat;
background-size: 0% 2px;
padding-bottom: 3px;
}
.link.underline:hover,
.link.underline:focus-visible {
background-size: 100% 2px;
}
@media (prefers-reduced-motion: no-preference) {
.link.underline {
transition: background-size var(--transitions-linkHover);
}
}

View File

@ -1,44 +1,25 @@
import NextLink from "next/link";
import clsx from "clsx";
import objStr from "obj-str";
import { styled, theme, stitchesConfig } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const StyledLink = styled(NextLink, {
color: theme.colors.link,
textDecoration: "none",
import styles from "./Link.module.css";
variants: {
underline: {
// fancy animated link underline effect (on by default)
true: {
// sets psuedo linear-gradient() for the underline's color; see stitches config for the weird calculation behind
// the local `$$underlineColor` variable.
...stitchesConfig.utils.setUnderlineColor({ color: "$colors$linkUnderline" }),
backgroundImage: "linear-gradient($$underlineColor, $$underlineColor)",
backgroundPosition: "0% 100%",
backgroundRepeat: "no-repeat",
backgroundSize: "0% 2px",
paddingBottom: "3px",
"@media (prefers-reduced-motion: no-preference)": {
transition: `background-size ${theme.transitions.linkHover}`,
},
"&:hover, &:focus-visible": {
backgroundSize: "100% 2px",
},
},
false: {},
},
},
});
export type LinkProps = ComponentPropsWithoutRef<typeof StyledLink> & {
export type LinkProps = ComponentPropsWithoutRef<typeof NextLink> & {
underline?: boolean;
openInNewTab?: boolean;
};
const Link = ({ href, rel, target, prefetch = false, underline = true, openInNewTab, ...rest }: LinkProps) => {
const Link = ({
href,
rel,
target,
prefetch = false,
underline = true,
openInNewTab,
className,
...rest
}: LinkProps) => {
// This component auto-detects whether or not this link should open in the same window (the default for internal
// links) or a new tab (the default for external links). Defaults can be overridden with `openInNewTab={true}`.
const isExternal =
@ -50,7 +31,7 @@ const Link = ({ href, rel, target, prefetch = false, underline = true, openInNew
if (openInNewTab || isExternal) {
return (
<StyledLink
<NextLink
href={href}
target={target || "_blank"}
rel={objStr({
@ -58,14 +39,20 @@ const Link = ({ href, rel, target, prefetch = false, underline = true, openInNew
noopener: true,
noreferrer: isExternal, // don't add "noreferrer" if link isn't external, and only opening in a new tab
})}
underline={underline}
prefetch={false}
className={clsx(styles.link, underline && styles.underline, className)}
{...rest}
/>
);
}
// If link is to an internal page, simply pass *everything* along as-is to next/link.
return <StyledLink {...{ href, rel, target, prefetch, underline, ...rest }} />;
return (
<NextLink
className={clsx(styles.link, underline && styles.underline, className)}
{...{ href, rel, target, prefetch, ...rest }}
/>
);
};
export default Link;

View File

@ -0,0 +1,8 @@
.list {
margin-left: 1.5em;
padding-left: 0;
}
.listItem {
padding-left: 0.25em;
}

View File

@ -1,15 +1,18 @@
import { styled, css } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const ListStyles = css({
marginLeft: "1.5em",
paddingLeft: 0,
});
import styles from "./List.module.css";
export const UnorderedList = styled("ul", ListStyles);
export const OrderedList = styled("ol", ListStyles);
export const UnorderedList = ({ className, ...rest }: ComponentPropsWithoutRef<"ul">) => (
<ul className={clsx(styles.list, className)} {...rest} />
);
export const ListItem = styled("li", {
paddingLeft: "0.25em",
});
export const OrderedList = ({ className, ...rest }: ComponentPropsWithoutRef<"ol">) => (
<ol className={clsx(styles.list, className)} {...rest} />
);
export const ListItem = ({ className, ...rest }: ComponentPropsWithoutRef<"li">) => (
<li className={clsx(styles.listItem, className)} {...rest} />
);
export default UnorderedList;

View File

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

View File

@ -1,32 +1,15 @@
import { styled, theme, keyframes } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("div", {
display: "inline-block",
textAlign: "center",
});
import styles from "./Loading.module.css";
const Box = styled("div", {
display: "inline-block",
height: "100%",
animation: `${keyframes({
"0%, 80%, 100%": {
transform: "scale(0)",
},
"40%": {
transform: "scale(0.6)",
},
})} 1.5s infinite ease-in-out both`,
backgroundColor: theme.colors.mediumLight,
});
export type LoadingProps = ComponentPropsWithoutRef<typeof Wrapper> & {
export type LoadingProps = ComponentPropsWithoutRef<"div"> & {
width: number; // of entire container, in pixels
boxes?: number; // total number of boxes (default: 3)
timing?: number; // staggered timing between each box's pulse, in seconds (default: 0.1s)
};
const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps) => {
const Loading = ({ width, boxes = 3, timing = 0.1, className, style, ...rest }: LoadingProps) => {
// each box is just an empty div
const divs = [];
@ -35,12 +18,11 @@ const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps)
// width of each box correlates with number of boxes (with a little padding)
// each individual box's animation has a staggered start in corresponding order
divs.push(
<Box
<div
key={i}
css={{
width: `${width / (boxes + 1)}px`,
}}
className={styles.box}
style={{
width: `${width / (boxes + 1)}px`,
animationDelay: `${i * timing}s`,
}}
/>
@ -48,16 +30,17 @@ const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps)
}
return (
<Wrapper
css={{
<div
className={clsx(styles.loading, className)}
style={{
width: `${width}px`,
height: `${width / 2}px`,
...css,
...style,
}}
{...rest}
>
{divs}
</Wrapper>
</div>
);
};

View File

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

View File

@ -1,65 +1,36 @@
import { useRouter } from "next/router";
"use client";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import MenuItem from "../MenuItem";
import ThemeToggle from "../ThemeToggle";
import { styled } from "../../lib/styles/stitches.config";
import { menuItems } from "../../lib/config/menu";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("ul", {
display: "inline-flex",
padding: 0,
margin: 0,
import styles from "./Menu.module.css";
"@medium": {
width: "100%",
justifyContent: "space-between",
marginLeft: "1em",
},
export type MenuProps = ComponentPropsWithoutRef<"ul">;
"@small": {
marginLeft: "1.4em",
},
});
const Item = styled("li", {
display: "inline-block",
marginLeft: "1em",
listStyle: "none",
"@medium": {
marginLeft: 0,
},
"@small": {
// the home icon is kinda redundant when space is SUPER tight
"&:first-of-type": {
display: "none",
},
},
});
export type MenuProps = ComponentPropsWithoutRef<typeof Wrapper>;
const Menu = ({ ...rest }: MenuProps) => {
const router = useRouter();
const Menu = ({ className, ...rest }: MenuProps) => {
const pathname = usePathname() || "";
return (
<Wrapper {...rest}>
<ul className={clsx(styles.menu, className)} {...rest}>
{menuItems.map((item, index) => {
// kinda weird/hacky way to determine if the *first part* of the current path matches this href
const isCurrent = item.href === `/${router.pathname.split("/")[1]}`;
const isCurrent = item.href === `/${pathname.split("/")[1]}`;
return (
<Item key={item.text || index}>
<li className={styles.menuItem} key={item.text || index}>
<MenuItem {...item} current={isCurrent} />
</Item>
</li>
);
})}
<Item>
<MenuItem icon={ThemeToggle} />
</Item>
</Wrapper>
<li className={styles.menuItem}>
<MenuItem Icon={ThemeToggle} />
</li>
</ul>
);
};

View File

@ -0,0 +1,42 @@
.link {
display: inline-block;
color: var(--colors-mediumDark) !important;
padding: 0.6em;
}
/* indicate active page/section */
.link.current {
margin-bottom: -0.2em;
border-bottom: 0.2em solid var(--colors-linkUnderline);
}
.link:not(.current):hover,
.link:not(.current):focus-visible {
margin-bottom: -0.2em;
border-bottom: 0.2em solid var(--colors-kindaLight);
}
.icon {
display: inline-block;
width: 1.25em;
height: 1.25em;
vertical-align: -0.3em;
}
.label {
font-size: 0.925em;
font-weight: 500;
letter-spacing: 0.025em;
margin-left: 0.7em;
}
@media (max-width: 768px) {
.icon {
width: 1.8em;
height: 1.8em;
}
.label {
display: none;
}
}

View File

@ -1,74 +1,38 @@
import clsx from "clsx";
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { Route } from "next";
import type { IconType } from "react-icons";
const MenuLink = styled(Link, {
display: "inline-block",
color: theme.colors.mediumDark,
padding: "0.6em",
variants: {
// indicate active page/section
current: {
true: {
marginBottom: "-0.2em",
borderBottom: `0.2em solid ${theme.colors.linkUnderline}`,
},
false: {
"&:hover, &:focus-visible": {
marginBottom: "-0.2em",
borderBottom: `0.2em solid ${theme.colors.kindaLight}`,
},
},
},
},
});
const Icon = styled("svg", {
display: "inline",
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.3em",
"@medium": {
width: "1.8em",
height: "1.8em",
},
});
const Label = styled("span", {
fontSize: "0.925em",
fontWeight: 500,
letterSpacing: "0.025em",
marginLeft: "0.7em",
"@medium": {
display: "none",
},
});
import styles from "./MenuItem.module.css";
export type MenuItemProps = {
icon?: IconType;
Icon?: IconType;
text?: string;
href?: string;
href?: Route;
current?: boolean;
className?: string;
};
const MenuItem = ({ icon, text, href, current, className }: MenuItemProps) => {
const MenuItem = ({ Icon, text, href, current, className }: MenuItemProps) => {
const item = (
<>
{icon && <Icon as={icon} />}
{text && <Label>{text}</Label>}
{Icon && <Icon className={styles.icon} />}
{text && <span className={styles.label}>{text}</span>}
</>
);
// allow both navigational links and/or other interactive react components (e.g. the theme toggle)
if (href) {
return (
<MenuLink href={href} className={className} current={current} title={text} underline={false} aria-label={text}>
<Link
href={href}
className={clsx(styles.link, current && styles.current, className)}
title={text}
underline={false}
aria-label={text}
>
{item}
</MenuLink>
</Link>
);
}

View File

@ -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;
}

View File

@ -1,14 +1,9 @@
import Link from "../Link";
import { SiGithub } from "react-icons/si";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const Octocat = styled(SiGithub, {
display: "inline",
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
});
import styles from "./OctocatLink.module.css";
import clsx from "clsx";
export type OctocatLinkProps = Omit<ComponentPropsWithoutRef<typeof Link>, "href"> & {
repo: string;
@ -16,20 +11,8 @@ export type OctocatLinkProps = Omit<ComponentPropsWithoutRef<typeof Link>, "href
const OctocatLink = ({ repo, className, ...rest }: OctocatLinkProps) => {
return (
<Link
href={`https://github.com/${repo}`}
underline={false}
css={{
margin: "0 0.4em",
color: theme.colors.text,
"&:hover, &:focus-visible": {
color: theme.colors.link,
},
}}
{...rest}
>
<Octocat className={className} />
<Link href={`https://github.com/${repo}`} underline={false} className={styles.octocatLink} {...rest}>
<SiGithub className={clsx(styles.octocat, className)} />
</Link>
);
};

View File

@ -0,0 +1,17 @@
.title {
margin-top: 0;
margin-bottom: 0.6em;
font-size: 1.7em;
font-weight: 600;
text-align: center;
}
.link {
color: var(--colors-text) !important;
}
@media (max-width: 768px) {
.title {
font-size: 1.8em;
}
}

View File

@ -1,31 +1,24 @@
import { useRouter } from "next/router";
"use client";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
import type { Route } from "next";
const Title = styled("h1", {
marginTop: 0,
marginBottom: "0.6em",
fontSize: "1.7em",
fontWeight: 600,
textAlign: "center",
import styles from "./PageTitle.module.css";
"@medium": {
fontSize: "1.8em",
},
});
export type PageTitleProps = ComponentPropsWithoutRef<"h1">;
export type PageTitleProps = ComponentPropsWithoutRef<typeof Title>;
const PageTitle = ({ children, ...rest }: PageTitleProps) => {
const router = useRouter();
const PageTitle = ({ className, children, ...rest }: PageTitleProps) => {
const pathname = usePathname() || "";
return (
<Title {...rest}>
<Link href={router.pathname} underline={false} css={{ color: theme.colors.text }}>
<h1 className={clsx(styles.title, className)} {...rest}>
<Link href={pathname as Route} underline={false} className={styles.link}>
{children}
</Link>
</Title>
</h1>
);
};

View File

@ -1,125 +0,0 @@
import { ErrorBoundary } from "react-error-boundary";
import Link from "../Link";
import Time from "../Time";
import HitCounter from "../HitCounter";
import PostTitle from "../PostTitle";
import { FiCalendar, FiTag, FiEdit, FiEye } from "react-icons/fi";
import { styled, theme } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { PostFrontMatter } from "../../types";
const Wrapper = styled("div", {
display: "inline-flex",
flexWrap: "wrap",
fontSize: "0.825em",
lineHeight: 2.3,
letterSpacing: "0.04em",
color: theme.colors.medium,
});
const MetaItem = styled("div", {
marginRight: "1.6em",
whiteSpace: "nowrap",
});
const MetaLink = styled(Link, {
color: "inherit",
});
const Icon = styled("svg", {
display: "inline",
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
marginRight: "0.6em",
});
const TagsList = styled("span", {
whiteSpace: "normal",
display: "inline-flex",
flexWrap: "wrap",
});
const Tag = styled("span", {
textTransform: "lowercase",
whiteSpace: "nowrap",
marginRight: "0.75em",
"&::before": {
content: "\\0023", // cosmetically hashtagify tags
paddingRight: "0.125em",
color: theme.colors.light,
},
"&:last-of-type": {
marginRight: 0,
},
});
export type PostMetaProps = Pick<PostFrontMatter, "slug" | "date" | "title" | "htmlTitle" | "tags">;
const PostMeta = ({ slug, date, title, htmlTitle, tags }: PostMetaProps) => {
return (
<>
<Wrapper>
<MetaItem>
<MetaLink
href={{
pathname: "/notes/[slug]/",
query: { slug },
}}
underline={false}
>
<Icon as={FiCalendar} />
<Time date={date} format="MMMM D, YYYY" />
</MetaLink>
</MetaItem>
{tags && (
<MetaItem>
<Icon as={FiTag} />
<TagsList>
{tags.map((tag) => (
<Tag key={tag} title={tag} aria-label={`Tagged with ${tag}`}>
{tag}
</Tag>
))}
</TagsList>
</MetaItem>
)}
<MetaItem>
<MetaLink
href={`https://github.com/${config.githubRepo}/blob/main/notes/${slug}.mdx`}
title={`Edit "${title}" on GitHub`}
underline={false}
>
<Icon as={FiEdit} />
<span>Improve This Post</span>
</MetaLink>
</MetaItem>
{/* only count hits on production site */}
{process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
<MetaItem
css={{
// fix potential layout shift when number of hits loads
minWidth: "7em",
marginRight: 0,
}}
>
{/* completely hide this block if anything goes wrong on the backend */}
<ErrorBoundary fallback={null}>
<Icon as={FiEye} />
<HitCounter slug={`notes/${slug}`} />
</ErrorBoundary>
</MetaItem>
)}
</Wrapper>
<PostTitle {...{ slug, title, htmlTitle }} />
</>
);
};
export default PostMeta;

View File

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

View File

@ -1,40 +0,0 @@
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
import type { PostFrontMatter } from "../../types";
const Title = styled("h1", {
margin: "0.3em 0 0.5em -1px", // misaligned left margin, super nitpicky
fontSize: "2.1em",
lineHeight: 1.3,
fontWeight: 700,
"& code": {
margin: "0 0.075em",
},
"@medium": {
fontSize: "1.8em",
},
});
export type PostTitleProps = Pick<PostFrontMatter, "slug" | "title" | "htmlTitle"> &
ComponentPropsWithoutRef<typeof Title>;
const PostTitle = ({ slug, title, htmlTitle, ...rest }: PostTitleProps) => {
return (
<Title {...rest}>
<Link
href={{
pathname: "/notes/[slug]/",
query: { slug },
}}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
underline={false}
css={{ color: theme.colors.text }}
/>
</Title>
);
};
export default PostTitle;

View File

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

Some files were not shown because too many files have changed in this diff Show More