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:
parent
e97613dda5
commit
8aabb4a66f
@ -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",
|
||||
|
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@ -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
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
|
||||
# node/npm/pnpm/yarn
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
@ -1 +1 @@
|
||||
20.18.1
|
||||
22.13.1
|
||||
|
@ -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;
|
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"prisma.prisma",
|
||||
"unifiedjs.vscode-mdx"
|
||||
|
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@ -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"
|
||||
}
|
||||
|
@ -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
31
app/api/count/route.ts
Normal 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
26
app/api/hits/route.ts
Normal 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
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
40
app/birthday/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
95
app/contact/form.module.css
Normal file
95
app/contact/form.module.css
Normal 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;
|
||||
}
|
@ -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
52
app/contact/page.tsx
Normal 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
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
11
app/feed.atom/route.ts
Normal 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
11
app/feed.xml/route.ts
Normal 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
21
app/global.css
Normal 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
66
app/hillary/page.tsx
Normal 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>
|
||||
. © 2016.
|
||||
</p>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
}
|
BIN
app/icon.png
Normal file
BIN
app/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
95
app/layout.tsx
Normal file
95
app/layout.tsx
Normal 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
62
app/leo/page.tsx
Normal 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>{" "}
|
||||
&{" "}
|
||||
<Link href="https://leolaporte.com/" style={{ fontWeight: 700 }}>
|
||||
Leo Laporte
|
||||
</Link>
|
||||
. © 2007 G4 Media, Inc.
|
||||
</p>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
29
app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
67
app/notes/[slug]/page.module.css
Normal file
67
app/notes/[slug]/page.module.css
Normal 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
178
app/notes/[slug]/page.tsx
Normal 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
51
app/notes/page.module.css
Normal 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
65
app/notes/page.tsx
Normal 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
100
app/page.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -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&fs=1&showinfo=1&rel=0&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": {
|
||||
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>
|
||||
,{" "}
|
||||
<ColorfulLink
|
||||
href="https://bsky.app/profile/jarv.is"
|
||||
rel="me"
|
||||
title="Jake Jarvis on Bluesky"
|
||||
lightColor="#0085FF"
|
||||
darkColor="#208BFE"
|
||||
>
|
||||
Bluesky
|
||||
</ColorfulLink>
|
||||
</Sup>
|
||||
,{" "}
|
||||
<ColorfulLink
|
||||
href="https://fediverse.jarv.is/@jake"
|
||||
@ -363,9 +288,7 @@ const Index = () => {
|
||||
SMS
|
||||
</ColorfulLink>{" "}
|
||||
as well!
|
||||
</Paragraph>
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
}
|
39
app/previously/page.module.css
Normal file
39
app/previously/page.module.css
Normal 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
220
app/previously/page.tsx
Normal 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™</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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
86
app/projects/page.module.css
Normal file
86
app/projects/page.module.css
Normal 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
175
app/projects/page.tsx
Normal 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
80
app/robots.ts
Normal 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
60
app/sitemap.ts
Normal 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
63
app/themes.css
Normal 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;
|
||||
}
|
@ -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
77
app/zip/page.tsx
Normal 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)" }}><3</span>
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ color: "var(--colors-codeAttribute)" }}>@monthly</span>
|
||||
<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>
|
||||
);
|
||||
}
|
6
components/Blockquote/Blockquote.module.css
Normal file
6
components/Blockquote/Blockquote.module.css
Normal 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);
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import clsx from "clsx";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Blockquote = styled("blockquote", {
|
||||
marginLeft: 0,
|
||||
paddingLeft: "1.25em",
|
||||
borderLeft: `0.25em solid ${theme.colors.link}`,
|
||||
color: theme.colors.mediumDark,
|
||||
});
|
||||
import styles from "./Blockquote.module.css";
|
||||
|
||||
const Blockquote = ({ className, ...rest }: ComponentPropsWithoutRef<"blockquote">) => (
|
||||
<blockquote className={clsx(styles.blockquote, className)} {...rest} />
|
||||
);
|
||||
|
||||
export default Blockquote;
|
||||
|
@ -1,27 +0,0 @@
|
||||
import Turnstile from "react-turnstile";
|
||||
import useHasMounted from "../../hooks/useHasMounted";
|
||||
import useTheme from "../../hooks/useTheme";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export type CaptchaProps = Omit<ComponentPropsWithoutRef<typeof Turnstile>, "sitekey"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Captcha = ({ theme, className, ...rest }: CaptchaProps) => {
|
||||
const hasMounted = useHasMounted();
|
||||
const { activeTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{hasMounted && (
|
||||
<Turnstile
|
||||
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
|
||||
theme={theme || (activeTheme === "dark" ? activeTheme : "light")}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Captcha;
|
@ -1,2 +0,0 @@
|
||||
export * from "./Captcha";
|
||||
export { default } from "./Captcha";
|
@ -1,10 +1,40 @@
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import CodeBlock from "../CodeBlock";
|
||||
import CodeInline from "../CodeInline";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
const Code = styled("code", {
|
||||
backgroundColor: theme.colors.codeBackground,
|
||||
border: `1px solid ${theme.colors.kindaLight}`,
|
||||
borderRadius: theme.radii.corner,
|
||||
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
|
||||
});
|
||||
export type CodeProps = PropsWithChildren<{
|
||||
forceBlock?: boolean;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
// a simple wrapper component that "intelligently" picks between inline code and code blocks (w/ optional syntax
|
||||
// highlighting & a clipboard button)
|
||||
const Code = ({ forceBlock, className, children, ...rest }: CodeProps) => {
|
||||
// detect if this input has already been touched by prism.js via rehype
|
||||
const classNames = className?.split(" ");
|
||||
const prismEnabled = classNames?.includes("code-highlight");
|
||||
|
||||
if (prismEnabled || forceBlock) {
|
||||
// full multi-line code blocks with copy-to-clipboard button
|
||||
// automatic if highlighted by prism, otherwise can be forced (rather than inlined) with `forceBlock={true}`
|
||||
return (
|
||||
<CodeBlock
|
||||
highlight={prismEnabled && !classNames?.includes("language-plaintext")}
|
||||
withCopyButton
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
// simple inline code in paragraphs, headings, etc. (never highlighted)
|
||||
return (
|
||||
<CodeInline className={className} {...rest}>
|
||||
{children}
|
||||
</CodeInline>
|
||||
);
|
||||
};
|
||||
|
||||
export default Code;
|
||||
|
105
components/CodeBlock/CodeBlock.module.css
Normal file
105
components/CodeBlock/CodeBlock.module.css
Normal 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);
|
||||
}
|
@ -1,105 +1,22 @@
|
||||
import Code from "../Code";
|
||||
import clsx from "clsx";
|
||||
import CopyButton from "../CopyButton";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Block = styled("div", {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
margin: "1em auto",
|
||||
color: theme.colors.codeText,
|
||||
import styles from "./CodeBlock.module.css";
|
||||
|
||||
[`& ${Code}`]: {
|
||||
display: "block",
|
||||
overflowX: "auto",
|
||||
padding: "1em",
|
||||
fontSize: "0.9em",
|
||||
tabSize: 2,
|
||||
|
||||
// optional line numbers added at time of prism compilation
|
||||
".line-number::before": {
|
||||
display: "inline-block",
|
||||
width: "1.5em",
|
||||
marginRight: "1.5em",
|
||||
textAlign: "right",
|
||||
color: theme.colors.codeComment,
|
||||
content: "attr(line)", // added as spans by prism
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
userSelect: "none",
|
||||
},
|
||||
|
||||
// leave room for clipboard button to the right of the first line
|
||||
".code-line:first-of-type": {
|
||||
marginRight: "3em",
|
||||
},
|
||||
},
|
||||
|
||||
variants: {
|
||||
highlight: {
|
||||
true: {
|
||||
// the following sub-classes MUST be global -- the prism rehype plugin isn't aware of this file
|
||||
".token": {
|
||||
"&.comment, &.prolog, &.cdata": {
|
||||
color: theme.colors.codeComment,
|
||||
},
|
||||
"&.delimiter, &.boolean, &.keyword, &.selector, &.important, &.doctype, &.atrule, &.url": {
|
||||
color: theme.colors.codeKeyword,
|
||||
},
|
||||
"&.tag, &.builtin, &.regex": {
|
||||
color: theme.colors.codeNamespace,
|
||||
},
|
||||
"&.property, &.constant, &.variable, &.attr-value, &.class-name, &.string, &.char": {
|
||||
color: theme.colors.codeVariable,
|
||||
},
|
||||
"&.literal-property, &.attr-name": {
|
||||
color: theme.colors.codeAttribute,
|
||||
},
|
||||
"&.function": {
|
||||
color: theme.colors.codeLiteral,
|
||||
},
|
||||
"&.tag .punctuation, &.attr-value .punctuation": {
|
||||
color: theme.colors.codePunctuation,
|
||||
},
|
||||
"&.inserted": {
|
||||
color: theme.colors.codeAddition,
|
||||
},
|
||||
"&.deleted": {
|
||||
color: theme.colors.codeDeletion,
|
||||
},
|
||||
"&.url": { textDecoration: "underline" },
|
||||
"&.bold": { fontWeight: "bold" },
|
||||
"&.italic": { fontStyle: "italic" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const CornerCopyButton = styled(CopyButton, {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
padding: "0.65em",
|
||||
backgroundColor: theme.colors.backgroundInner,
|
||||
border: `1px solid ${theme.colors.kindaLight}`,
|
||||
borderTopRightRadius: theme.radii.corner,
|
||||
borderBottomLeftRadius: theme.radii.corner,
|
||||
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
|
||||
});
|
||||
|
||||
export type CodeBlockProps = ComponentPropsWithoutRef<typeof Code> & {
|
||||
export type CodeBlockProps = ComponentPropsWithoutRef<"div"> & {
|
||||
highlight?: boolean;
|
||||
withCopyButton?: boolean;
|
||||
};
|
||||
|
||||
const CodeBlock = ({ highlight, withCopyButton, className, children, ...rest }: CodeBlockProps) => {
|
||||
return (
|
||||
<Block highlight={highlight}>
|
||||
{withCopyButton && <CornerCopyButton source={children} />}
|
||||
<Code className={className?.replace("code-highlight", "").trim()} {...rest}>
|
||||
<div className={clsx(styles.codeBlock, highlight && styles.highlight)}>
|
||||
{withCopyButton && <CopyButton className={styles.cornerCopyButton} source={children} />}
|
||||
<code className={clsx(styles.code, className)} {...rest}>
|
||||
{children}
|
||||
</Code>
|
||||
</Block>
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,40 +0,0 @@
|
||||
import CodeBlock from "../CodeBlock";
|
||||
import CodeInline from "../CodeInline";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export type CodeHybridProps = PropsWithChildren<{
|
||||
forceBlock?: boolean;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
// a simple wrapper component that "intelligently" picks between inline code and code blocks (w/ optional syntax
|
||||
// highlighting & a clipboard button)
|
||||
const CodeHybrid = ({ forceBlock, className, children, ...rest }: CodeHybridProps) => {
|
||||
// detect if this input has already been touched by prism.js via rehype
|
||||
const classNames = className?.split(" ");
|
||||
const prismEnabled = classNames?.includes("code-highlight");
|
||||
|
||||
if (prismEnabled || forceBlock) {
|
||||
// full multi-line code blocks with copy-to-clipboard button
|
||||
// automatic if highlighted by prism, otherwise can be forced (rather than inlined) with `forceBlock={true}`
|
||||
return (
|
||||
<CodeBlock
|
||||
highlight={prismEnabled && !classNames?.includes("language-plaintext")}
|
||||
withCopyButton
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
// simple inline code in paragraphs, headings, etc. (never highlighted)
|
||||
return (
|
||||
<CodeInline className={className} {...rest}>
|
||||
{children}
|
||||
</CodeInline>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeHybrid;
|
@ -1,2 +0,0 @@
|
||||
export * from "./CodeHybrid";
|
||||
export { default } from "./CodeHybrid";
|
11
components/CodeInline/CodeInline.module.css
Normal file
11
components/CodeInline/CodeInline.module.css
Normal 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);
|
||||
}
|
@ -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;
|
||||
|
3
components/CodePenEmbed/CodePenEmbed.module.css
Normal file
3
components/CodePenEmbed/CodePenEmbed.module.css
Normal file
@ -0,0 +1,3 @@
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
}
|
@ -1,11 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import IFrame from "../IFrame";
|
||||
import useHasMounted from "../../hooks/useHasMounted";
|
||||
import useTheme from "../../hooks/useTheme";
|
||||
import { styled } from "../../lib/styles/stitches.config";
|
||||
|
||||
const Wrapper = styled("div", {
|
||||
width: "100%",
|
||||
});
|
||||
import styles from "./CodePenEmbed.module.css";
|
||||
|
||||
export type CodePenEmbedProps = {
|
||||
username: string;
|
||||
@ -26,15 +22,10 @@ const CodePenEmbed = ({
|
||||
editable = false,
|
||||
className,
|
||||
}: CodePenEmbedProps) => {
|
||||
const hasMounted = useHasMounted();
|
||||
const { activeTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Wrapper className={className} css={{ height }}>
|
||||
{hasMounted && (
|
||||
<div className={clsx(styles.wrapper, className)} style={{ height }}>
|
||||
<IFrame
|
||||
src={`https://codepen.io/${username}/embed/${id}/?${new URLSearchParams({
|
||||
"theme-id": activeTheme === "dark" ? activeTheme : "light",
|
||||
"default-tab": `${defaultTab},result`,
|
||||
preview: `${!!preview}`,
|
||||
editable: `${!!editable}`,
|
||||
@ -43,8 +34,7 @@ const CodePenEmbed = ({
|
||||
allowScripts
|
||||
noScroll
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
6
components/Comments/Comments.module.css
Normal file
6
components/Comments/Comments.module.css
Normal file
@ -0,0 +1,6 @@
|
||||
.comments {
|
||||
margin-top: 2em;
|
||||
padding-top: 2em;
|
||||
border-top: 2px solid var(--colors-light);
|
||||
min-height: 360px;
|
||||
}
|
@ -1,22 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Giscus from "@giscus/react";
|
||||
import clsx from "clsx";
|
||||
import useTheme from "../../hooks/useTheme";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import config from "../../lib/config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { GiscusProps } from "@giscus/react";
|
||||
|
||||
const Wrapper = styled("div", {
|
||||
marginTop: "2em",
|
||||
paddingTop: "2em",
|
||||
borderTop: `2px solid ${theme.colors.light}`,
|
||||
minHeight: "360px",
|
||||
});
|
||||
import styles from "./Comments.module.css";
|
||||
|
||||
export type CommentsProps = ComponentPropsWithoutRef<typeof Wrapper> & {
|
||||
export type CommentsProps = ComponentPropsWithoutRef<"div"> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
const Comments = ({ title, ...rest }: CommentsProps) => {
|
||||
const Comments = ({ title, className, ...rest }: CommentsProps) => {
|
||||
const { activeTheme } = useTheme();
|
||||
|
||||
// fail silently if giscus isn't configured
|
||||
@ -27,7 +24,7 @@ const Comments = ({ title, ...rest }: CommentsProps) => {
|
||||
|
||||
// TODO: use custom `<Loading />` spinner component during suspense
|
||||
return (
|
||||
<Wrapper {...rest}>
|
||||
<div className={clsx(styles.comments, className)} {...rest}>
|
||||
<Giscus
|
||||
repo={config.githubRepo as GiscusProps["repo"]}
|
||||
repoId={config.giscusConfig.repoId}
|
||||
@ -38,10 +35,10 @@ const Comments = ({ title, ...rest }: CommentsProps) => {
|
||||
reactionsEnabled="1"
|
||||
emitMetadata="0"
|
||||
inputPosition="top"
|
||||
loading="eager" // still lazily loaded with react-intersection-observer
|
||||
loading="lazy"
|
||||
theme={activeTheme === "dark" ? activeTheme : "light"}
|
||||
/>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,2 +0,0 @@
|
||||
export * from "./ContactForm";
|
||||
export { default } from "./ContactForm";
|
12
components/Content/Content.module.css
Normal file
12
components/Content/Content.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
19
components/CopyButton/CopyButton.module.css
Normal file
19
components/CopyButton/CopyButton.module.css
Normal 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;
|
||||
}
|
@ -1,45 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useState, useEffect } from "react";
|
||||
import innerText from "react-innertext";
|
||||
import copy from "copy-to-clipboard";
|
||||
import clsx from "clsx";
|
||||
import { FiClipboard, FiCheck } from "react-icons/fi";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import type { ReactNode, Ref, ComponentPropsWithoutRef, ElementRef, MouseEventHandler } from "react";
|
||||
|
||||
const Button = styled("button", {
|
||||
lineHeight: 1,
|
||||
cursor: "pointer",
|
||||
import styles from "./CopyButton.module.css";
|
||||
|
||||
variants: {
|
||||
copied: {
|
||||
true: {
|
||||
color: theme.colors.success,
|
||||
},
|
||||
false: {
|
||||
color: theme.colors.mediumDark,
|
||||
|
||||
"&:hover, &:focus-visible": {
|
||||
color: theme.colors.link,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Icon = styled("svg", {
|
||||
width: "1.25em",
|
||||
height: "1.25em",
|
||||
verticalAlign: "-0.3em",
|
||||
});
|
||||
|
||||
export type CopyButtonProps = ComponentPropsWithoutRef<typeof Button> & {
|
||||
export type CopyButtonProps = ComponentPropsWithoutRef<"button"> & {
|
||||
source: string | ReactNode;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
const CopyButton = ({ source, timeout = 2000, ...rest }: CopyButtonProps, ref: Ref<ElementRef<typeof Button>>) => {
|
||||
const CopyButton = (
|
||||
{ source, timeout = 2000, className, ...rest }: CopyButtonProps,
|
||||
ref: Ref<ElementRef<"button">>
|
||||
) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy: MouseEventHandler<ElementRef<typeof Button>> = (e) => {
|
||||
const handleCopy: MouseEventHandler<ElementRef<"button">> = (e) => {
|
||||
// prevent unintentional double-clicks by unfocusing button
|
||||
e.currentTarget.blur();
|
||||
|
||||
@ -67,17 +48,17 @@ const CopyButton = ({ source, timeout = 2000, ...rest }: CopyButtonProps, ref: R
|
||||
}, [timeout, copied]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
ref={ref}
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy to clipboard"
|
||||
onClick={handleCopy}
|
||||
disabled={copied}
|
||||
copied={copied}
|
||||
className={clsx(styles.button, copied && styles.copied, className)}
|
||||
{...rest}
|
||||
>
|
||||
<Icon as={copied ? FiCheck : FiClipboard} />
|
||||
</Button>
|
||||
{copied ? <FiCheck className={styles.icon} /> : <FiClipboard className={styles.icon} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
11
components/Figure/Figure.module.css
Normal file
11
components/Figure/Figure.module.css
Normal 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;
|
||||
}
|
@ -1,19 +1,9 @@
|
||||
import innerText from "react-innertext";
|
||||
import clsx from "clsx";
|
||||
import Image from "../Image";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import type { PropsWithChildren, ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Wrapper = styled("figure", {
|
||||
margin: "1em auto",
|
||||
textAlign: "center",
|
||||
});
|
||||
|
||||
const Caption = styled("figcaption", {
|
||||
fontSize: "0.9em",
|
||||
lineHeight: 1.5,
|
||||
color: theme.colors.medium,
|
||||
marginTop: "-0.4em",
|
||||
});
|
||||
import styles from "./Figure.module.css";
|
||||
|
||||
export type FigureProps = Omit<ComponentPropsWithoutRef<typeof Image>, "alt"> &
|
||||
PropsWithChildren<{
|
||||
@ -22,10 +12,10 @@ export type FigureProps = Omit<ComponentPropsWithoutRef<typeof Image>, "alt"> &
|
||||
|
||||
const Figure = ({ children, alt, className, ...imageProps }: FigureProps) => {
|
||||
return (
|
||||
<Wrapper className={className}>
|
||||
<figure className={clsx(styles.figure, className)}>
|
||||
<Image alt={alt || innerText(children)} {...imageProps} />
|
||||
<Caption>{children}</Caption>
|
||||
</Wrapper>
|
||||
<figcaption className={styles.caption}>{children}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
|
92
components/Footer/Footer.module.css
Normal file
92
components/Footer/Footer.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -1,124 +1,57 @@
|
||||
import clsx from "clsx";
|
||||
import Link from "../Link";
|
||||
import { GoHeartFill } from "react-icons/go";
|
||||
import { SiNextdotjs } from "react-icons/si";
|
||||
import { styled, theme, keyframes } from "../../lib/styles/stitches.config";
|
||||
import config from "../../lib/config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Wrapper = styled("footer", {
|
||||
width: "100%",
|
||||
padding: "1.25em 1.5em",
|
||||
borderTop: `1px solid ${theme.colors.kindaLight}`,
|
||||
backgroundColor: theme.colors.backgroundOuter,
|
||||
color: theme.colors.mediumDark,
|
||||
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
|
||||
import styles from "./Footer.module.css";
|
||||
|
||||
"@medium": {
|
||||
padding: "1em 1.25em",
|
||||
},
|
||||
});
|
||||
export type FooterProps = ComponentPropsWithoutRef<"footer">;
|
||||
|
||||
const Row = styled("div", {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
maxWidth: theme.sizes.maxLayoutWidth,
|
||||
margin: "0 auto",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.8em",
|
||||
lineHeight: 2.3,
|
||||
|
||||
// stack columns on left instead of flexboxing across
|
||||
"@medium": {
|
||||
display: "block",
|
||||
},
|
||||
});
|
||||
|
||||
const PlainLink = styled(Link, {
|
||||
color: theme.colors.mediumDark,
|
||||
});
|
||||
|
||||
const Icon = styled("svg", {
|
||||
display: "inline",
|
||||
width: "1.25em",
|
||||
height: "1.25em",
|
||||
verticalAlign: "-0.25em",
|
||||
margin: "0 0.075em",
|
||||
});
|
||||
|
||||
const Heart = styled("span", {
|
||||
display: "inline-block",
|
||||
color: theme.colors.error, // somewhat ironically color the heart with the themed "error" red... </3
|
||||
|
||||
"@media (prefers-reduced-motion: no-preference)": {
|
||||
animation: `${keyframes({
|
||||
"0%": { transform: "scale(1)" },
|
||||
"2%": { transform: "scale(1.25)" },
|
||||
"4%": { transform: "scale(1)" },
|
||||
"6%": { transform: "scale(1.2)" },
|
||||
"8%": { transform: "scale(1)" },
|
||||
// pause for ~9 out of 10 seconds
|
||||
"100%": { transform: "scale(1)" },
|
||||
})} 10s ease 7.5s infinite`,
|
||||
willChange: "transform",
|
||||
},
|
||||
});
|
||||
|
||||
export type FooterProps = ComponentPropsWithoutRef<typeof Wrapper>;
|
||||
|
||||
const Footer = ({ ...rest }: FooterProps) => {
|
||||
const Footer = ({ className, ...rest }: FooterProps) => {
|
||||
return (
|
||||
<Wrapper {...rest}>
|
||||
<Row>
|
||||
<footer className={clsx(styles.footer, className)} {...rest}>
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
Content{" "}
|
||||
<PlainLink href="/license/" title={config.license} underline={false}>
|
||||
<Link href="/license" title={config.license} underline={false} className={styles.link}>
|
||||
licensed under {config.licenseAbbr}
|
||||
</PlainLink>
|
||||
</Link>
|
||||
,{" "}
|
||||
<PlainLink href="/previously/" title="Previously on..." underline={false}>
|
||||
<Link href="/previously" title="Previously on..." underline={false} className={styles.link}>
|
||||
{config.copyrightYearStart}
|
||||
</PlainLink>{" "}
|
||||
</Link>{" "}
|
||||
– {new Date(process.env.RELEASE_DATE || Date.now()).getUTCFullYear()}.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Made with{" "}
|
||||
<Heart title="Love">
|
||||
<Icon as={GoHeartFill} css={{ strokeWidth: 2 }} />
|
||||
</Heart>{" "}
|
||||
<span className={styles.heart} title="Love">
|
||||
<GoHeartFill className={styles.icon} style={{ strokeWidth: 2 }} />
|
||||
</span>{" "}
|
||||
and{" "}
|
||||
<PlainLink
|
||||
<Link
|
||||
href="https://nextjs.org/"
|
||||
title="Powered by Next.js"
|
||||
aria-label="Next.js"
|
||||
underline={false}
|
||||
css={{
|
||||
"&:hover, &:focus-visible": {
|
||||
color: theme.colors.medium,
|
||||
},
|
||||
}}
|
||||
className={clsx(styles.link, styles.hover)}
|
||||
>
|
||||
<Icon as={SiNextdotjs} />
|
||||
</PlainLink>
|
||||
<SiNextdotjs className={styles.icon} />
|
||||
</Link>
|
||||
.{" "}
|
||||
<PlainLink
|
||||
<Link
|
||||
href={`https://github.com/${config.githubRepo}`}
|
||||
title="View Source on GitHub"
|
||||
underline={false}
|
||||
css={{
|
||||
paddingBottom: "2px",
|
||||
borderBottom: `1px solid ${theme.colors.light}`,
|
||||
|
||||
"&:hover, &:focus-visible": {
|
||||
borderColor: theme.colors.kindaLight,
|
||||
},
|
||||
}}
|
||||
className={clsx(styles.link, styles.underline)}
|
||||
>
|
||||
View source.
|
||||
</PlainLink>
|
||||
</Link>
|
||||
</div>
|
||||
</Row>
|
||||
</Wrapper>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Frame from "react-frame-component";
|
||||
import useHasMounted from "../../hooks/useHasMounted";
|
||||
|
||||
|
82
components/Header/Header.module.css
Normal file
82
components/Header/Header.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -1,55 +1,38 @@
|
||||
import Selfie from "../Selfie";
|
||||
import clsx from "clsx";
|
||||
import Link from "../Link";
|
||||
import Image from "../Image";
|
||||
import Menu from "../Menu";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import config from "../../lib/config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Wrapper = styled("header", {
|
||||
width: "100%",
|
||||
height: "4.5em",
|
||||
padding: "0.7em 1.5em",
|
||||
borderBottom: `1px solid ${theme.colors.kindaLight}`,
|
||||
backgroundColor: theme.colors.backgroundHeader,
|
||||
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
|
||||
zIndex: 9999,
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
// blurry glass-like background effect (except on firefox...?)
|
||||
backdropFilter: "saturate(180%) blur(5px)",
|
||||
import selfieJpg from "../../public/static/images/selfie.jpg";
|
||||
|
||||
"@medium": {
|
||||
padding: "0.75em 1.25em",
|
||||
height: "5.9em",
|
||||
},
|
||||
});
|
||||
export type HeaderProps = ComponentPropsWithoutRef<"header">;
|
||||
|
||||
const Nav = styled("nav", {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
maxWidth: theme.sizes.maxLayoutWidth,
|
||||
margin: "0 auto",
|
||||
});
|
||||
|
||||
const ResponsiveMenu = styled(Menu, {
|
||||
"@medium": {
|
||||
maxWidth: "325px",
|
||||
},
|
||||
|
||||
"@small": {
|
||||
maxWidth: "225px",
|
||||
},
|
||||
});
|
||||
|
||||
export type HeaderProps = ComponentPropsWithoutRef<typeof Wrapper>;
|
||||
|
||||
const Header = ({ ...rest }: HeaderProps) => {
|
||||
const Header = ({ className, ...rest }: HeaderProps) => {
|
||||
return (
|
||||
<Wrapper {...rest}>
|
||||
<Nav>
|
||||
<Selfie />
|
||||
<ResponsiveMenu />
|
||||
</Nav>
|
||||
</Wrapper>
|
||||
<header className={clsx(styles.header, className)} {...rest}>
|
||||
<nav className={styles.nav}>
|
||||
<Link href="/" rel="author" title={config.authorName} underline={false} className={styles.selfieLink}>
|
||||
<Image
|
||||
src={selfieJpg}
|
||||
alt={`Photo of ${config.authorName}`}
|
||||
className={styles.selfieImage}
|
||||
width={70}
|
||||
height={70}
|
||||
quality={60}
|
||||
placeholder="empty"
|
||||
inline
|
||||
priority
|
||||
/>
|
||||
<span className={styles.name}>{config.authorName}</span>
|
||||
</Link>
|
||||
|
||||
<Menu className={styles.menu} />
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
48
components/Heading/Heading.module.css
Normal file
48
components/Heading/Heading.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -1,70 +1,27 @@
|
||||
import innerText from "react-innertext";
|
||||
import clsx from "clsx";
|
||||
import HeadingAnchor from "../HeadingAnchor";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { JSX, ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Anchor = styled(HeadingAnchor, {
|
||||
margin: "0 0.4em",
|
||||
padding: "0 0.2em",
|
||||
color: theme.colors.medium,
|
||||
opacity: 0, // overridden on hover below (except on small screens)
|
||||
import styles from "./Heading.module.css";
|
||||
|
||||
"&:hover, &:focus-visible": {
|
||||
color: theme.colors.link,
|
||||
},
|
||||
|
||||
"@medium": {
|
||||
margin: "0 0.2em",
|
||||
padding: "0 0.4em",
|
||||
|
||||
// don't require hover to show anchor link on small (likely touch) screens
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const H = styled("h1", {
|
||||
marginTop: "1em",
|
||||
marginBottom: "0.5em",
|
||||
lineHeight: 1.5,
|
||||
|
||||
// offset (approximately) with sticky header so jumped-to content isn't hiding behind it.
|
||||
// note: use rem so it isn't based on the heading's font size.
|
||||
scrollMarginTop: "5.5rem",
|
||||
|
||||
"@medium": {
|
||||
scrollMarginTop: "6.5rem",
|
||||
},
|
||||
|
||||
// show anchor link when hovering anywhere over the heading line, or on keyboard tab focus
|
||||
[`&:hover ${Anchor}, ${Anchor}:focus-visible`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
variants: {
|
||||
// subtle horizontal rule under the heading, set by default on `<h2>`s
|
||||
divider: {
|
||||
true: {
|
||||
paddingBottom: "0.25em",
|
||||
borderBottom: `1px solid ${theme.colors.kindaLight}`,
|
||||
},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type HeadingProps = ComponentPropsWithoutRef<typeof H> & {
|
||||
export type HeadingProps = ComponentPropsWithoutRef<"h1"> & {
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
const Heading = ({ level, id, divider, children, ...rest }: HeadingProps) => {
|
||||
const Heading = ({ level, id, divider, className, children, ...rest }: HeadingProps) => {
|
||||
const HWild: keyof JSX.IntrinsicElements = `h${level}`;
|
||||
|
||||
return (
|
||||
<H as={`h${level}` as keyof JSX.IntrinsicElements} id={id} divider={divider || level === 2} {...rest}>
|
||||
<HWild id={id} className={clsx(styles.h, (divider || level === 2) && styles.divider, className)} {...rest}>
|
||||
{children}
|
||||
|
||||
{/* add anchor link to H2s and H3s. ID is either provided or automatically generated by rehype-slug. */}
|
||||
{id && (level === 2 || level === 3) && <Anchor id={id} title={innerText(children)} />}
|
||||
</H>
|
||||
{id && (level === 2 || level === 3) && (
|
||||
<HeadingAnchor id={id} title={innerText(children)} className={styles.anchor} />
|
||||
)}
|
||||
</HWild>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -9,7 +9,14 @@ export type HeadingAnchorProps = Omit<ComponentPropsWithoutRef<typeof Link>, "hr
|
||||
|
||||
const HeadingAnchor = ({ id, title, ...rest }: HeadingAnchorProps) => {
|
||||
return (
|
||||
<Link href={`#${id}`} title={`Jump to "${title}"`} aria-hidden underline={false} css={{ lineHeight: 1 }} {...rest}>
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
title={`Jump to "${title}"`}
|
||||
aria-hidden
|
||||
underline={false}
|
||||
style={{ lineHeight: 1 }}
|
||||
{...rest}
|
||||
>
|
||||
<FiLink size="0.8em" />
|
||||
</Link>
|
||||
);
|
||||
|
@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import { useErrorBoundary } from "react-error-boundary";
|
||||
import commaNumber from "comma-number";
|
||||
|
6
components/HorizontalRule/HorizontalRule.module.css
Normal file
6
components/HorizontalRule/HorizontalRule.module.css
Normal file
@ -0,0 +1,6 @@
|
||||
.hr {
|
||||
margin: 1.5em auto;
|
||||
height: 0.175em;
|
||||
border: 0;
|
||||
background-color: var(--colors-light);
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import clsx from "clsx";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const HorizontalRule = styled("hr", {
|
||||
margin: "1.5em auto",
|
||||
height: "0.175em",
|
||||
border: 0,
|
||||
backgroundColor: theme.colors.light,
|
||||
});
|
||||
import styles from "./HorizontalRule.module.css";
|
||||
|
||||
const HorizontalRule = ({ className, ...rest }: ComponentPropsWithoutRef<"hr">) => (
|
||||
<hr className={clsx(styles.hr, className)} {...rest} />
|
||||
);
|
||||
|
||||
export default HorizontalRule;
|
||||
|
7
components/IFrame/IFrame.module.css
Normal file
7
components/IFrame/IFrame.module.css
Normal file
@ -0,0 +1,7 @@
|
||||
.iframe {
|
||||
width: 100%;
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
border: 2px solid var(--colors-kindaLight);
|
||||
border-radius: var(--radii-corner);
|
||||
}
|
@ -1,15 +1,9 @@
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import clsx from "clsx";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const RoundedIFrame = styled("iframe", {
|
||||
width: "100%",
|
||||
display: "block",
|
||||
margin: "1em auto",
|
||||
border: `2px solid ${theme.colors.kindaLight}`,
|
||||
borderRadius: theme.radii.corner,
|
||||
});
|
||||
import styles from "./IFrame.module.css";
|
||||
|
||||
export type IFrameProps = ComponentPropsWithoutRef<typeof RoundedIFrame> & {
|
||||
export type IFrameProps = ComponentPropsWithoutRef<"iframe"> & {
|
||||
src: string;
|
||||
height: number;
|
||||
width?: number; // defaults to 100%
|
||||
@ -17,18 +11,19 @@ export type IFrameProps = ComponentPropsWithoutRef<typeof RoundedIFrame> & {
|
||||
noScroll?: boolean;
|
||||
};
|
||||
|
||||
const IFrame = ({ src, title, height, width, allowScripts, noScroll, css, ...rest }: IFrameProps) => {
|
||||
const IFrame = ({ src, title, height, width, allowScripts, noScroll, className, style, ...rest }: IFrameProps) => {
|
||||
return (
|
||||
<RoundedIFrame
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
sandbox={allowScripts ? "allow-same-origin allow-scripts allow-popups" : undefined}
|
||||
scrolling={noScroll ? "no" : undefined}
|
||||
loading="lazy"
|
||||
css={{
|
||||
className={clsx(styles.iframe, className)}
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
maxWidth: width ? `${width}px` : "100%",
|
||||
...css,
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
|
14
components/Image/Image.module.css
Normal file
14
components/Image/Image.module.css
Normal 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;
|
||||
}
|
@ -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,
|
||||
|
||||
// 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
|
||||
export type ImageProps = ComponentPropsWithoutRef<typeof NextImage> &
|
||||
Partial<Pick<LinkProps, "href">> & {
|
||||
inline?: boolean; // don't wrap everything in a `<div>` block
|
||||
};
|
||||
|
||||
const Image = ({ src, width, height, quality = DEFAULT_QUALITY, placeholder, href, inline, ...rest }: ImageProps) => {
|
||||
const Image = ({
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
quality = DEFAULT_QUALITY,
|
||||
placeholder,
|
||||
href,
|
||||
inline,
|
||||
className,
|
||||
...rest
|
||||
}: ImageProps) => {
|
||||
const imageProps: NextImageProps = {
|
||||
// strip "px" from dimensions: https://stackoverflow.com/a/4860249/1438024
|
||||
width: typeof width === "string" ? Number.parseInt(width, 10) : width,
|
||||
@ -63,13 +60,13 @@ const Image = ({ src, width, height, quality = DEFAULT_QUALITY, placeholder, hre
|
||||
|
||||
const StyledImageWithProps = href ? (
|
||||
<Link href={href} underline={false}>
|
||||
<StyledImage {...imageProps} />
|
||||
<NextImage className={clsx(styles.image, className)} {...imageProps} />
|
||||
</Link>
|
||||
) : (
|
||||
<StyledImage {...imageProps} />
|
||||
<NextImage className={clsx(styles.image, className)} {...imageProps} />
|
||||
);
|
||||
|
||||
return inline ? StyledImageWithProps : <Block>{StyledImageWithProps}</Block>;
|
||||
return inline ? StyledImageWithProps : <div className={styles.block}>{StyledImageWithProps}</div>;
|
||||
};
|
||||
|
||||
export default Image;
|
||||
|
26
components/Layout/Layout.module.css
Normal file
26
components/Layout/Layout.module.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
<main className={styles.default}>
|
||||
<SkipToContentTarget />
|
||||
<Container>{children}</Container>
|
||||
</Default>
|
||||
) : (
|
||||
<>
|
||||
<SkipToContentTarget />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
<div className={styles.container}>{children}</div>
|
||||
</main>
|
||||
|
||||
<FlexedFooter />
|
||||
</Flex>
|
||||
<Footer className={styles.flexedFooter} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
23
components/Link/Link.module.css
Normal file
23
components/Link/Link.module.css
Normal 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);
|
||||
}
|
||||
}
|
@ -1,44 +1,25 @@
|
||||
import NextLink from "next/link";
|
||||
import clsx from "clsx";
|
||||
import objStr from "obj-str";
|
||||
import { styled, theme, stitchesConfig } from "../../lib/styles/stitches.config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const StyledLink = styled(NextLink, {
|
||||
color: theme.colors.link,
|
||||
textDecoration: "none",
|
||||
import styles from "./Link.module.css";
|
||||
|
||||
variants: {
|
||||
underline: {
|
||||
// fancy animated link underline effect (on by default)
|
||||
true: {
|
||||
// sets psuedo linear-gradient() for the underline's color; see stitches config for the weird calculation behind
|
||||
// the local `$$underlineColor` variable.
|
||||
...stitchesConfig.utils.setUnderlineColor({ color: "$colors$linkUnderline" }),
|
||||
|
||||
backgroundImage: "linear-gradient($$underlineColor, $$underlineColor)",
|
||||
backgroundPosition: "0% 100%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "0% 2px",
|
||||
paddingBottom: "3px",
|
||||
|
||||
"@media (prefers-reduced-motion: no-preference)": {
|
||||
transition: `background-size ${theme.transitions.linkHover}`,
|
||||
},
|
||||
|
||||
"&:hover, &:focus-visible": {
|
||||
backgroundSize: "100% 2px",
|
||||
},
|
||||
},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type LinkProps = ComponentPropsWithoutRef<typeof StyledLink> & {
|
||||
export type LinkProps = ComponentPropsWithoutRef<typeof NextLink> & {
|
||||
underline?: boolean;
|
||||
openInNewTab?: boolean;
|
||||
};
|
||||
|
||||
const Link = ({ href, rel, target, prefetch = false, underline = true, openInNewTab, ...rest }: LinkProps) => {
|
||||
const Link = ({
|
||||
href,
|
||||
rel,
|
||||
target,
|
||||
prefetch = false,
|
||||
underline = true,
|
||||
openInNewTab,
|
||||
className,
|
||||
...rest
|
||||
}: LinkProps) => {
|
||||
// This component auto-detects whether or not this link should open in the same window (the default for internal
|
||||
// links) or a new tab (the default for external links). Defaults can be overridden with `openInNewTab={true}`.
|
||||
const isExternal =
|
||||
@ -50,7 +31,7 @@ const Link = ({ href, rel, target, prefetch = false, underline = true, openInNew
|
||||
|
||||
if (openInNewTab || isExternal) {
|
||||
return (
|
||||
<StyledLink
|
||||
<NextLink
|
||||
href={href}
|
||||
target={target || "_blank"}
|
||||
rel={objStr({
|
||||
@ -58,14 +39,20 @@ const Link = ({ href, rel, target, prefetch = false, underline = true, openInNew
|
||||
noopener: true,
|
||||
noreferrer: isExternal, // don't add "noreferrer" if link isn't external, and only opening in a new tab
|
||||
})}
|
||||
underline={underline}
|
||||
prefetch={false}
|
||||
className={clsx(styles.link, underline && styles.underline, className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// If link is to an internal page, simply pass *everything* along as-is to next/link.
|
||||
return <StyledLink {...{ href, rel, target, prefetch, underline, ...rest }} />;
|
||||
return (
|
||||
<NextLink
|
||||
className={clsx(styles.link, underline && styles.underline, className)}
|
||||
{...{ href, rel, target, prefetch, ...rest }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Link;
|
||||
|
8
components/List/List.module.css
Normal file
8
components/List/List.module.css
Normal file
@ -0,0 +1,8 @@
|
||||
.list {
|
||||
margin-left: 1.5em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
padding-left: 0.25em;
|
||||
}
|
@ -1,15 +1,18 @@
|
||||
import { styled, css } from "../../lib/styles/stitches.config";
|
||||
import clsx from "clsx";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const ListStyles = css({
|
||||
marginLeft: "1.5em",
|
||||
paddingLeft: 0,
|
||||
});
|
||||
import styles from "./List.module.css";
|
||||
|
||||
export const UnorderedList = styled("ul", ListStyles);
|
||||
export const OrderedList = styled("ol", ListStyles);
|
||||
export const UnorderedList = ({ className, ...rest }: ComponentPropsWithoutRef<"ul">) => (
|
||||
<ul className={clsx(styles.list, className)} {...rest} />
|
||||
);
|
||||
|
||||
export const ListItem = styled("li", {
|
||||
paddingLeft: "0.25em",
|
||||
});
|
||||
export const OrderedList = ({ className, ...rest }: ComponentPropsWithoutRef<"ol">) => (
|
||||
<ol className={clsx(styles.list, className)} {...rest} />
|
||||
);
|
||||
|
||||
export const ListItem = ({ className, ...rest }: ComponentPropsWithoutRef<"li">) => (
|
||||
<li className={clsx(styles.listItem, className)} {...rest} />
|
||||
);
|
||||
|
||||
export default UnorderedList;
|
||||
|
22
components/Loading/Loading.module.css
Normal file
22
components/Loading/Loading.module.css
Normal 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);
|
||||
}
|
||||
}
|
@ -1,32 +1,15 @@
|
||||
import { styled, theme, keyframes } from "../../lib/styles/stitches.config";
|
||||
import clsx from "clsx";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Wrapper = styled("div", {
|
||||
display: "inline-block",
|
||||
textAlign: "center",
|
||||
});
|
||||
import styles from "./Loading.module.css";
|
||||
|
||||
const Box = styled("div", {
|
||||
display: "inline-block",
|
||||
height: "100%",
|
||||
animation: `${keyframes({
|
||||
"0%, 80%, 100%": {
|
||||
transform: "scale(0)",
|
||||
},
|
||||
"40%": {
|
||||
transform: "scale(0.6)",
|
||||
},
|
||||
})} 1.5s infinite ease-in-out both`,
|
||||
backgroundColor: theme.colors.mediumLight,
|
||||
});
|
||||
|
||||
export type LoadingProps = ComponentPropsWithoutRef<typeof Wrapper> & {
|
||||
export type LoadingProps = ComponentPropsWithoutRef<"div"> & {
|
||||
width: number; // of entire container, in pixels
|
||||
boxes?: number; // total number of boxes (default: 3)
|
||||
timing?: number; // staggered timing between each box's pulse, in seconds (default: 0.1s)
|
||||
};
|
||||
|
||||
const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps) => {
|
||||
const Loading = ({ width, boxes = 3, timing = 0.1, className, style, ...rest }: LoadingProps) => {
|
||||
// each box is just an empty div
|
||||
const divs = [];
|
||||
|
||||
@ -35,12 +18,11 @@ const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps)
|
||||
// width of each box correlates with number of boxes (with a little padding)
|
||||
// each individual box's animation has a staggered start in corresponding order
|
||||
divs.push(
|
||||
<Box
|
||||
<div
|
||||
key={i}
|
||||
css={{
|
||||
width: `${width / (boxes + 1)}px`,
|
||||
}}
|
||||
className={styles.box}
|
||||
style={{
|
||||
width: `${width / (boxes + 1)}px`,
|
||||
animationDelay: `${i * timing}s`,
|
||||
}}
|
||||
/>
|
||||
@ -48,16 +30,17 @@ const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
css={{
|
||||
<div
|
||||
className={clsx(styles.loading, className)}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${width / 2}px`,
|
||||
...css,
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{divs}
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
34
components/Menu/Menu.module.css
Normal file
34
components/Menu/Menu.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -1,65 +1,36 @@
|
||||
import { useRouter } from "next/router";
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import MenuItem from "../MenuItem";
|
||||
import ThemeToggle from "../ThemeToggle";
|
||||
import { styled } from "../../lib/styles/stitches.config";
|
||||
import { menuItems } from "../../lib/config/menu";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Wrapper = styled("ul", {
|
||||
display: "inline-flex",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
import styles from "./Menu.module.css";
|
||||
|
||||
"@medium": {
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
marginLeft: "1em",
|
||||
},
|
||||
export type MenuProps = ComponentPropsWithoutRef<"ul">;
|
||||
|
||||
"@small": {
|
||||
marginLeft: "1.4em",
|
||||
},
|
||||
});
|
||||
|
||||
const Item = styled("li", {
|
||||
display: "inline-block",
|
||||
marginLeft: "1em",
|
||||
listStyle: "none",
|
||||
|
||||
"@medium": {
|
||||
marginLeft: 0,
|
||||
},
|
||||
|
||||
"@small": {
|
||||
// the home icon is kinda redundant when space is SUPER tight
|
||||
"&:first-of-type": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type MenuProps = ComponentPropsWithoutRef<typeof Wrapper>;
|
||||
|
||||
const Menu = ({ ...rest }: MenuProps) => {
|
||||
const router = useRouter();
|
||||
const Menu = ({ className, ...rest }: MenuProps) => {
|
||||
const pathname = usePathname() || "";
|
||||
|
||||
return (
|
||||
<Wrapper {...rest}>
|
||||
<ul className={clsx(styles.menu, className)} {...rest}>
|
||||
{menuItems.map((item, index) => {
|
||||
// kinda weird/hacky way to determine if the *first part* of the current path matches this href
|
||||
const isCurrent = item.href === `/${router.pathname.split("/")[1]}`;
|
||||
const isCurrent = item.href === `/${pathname.split("/")[1]}`;
|
||||
|
||||
return (
|
||||
<Item key={item.text || index}>
|
||||
<li className={styles.menuItem} key={item.text || index}>
|
||||
<MenuItem {...item} current={isCurrent} />
|
||||
</Item>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<Item>
|
||||
<MenuItem icon={ThemeToggle} />
|
||||
</Item>
|
||||
</Wrapper>
|
||||
<li className={styles.menuItem}>
|
||||
<MenuItem Icon={ThemeToggle} />
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
|
42
components/MenuItem/MenuItem.module.css
Normal file
42
components/MenuItem/MenuItem.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
16
components/OctocatLink/OctocatLink.module.css
Normal file
16
components/OctocatLink/OctocatLink.module.css
Normal 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;
|
||||
}
|
@ -1,14 +1,9 @@
|
||||
import Link from "../Link";
|
||||
import { SiGithub } from "react-icons/si";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Octocat = styled(SiGithub, {
|
||||
display: "inline",
|
||||
width: "1.2em",
|
||||
height: "1.2em",
|
||||
verticalAlign: "-0.2em",
|
||||
});
|
||||
import styles from "./OctocatLink.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type OctocatLinkProps = Omit<ComponentPropsWithoutRef<typeof Link>, "href"> & {
|
||||
repo: string;
|
||||
@ -16,20 +11,8 @@ export type OctocatLinkProps = Omit<ComponentPropsWithoutRef<typeof Link>, "href
|
||||
|
||||
const OctocatLink = ({ repo, className, ...rest }: OctocatLinkProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={`https://github.com/${repo}`}
|
||||
underline={false}
|
||||
css={{
|
||||
margin: "0 0.4em",
|
||||
color: theme.colors.text,
|
||||
|
||||
"&:hover, &:focus-visible": {
|
||||
color: theme.colors.link,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Octocat className={className} />
|
||||
<Link href={`https://github.com/${repo}`} underline={false} className={styles.octocatLink} {...rest}>
|
||||
<SiGithub className={clsx(styles.octocat, className)} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
17
components/PageTitle/PageTitle.module.css
Normal file
17
components/PageTitle/PageTitle.module.css
Normal 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;
|
||||
}
|
||||
}
|
@ -1,31 +1,24 @@
|
||||
import { useRouter } from "next/router";
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import Link from "../Link";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { Route } from "next";
|
||||
|
||||
const Title = styled("h1", {
|
||||
marginTop: 0,
|
||||
marginBottom: "0.6em",
|
||||
fontSize: "1.7em",
|
||||
fontWeight: 600,
|
||||
textAlign: "center",
|
||||
import styles from "./PageTitle.module.css";
|
||||
|
||||
"@medium": {
|
||||
fontSize: "1.8em",
|
||||
},
|
||||
});
|
||||
export type PageTitleProps = ComponentPropsWithoutRef<"h1">;
|
||||
|
||||
export type PageTitleProps = ComponentPropsWithoutRef<typeof Title>;
|
||||
|
||||
const PageTitle = ({ children, ...rest }: PageTitleProps) => {
|
||||
const router = useRouter();
|
||||
const PageTitle = ({ className, children, ...rest }: PageTitleProps) => {
|
||||
const pathname = usePathname() || "";
|
||||
|
||||
return (
|
||||
<Title {...rest}>
|
||||
<Link href={router.pathname} underline={false} css={{ color: theme.colors.text }}>
|
||||
<h1 className={clsx(styles.title, className)} {...rest}>
|
||||
<Link href={pathname as Route} underline={false} className={styles.link}>
|
||||
{children}
|
||||
</Link>
|
||||
</Title>
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,125 +0,0 @@
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import Link from "../Link";
|
||||
import Time from "../Time";
|
||||
import HitCounter from "../HitCounter";
|
||||
import PostTitle from "../PostTitle";
|
||||
import { FiCalendar, FiTag, FiEdit, FiEye } from "react-icons/fi";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import config from "../../lib/config";
|
||||
import type { PostFrontMatter } from "../../types";
|
||||
|
||||
const Wrapper = styled("div", {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
fontSize: "0.825em",
|
||||
lineHeight: 2.3,
|
||||
letterSpacing: "0.04em",
|
||||
color: theme.colors.medium,
|
||||
});
|
||||
|
||||
const MetaItem = styled("div", {
|
||||
marginRight: "1.6em",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
const MetaLink = styled(Link, {
|
||||
color: "inherit",
|
||||
});
|
||||
|
||||
const Icon = styled("svg", {
|
||||
display: "inline",
|
||||
width: "1.2em",
|
||||
height: "1.2em",
|
||||
verticalAlign: "-0.2em",
|
||||
marginRight: "0.6em",
|
||||
});
|
||||
|
||||
const TagsList = styled("span", {
|
||||
whiteSpace: "normal",
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
});
|
||||
|
||||
const Tag = styled("span", {
|
||||
textTransform: "lowercase",
|
||||
whiteSpace: "nowrap",
|
||||
marginRight: "0.75em",
|
||||
|
||||
"&::before": {
|
||||
content: "\\0023", // cosmetically hashtagify tags
|
||||
paddingRight: "0.125em",
|
||||
color: theme.colors.light,
|
||||
},
|
||||
|
||||
"&:last-of-type": {
|
||||
marginRight: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export type PostMetaProps = Pick<PostFrontMatter, "slug" | "date" | "title" | "htmlTitle" | "tags">;
|
||||
|
||||
const PostMeta = ({ slug, date, title, htmlTitle, tags }: PostMetaProps) => {
|
||||
return (
|
||||
<>
|
||||
<Wrapper>
|
||||
<MetaItem>
|
||||
<MetaLink
|
||||
href={{
|
||||
pathname: "/notes/[slug]/",
|
||||
query: { slug },
|
||||
}}
|
||||
underline={false}
|
||||
>
|
||||
<Icon as={FiCalendar} />
|
||||
<Time date={date} format="MMMM D, YYYY" />
|
||||
</MetaLink>
|
||||
</MetaItem>
|
||||
|
||||
{tags && (
|
||||
<MetaItem>
|
||||
<Icon as={FiTag} />
|
||||
<TagsList>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} title={tag} aria-label={`Tagged with ${tag}`}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</TagsList>
|
||||
</MetaItem>
|
||||
)}
|
||||
|
||||
<MetaItem>
|
||||
<MetaLink
|
||||
href={`https://github.com/${config.githubRepo}/blob/main/notes/${slug}.mdx`}
|
||||
title={`Edit "${title}" on GitHub`}
|
||||
underline={false}
|
||||
>
|
||||
<Icon as={FiEdit} />
|
||||
<span>Improve This Post</span>
|
||||
</MetaLink>
|
||||
</MetaItem>
|
||||
|
||||
{/* only count hits on production site */}
|
||||
{process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
|
||||
<MetaItem
|
||||
css={{
|
||||
// fix potential layout shift when number of hits loads
|
||||
minWidth: "7em",
|
||||
marginRight: 0,
|
||||
}}
|
||||
>
|
||||
{/* completely hide this block if anything goes wrong on the backend */}
|
||||
<ErrorBoundary fallback={null}>
|
||||
<Icon as={FiEye} />
|
||||
<HitCounter slug={`notes/${slug}`} />
|
||||
</ErrorBoundary>
|
||||
</MetaItem>
|
||||
)}
|
||||
</Wrapper>
|
||||
|
||||
<PostTitle {...{ slug, title, htmlTitle }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostMeta;
|
@ -1,2 +0,0 @@
|
||||
export * from "./PostMeta";
|
||||
export { default } from "./PostMeta";
|
@ -1,40 +0,0 @@
|
||||
import Link from "../Link";
|
||||
import { styled, theme } from "../../lib/styles/stitches.config";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { PostFrontMatter } from "../../types";
|
||||
|
||||
const Title = styled("h1", {
|
||||
margin: "0.3em 0 0.5em -1px", // misaligned left margin, super nitpicky
|
||||
fontSize: "2.1em",
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 700,
|
||||
|
||||
"& code": {
|
||||
margin: "0 0.075em",
|
||||
},
|
||||
|
||||
"@medium": {
|
||||
fontSize: "1.8em",
|
||||
},
|
||||
});
|
||||
|
||||
export type PostTitleProps = Pick<PostFrontMatter, "slug" | "title" | "htmlTitle"> &
|
||||
ComponentPropsWithoutRef<typeof Title>;
|
||||
|
||||
const PostTitle = ({ slug, title, htmlTitle, ...rest }: PostTitleProps) => {
|
||||
return (
|
||||
<Title {...rest}>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/notes/[slug]/",
|
||||
query: { slug },
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
|
||||
underline={false}
|
||||
css={{ color: theme.colors.text }}
|
||||
/>
|
||||
</Title>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostTitle;
|
@ -1,2 +0,0 @@
|
||||
export * from "./PostTitle";
|
||||
export { default } from "./PostTitle";
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user