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

server all the actions!

This commit is contained in:
Jake Jarvis 2025-02-08 12:37:41 -05:00
parent fa5edc003f
commit 37375b766f
Signed by: jake
SSH Key Fingerprint: SHA256:nCkvAjYA6XaSPUqc4TfbBQTpzr8Xj7ritg/sGInCdkc
27 changed files with 689 additions and 707 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "Node.js & TypeScript", "name": "Node.js",
"image": "mcr.microsoft.com/devcontainers/typescript-node:22-bookworm", "image": "mcr.microsoft.com/devcontainers/javascript-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'", "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": { "customizations": {
"vscode": { "vscode": {

View File

@ -23,7 +23,7 @@ Most production steps are handled [automatically by Vercel](https://vercel.com/d
- [🕰️ /previously](https://jarv.is/previously/) An embarrassing trip down this site's memory lane. - [🕰️ /previously](https://jarv.is/previously/) An embarrassing trip down this site's memory lane.
- Visit [/y2k](https://jarv.is/y2k/) if you want to experience the _fully_ immersive time machine, but don't say I didn't warn you... - Visit [/y2k](https://jarv.is/y2k/) if you want to experience the _fully_ immersive time machine, but don't say I didn't warn you...
- [🧅 Tor (.onion) mirror](http://jarvis2i2vp4j4tbxjogsnqdemnte5xhzyi7hziiyzxwge3hzmh57zad.onion/) For an excessive level of privacy and security. - [🧅 Tor (.onion) mirror](http://jarvis2i2vp4j4tbxjogsnqdemnte5xhzyi7hziiyzxwge3hzmh57zad.onion/) For an excessive level of privacy and security.
- [🧮 jakejarvis/website-stats](https://github.com/jakejarvis/website-stats) Daily raw snapshots of the [hit counter](pages/api/hits.ts) database. - [🧮 jakejarvis/website-stats](https://github.com/jakejarvis/website-stats) Daily raw snapshots of the [hit counter](app/api/hits/route.ts) database.
- [🔗 jakejarvis/jrvs.io](https://github.com/jakejarvis/jrvs.io) Personal link shortener. - [🔗 jakejarvis/jrvs.io](https://github.com/jakejarvis/jrvs.io) Personal link shortener.
## 📜 License ## 📜 License

View File

@ -1,96 +0,0 @@
import nodemailer from "nodemailer";
import fetcher from "../../../lib/helpers/fetcher";
import config from "../../../lib/config";
import { headers } from "next/headers";
import { NextResponse, NextRequest } from "next/server";
export async function POST(req: NextRequest): Promise<
NextResponse<{
success?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error?: any;
} | null>
> {
try {
// possible weirdness? https://github.com/orgs/vercel/discussions/78#discussioncomment-5089059
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.get("name") || !data.get("email") || !data.get("message")) {
// all fields are required
throw new Error("missing_data");
}
if (
!data.get("cf-turnstile-response") ||
!(await validateCaptcha(
data.get("cf-turnstile-response"),
headersList.get("x-forwarded-for") || headersList.get("x-real-ip") || ""
))
) {
// either the captcha is wrong or completely missing
throw new Error("invalid_captcha");
}
// throw an internal error, not user's fault
if (!(await sendMessage(data))) {
throw new Error("nodemailer_error");
}
// success! let the client know
return NextResponse.json({ success: true }, { status: 201 });
} catch (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any
) {
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", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// fallback to dummy secret for testing: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
response: formResponse,
remoteip: ip,
}),
});
return response?.success;
};
const sendMessage = async (data: FormData): Promise<boolean> => {
try {
const transporter = nodemailer.createTransport({
// https://resend.com/docs/send-with-nodemailer-smtp
host: "smtp.resend.com",
secure: true,
port: 465,
auth: {
user: "resend",
pass: process.env.RESEND_API_KEY,
},
});
await transporter.sendMail({
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.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.get("message")}`,
});
} catch (error) {
console.error(error);
return false;
}
return true;
};

View File

@ -1,31 +0,0 @@
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 });
}

View File

@ -1,10 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "../../../lib/helpers/prisma"; import { prisma } from "../../../lib/helpers/prisma";
import type { SiteStats } from "../../../types"; import type { hits as Hits } from "@prisma/client";
export const revalidate = 900; // 15 mins export const revalidate = 900; // 15 mins
export async function GET(): Promise<NextResponse<SiteStats>> { export async function GET(): Promise<
NextResponse<{
total: Pick<Hits, "hits">;
pages: Hits[];
}>
> {
// fetch all rows from db sorted by most hits // fetch all rows from db sorted by most hits
const pages = await prisma.hits.findMany({ const pages = await prisma.hits.findMany({
orderBy: [ orderBy: [

60
app/contact/actions.ts Normal file
View File

@ -0,0 +1,60 @@
"use server";
import { Resend } from "resend";
import config from "../../lib/config";
export async function sendMessage(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prevState: any,
formData: FormData
): Promise<{
success: boolean;
message: string;
payload: FormData;
}> {
try {
// 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 (!formData.get("name") || !formData.get("email") || !formData.get("message")) {
return { success: false, message: "Please make sure that all fields are properly filled in.", payload: formData };
}
// validate captcha
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
response: formData.get("cf-turnstile-response"),
}),
});
const turnstileData = await turnstileResponse.json();
if (!turnstileData.success) {
return {
success: false,
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
payload: formData,
};
}
// send email
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: `${formData.get("name")} <${process.env.RESEND_DOMAIN ? `noreply@${process.env.RESEND_DOMAIN}` : "onboarding@resend.dev"}>`,
replyTo: `${formData.get("name")} <${formData.get("email")}>`,
to: [config.authorEmail],
subject: `[${config.siteDomain}] Contact Form Submission`,
text: formData.get("message") as string,
});
return { success: true, message: "Thanks! You should hear from me soon.", payload: formData };
} catch (error) {
console.error(error);
return {
success: false,
message: "Internal server error... Try again later or shoot me an old-fashioned email?",
payload: formData,
};
}
}

View File

@ -25,23 +25,6 @@
resize: vertical; 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 { .actionRow {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1,199 +1,114 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useActionState } from "react";
import { Formik, Form, Field } from "formik";
import TextareaAutosize from "react-textarea-autosize"; import TextareaAutosize from "react-textarea-autosize";
import Turnstile from "react-turnstile"; import Turnstile from "react-turnstile";
import clsx from "clsx"; import clsx from "clsx";
import Link from "../../components/Link"; import Link from "../../components/Link";
import useTheme from "../../hooks/useTheme"; import useTheme from "../../hooks/useTheme";
import { sendMessage } from "./actions";
import { GoCheck, GoX } from "react-icons/go"; import { GoCheck, GoX } from "react-icons/go";
import { SiMarkdown } from "react-icons/si"; import { SiMarkdown } from "react-icons/si";
import type { FormikHelpers, FormikProps, FieldInputProps, FieldMetaProps } from "formik";
import styles from "./form.module.css"; import styles from "./form.module.css";
type FormValues = { const ContactForm = () => {
name: string;
email: string;
message: string;
"cf-turnstile-response": string;
};
export type ContactFormProps = {
className?: string;
};
const ContactForm = ({ className }: ContactFormProps) => {
const { activeTheme } = useTheme(); const { activeTheme } = useTheme();
const [formState, formAction, pending] = useActionState<
// status/feedback: Partial<{ success: boolean; message: string; payload: FormData }>,
const [submitted, setSubmitted] = useState(false); FormData
const [success, setSuccess] = useState(false); >(sendMessage, {});
const [feedback, setFeedback] = useState(""); const [turnstileToken, setTurnstileToken] = useState<string>("");
const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers<FormValues>) => {
// once a user attempts a submission, this is true and stays true whether or not the next attempt(s) are successful
setSubmitted(true);
// 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: {
Accept: "application/json",
},
body: formData,
})
.then((response) => response.json())
.then((data) => {
if (data.success === true) {
// handle successful submission
// disable submissions, hide the send button, and let user know we were successful
setSuccess(true);
setFeedback("Thanks! You should hear from me soon.");
} else {
// pass on any error sent by the server to the catch block below
throw new Error(data.message);
}
})
.catch((error) => {
setSuccess(false);
if (error.message === "missing_data") {
// this should be validated client-side but it's also checked server-side just in case someone slipped past
setFeedback("Please make sure that all fields are properly filled in.");
} else if (error.message === "invalid_captcha") {
// missing/invalid captcha
setFeedback("Did you complete the CAPTCHA? (If you're human, that is...)");
} else {
// something else went wrong, and it's probably my fault...
setFeedback("Internal server error... Try again later or shoot me an old-fashioned email?");
}
})
.finally(() => setSubmitting(false));
};
return ( return (
<Formik <form action={formAction}>
onSubmit={handleSubmit} <input
initialValues={{ type="text"
name: "", name="name"
email: "", placeholder="Name"
message: "", required
"cf-turnstile-response": "", className={styles.input}
}} defaultValue={(formState.payload?.get("name") || "") as string}
validate={(values: FormValues) => { disabled={formState.success}
const errors: Partial<Record<keyof FormValues, boolean>> = {}; />
errors.name = !values.name; <input
errors.email = !values.email; // also loosely validated that it's email-like via browser (not foolproof) type="email"
errors.message = !values.message; name="email"
errors["cf-turnstile-response"] = !values["cf-turnstile-response"]; placeholder="Email"
required
inputMode="email"
className={styles.input}
defaultValue={(formState.payload?.get("email") || "") as string}
disabled={formState.success}
/>
if (!errors.name && !errors.email && !errors.message && !errors["cf-turnstile-response"]) { <TextareaAutosize
setFeedback(""); name="message"
return {}; placeholder="Write something..."
} else { minRows={5}
setSuccess(false); required
setFeedback("Please make sure that all fields are properly filled in."); className={styles.input}
} defaultValue={(formState.payload?.get("message") || "") as string}
disabled={formState.success}
/>
return errors; <div
}} style={{
> fontSize: "0.825em",
{({ setFieldValue, isSubmitting }: FormikProps<FormValues>) => ( lineHeight: 1.75,
<Form className={className} name="contact"> }}
<Field name="name"> >
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => ( <SiMarkdown
<input style={{
type="text" display: "inline",
placeholder="Name" width: "1.25em",
disabled={success} height: "1.25em",
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })} verticalAlign: "-0.25em",
{...field} marginRight: "0.25em",
/> }}
/>{" "}
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>, [
<Link href="https://jarv.is" underline={false} openInNewTab>
links
</Link>
](https://jarv.is), and <code>`code`</code>.
</div>
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
onVerify={(token) => setTurnstileToken(token)}
style={{ margin: "1em 0" }}
theme={activeTheme === "dark" ? activeTheme : "light"}
/>
<input type="hidden" name="cf-turnstile-response" value={turnstileToken} />
<div className={styles.actionRow}>
{!formState.success && (
<button type="submit" disabled={pending} className={styles.submitButton}>
{pending ? (
<span>Sending...</span>
) : (
<>
<span className={styles.submitIcon}>📤</span> <span>Send</span>
</>
)} )}
</Field> </button>
)}
<Field name="email"> {formState.message && (
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => ( <div className={clsx(styles.result, formState.success ? styles.success : styles.error)}>
<input {formState.success ? <GoCheck className={styles.resultIcon} /> : <GoX className={styles.resultIcon} />}{" "}
type="email" {formState.message}
inputMode="email"
placeholder="Email"
disabled={success}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
)}
</Field>
<Field name="message">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<TextareaAutosize
placeholder="Write something..."
minRows={5}
disabled={success}
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
{...field}
/>
)}
</Field>
<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>, [
<Link href="https://jarv.is" underline={false} openInNewTab>
links
</Link>
](https://jarv.is), and <code>`code`</code>.
</div> </div>
)}
<Turnstile </div>
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"} </form>
onVerify={(token) => setFieldValue("cf-turnstile-response", token)}
className={styles.captcha}
theme={activeTheme === "dark" ? activeTheme : "light"}
/>
<div className={styles.actionRow}>
<button
type="submit"
title="Send Message"
aria-label="Send Message"
onClick={() => setSubmitted(true)}
disabled={isSubmitting}
className={styles.submitButton}
style={{ display: success ? "none" : "inline-flex" }}
>
{isSubmitting ? (
<span>Sending...</span>
) : (
<>
<span className={styles.submitIcon}>📤</span> <span>Send</span>
</>
)}
</button>
<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>
); );
}; };

View File

@ -0,0 +1,27 @@
import { connection } from "next/server";
import commaNumber from "comma-number";
import { prisma } from "../../../lib/helpers/prisma";
const HitCounter = async ({ slug }: { slug: string }) => {
await connection();
try {
const { hits } = await prisma.hits.upsert({
where: { slug },
create: { slug },
update: {
hits: {
increment: 1,
},
},
});
// we have data!
return <span title={`${commaNumber(hits)} ${hits === 1 ? "view" : "views"}`}>{commaNumber(hits)}</span>;
} catch (error) {
console.error(error);
throw new Error();
}
};
export default HitCounter;

View File

@ -35,7 +35,6 @@
white-space: nowrap; white-space: nowrap;
margin-right: 0.75em; margin-right: 0.75em;
} }
.meta .tag:before { .meta .tag:before {
content: "\0023"; /* cosmetically hashtagify tags */ content: "\0023"; /* cosmetically hashtagify tags */
padding-right: 0.125em; padding-right: 0.125em;

View File

@ -1,11 +1,13 @@
import { Suspense } from "react";
import * as runtime from "react/jsx-runtime"; import * as runtime from "react/jsx-runtime";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { evaluate } from "@mdx-js/mdx"; import { evaluate } from "@mdx-js/mdx";
import Content from "../../../components/Content"; import Content from "../../../components/Content";
import Link from "../../../components/Link"; import Link from "../../../components/Link";
import Time from "../../../components/Time"; import Time from "../../../components/Time";
import HitCounter from "../../../components/HitCounter";
import Comments from "../../../components/Comments"; import Comments from "../../../components/Comments";
import Loading from "../../../components/Loading";
import HitCounter from "./counter";
import { getPostSlugs, getPostData } from "../../../lib/helpers/posts"; import { getPostSlugs, getPostData } from "../../../lib/helpers/posts";
import * as mdxComponents from "../../../lib/helpers/mdx-components"; import * as mdxComponents from "../../../lib/helpers/mdx-components";
import { metadata as defaultMetadata } from "../../layout"; import { metadata as defaultMetadata } from "../../layout";
@ -19,6 +21,9 @@ import styles from "./page.module.css";
// https://nextjs.org/docs/app/api-reference/functions/generate-static-params#disable-rendering-for-unspecified-paths // https://nextjs.org/docs/app/api-reference/functions/generate-static-params#disable-rendering-for-unspecified-paths
export const dynamicParams = false; export const dynamicParams = false;
// https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering#using-partial-prerendering
export const experimental_ppr = true;
export async function generateStaticParams() { export async function generateStaticParams() {
const slugs = await getPostSlugs(); const slugs = await getPostSlugs();
@ -135,20 +140,21 @@ export default async function Page({ params }: { params: Promise<{ slug: string
{/* only count hits on production site */} {/* only count hits on production site */}
{process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && ( {process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
<div <ErrorBoundary fallback={null}>
className={styles.item} <div
style={{ className={styles.item}
// fix potential layout shift when number of hits loads style={{
minWidth: "7em", // fix potential layout shift when number of hits loads
marginRight: 0, minWidth: "7em",
}} marginRight: 0,
> }}
{/* completely hide this block if anything goes wrong on the backend */} >
<ErrorBoundary fallback={null}>
<FiEye className={styles.icon} /> <FiEye className={styles.icon} />
<HitCounter slug={`notes/${frontMatter.slug}`} /> <Suspense fallback={<Loading boxes={3} width={20} />}>
</ErrorBoundary> <HitCounter slug={`notes/${frontMatter.slug}`} />
</div> </Suspense>
</div>
</ErrorBoundary>
)} )}
</div> </div>

View File

@ -6,7 +6,7 @@ import config from "../../lib/config";
import { metadata as defaultMetadata } from "../layout"; import { metadata as defaultMetadata } from "../layout";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import type { Metadata, Route } from "next"; import type { Metadata, Route } from "next";
import type { PostsByYear } from "../../types"; import type { FrontMatter } from "../../lib/helpers/posts";
import styles from "./page.module.css"; import styles from "./page.module.css";
@ -27,7 +27,9 @@ export const metadata: Metadata = {
export default async function Page() { export default async function Page() {
// parse the year of each note and group them together // parse the year of each note and group them together
const notes = await getAllPosts(); const notes = await getAllPosts();
const notesByYear: PostsByYear = {}; const notesByYear: {
[year: string]: FrontMatter[];
} = {};
notes.forEach((note) => { notes.forEach((note) => {
const year = new Date(note.date).getUTCFullYear(); const year = new Date(note.date).getUTCFullYear();
@ -45,7 +47,11 @@ export default async function Page() {
<li className={styles.post} key={slug}> <li className={styles.post} key={slug}>
<Time date={date} format="MMM D" className={styles.postDate} /> <Time date={date} format="MMM D" className={styles.postDate} />
<span> <span>
<Link href={`/notes/${slug}` as Route} dangerouslySetInnerHTML={{ __html: htmlTitle || title }} /> <Link
href={`/notes/${slug}` as Route}
prefetch={null}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
/>
</span> </span>
</li> </li>
))} ))}

View File

@ -30,18 +30,6 @@
font-size: 0.6em; 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 { .wave {
display: inline-block; display: inline-block;
margin-left: 0.1em; margin-left: 0.1em;
@ -85,15 +73,15 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.h1 { .page h1 {
font-size: 1.6em; font-size: 1.6em;
} }
.h2 { .page h2 {
font-size: 1.25em; font-size: 1.25em;
} }
.paragraph { .page p {
font-size: 0.925em; font-size: 0.925em;
line-height: 1.825; line-height: 1.825;
} }

View File

@ -254,8 +254,23 @@ export default function Page() {
underline={false} underline={false}
openInNewTab openInNewTab
> >
<GoLock size="1.25em" className={styles.pgpIcon} />{" "} <GoLock
<span className={styles.pgpKey}>2B0C 9CF2 51E6 9A39</span> size="1.25em"
style={{
verticalAlign: "-0.25em",
strokeWidth: 0.5,
}}
/>{" "}
<span
style={{
margin: "0 0.15em",
fontFamily: "var(--fonts-mono)",
letterSpacing: "0.075em",
wordSpacing: "-0.4em",
}}
>
2B0C 9CF2 51E6 9A39
</span>
</ColorfulLink> </ColorfulLink>
</sup> </sup>
,{" "} ,{" "}

View File

@ -69,18 +69,3 @@
border-radius: 50%; border-radius: 50%;
vertical-align: text-top; 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);
}

View File

@ -10,7 +10,6 @@ import { GoStar, GoRepoForked } from "react-icons/go";
import { SiGithub } from "react-icons/si"; import { SiGithub } from "react-icons/si";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { User, Repository } from "@octokit/graphql-schema"; import type { User, Repository } from "@octokit/graphql-schema";
import type { Project } from "../../types";
import styles from "./page.module.css"; import styles from "./page.module.css";
@ -29,6 +28,19 @@ export const metadata: Metadata = {
}, },
}; };
type Project = {
name: string;
url: string;
description?: string;
language?: {
name: string;
color?: string;
};
stars?: number;
forks?: number;
updatedAt: string;
};
async function getRepos(): Promise<Project[] | null> { async function getRepos(): Promise<Project[] | null> {
// don't fail the entire site build if the required API key for this page is missing // 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 === "") { if (!process.env.GH_PUBLIC_TOKEN || process.env.GH_PUBLIC_TOKEN === "") {
@ -164,9 +176,26 @@ export default async function Page() {
))} ))}
</div> </div>
<p className={styles.viewMore}> <p
style={{
textAlign: "center",
marginBottom: 0,
fontWeight: 500,
}}
>
<Link href={`https://github.com/${config.authorSocial.github}`}> <Link href={`https://github.com/${config.authorSocial.github}`}>
View more on <SiGithub className={styles.githubIcon} /> GitHub... View more on{" "}
<SiGithub
style={{
display: "inline",
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
margin: "0 0.15em",
fill: "var(--colors-text)",
}}
/>{" "}
GitHub...
</Link> </Link>
</p> </p>
</Content> </Content>

View File

@ -1,43 +0,0 @@
"use client";
import useSWRImmutable from "swr/immutable";
import { useErrorBoundary } from "react-error-boundary";
import commaNumber from "comma-number";
import Loading from "../Loading";
import fetcher from "../../lib/helpers/fetcher";
import type { PageStats } from "../../types";
export type HitCounterProps = {
slug: string;
};
const HitCounter = ({ slug }: HitCounterProps) => {
const { showBoundary } = useErrorBoundary();
// use immutable SWR to avoid double (or more) counting views:
// https://swr.vercel.app/docs/revalidation#disable-automatic-revalidations
const { data, error } = useSWRImmutable<PageStats>(
`/api/count/?${new URLSearchParams({
slug,
})}`,
fetcher
);
// fail somewhat silently, see error boundary in PostMeta component
if (error) {
showBoundary(`${error}`);
return null;
}
// show spinning loading indicator if data isn't fetched yet
if (!data) {
return <Loading boxes={3} width={20} />;
}
// we have data!
return (
<span title={`${commaNumber(data.hits)} ${data.hits === 1 ? "view" : "views"}`}>{commaNumber(data.hits)}</span>
);
};
export default HitCounter;

View File

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

View File

@ -1,9 +0,0 @@
// very simple fetch wrapper that's passed into SWR hooks:
// https://swr.vercel.app/docs/data-fetching#fetch
// note: fetch does *not* need to be poly/ponyfilled in Next.js:
// https://nextjs.org/blog/next-9-1-7#new-built-in-polyfills-fetch-url-and-objectassign
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetcher = <T = any>(...args: Parameters<typeof fetch>): Promise<T> => fetch(...args).then((res) => res.json());
export default fetcher;

View File

@ -5,17 +5,28 @@ import pMap from "p-map";
import pMemoize from "p-memoize"; import pMemoize from "p-memoize";
import matter from "gray-matter"; import matter from "gray-matter";
import { formatDate } from "./format-date"; import { formatDate } from "./format-date";
import type { PostFrontMatter } from "../../types";
import { metadata as defaultMetadata } from "../../app/layout"; import { metadata as defaultMetadata } from "../../app/layout";
// path to directory with .mdx files, relative to project root // path to directory with .mdx files, relative to project root
export const POSTS_DIR = "notes"; const POSTS_DIR = "notes";
export type FrontMatter = {
slug: string;
permalink: string;
date: string;
title: string;
htmlTitle?: string;
description?: string;
image?: string;
tags?: string[];
noComments?: boolean;
};
// returns front matter and the **raw & uncompiled** markdown of a given slug // returns front matter and the **raw & uncompiled** markdown of a given slug
export const getPostData = async ( export const getPostData = async (
slug: string slug: string
): Promise<{ ): Promise<{
frontMatter: PostFrontMatter; frontMatter: FrontMatter;
markdown: string; markdown: string;
}> => { }> => {
const { unified } = await import("unified"); const { unified } = await import("unified");
@ -54,7 +65,7 @@ export const getPostData = async (
// return both the parsed YAML front matter (with a few amendments) and the raw, unparsed markdown content // return both the parsed YAML front matter (with a few amendments) and the raw, unparsed markdown content
return { return {
frontMatter: { frontMatter: {
...(data as Partial<PostFrontMatter>), ...(data as Partial<FrontMatter>),
// zero markdown title: // zero markdown title:
title, title,
htmlTitle, htmlTitle,
@ -81,7 +92,7 @@ export const getPostSlugs = pMemoize(async (): Promise<string[]> => {
}); });
// returns the parsed front matter of ALL posts, sorted reverse chronologically // returns the parsed front matter of ALL posts, sorted reverse chronologically
export const getAllPosts = pMemoize(async (): Promise<PostFrontMatter[]> => { export const getAllPosts = pMemoize(async (): Promise<FrontMatter[]> => {
// for each post, query its front matter // for each post, query its front matter
const data = await pMap(await getPostSlugs(), async (slug) => (await getPostData(slug)).frontMatter); const data = await pMap(await getPostSlugs(), async (slug) => (await getPostData(slug)).frontMatter);

View File

@ -19,13 +19,14 @@ const nextConfig: NextConfig = {
], ],
}, },
experimental: { experimental: {
ppr: "incremental", // https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering#using-partial-prerendering
cssChunking: true,
typedRoutes: true, typedRoutes: true,
largePageDataBytes: 512 * 1000, // raise getStaticProps limit to 512 kB since compiled MDX will exceed the default. largePageDataBytes: 512 * 1000, // raise getStaticProps limit to 512 kB since compiled MDX will exceed the default.
optimisticClientCache: false, // https://github.com/vercel/next.js/discussions/40268#discussioncomment-3572642
}, },
eslint: { eslint: {
// https://nextjs.org/docs/basic-features/eslint#linting-custom-directories-and-files // https://nextjs.org/docs/basic-features/eslint#linting-custom-directories-and-files
dirs: ["app", "components", "contexts", "hooks", "lib", "types"], dirs: ["app", "components", "contexts", "hooks", "lib"],
}, },
headers: async () => [ headers: async () => [
{ {

View File

@ -23,7 +23,7 @@
"@giscus/react": "^3.1.0", "@giscus/react": "^3.1.0",
"@libsql/client": "0.15.0-pre.1", "@libsql/client": "0.15.0-pre.1",
"@mdx-js/mdx": "^3.1.0", "@mdx-js/mdx": "^3.1.0",
"@next/bundle-analyzer": "15.1.6", "@next/bundle-analyzer": "15.2.0-canary.46",
"@octokit/graphql": "^8.2.0", "@octokit/graphql": "^8.2.0",
"@octokit/graphql-schema": "^15.25.0", "@octokit/graphql-schema": "^15.25.0",
"@prisma/adapter-libsql": "^6.3.1", "@prisma/adapter-libsql": "^6.3.1",
@ -36,11 +36,9 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"feed": "^4.2.2", "feed": "^4.2.2",
"formik": "^2.4.6",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"modern-normalize": "^3.0.1", "modern-normalize": "^3.0.1",
"next": "15.1.6", "next": "15.2.0-canary.46",
"nodemailer": "^6.10.0",
"obj-str": "^1.1.0", "obj-str": "^1.1.0",
"p-map": "^7.0.3", "p-map": "^7.0.3",
"p-memoize": "^7.1.1", "p-memoize": "^7.1.1",
@ -65,23 +63,22 @@
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.1",
"remark-smartypants": "^3.0.2", "remark-smartypants": "^3.0.2",
"swr": "^2.3.2", "resend": "^4.1.2",
"unified": "^11.0.5" "unified": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.20.0",
"@jakejarvis/eslint-config": "~4.0.7", "@jakejarvis/eslint-config": "~4.0.7",
"@types/comma-number": "^2.1.2", "@types/comma-number": "^2.1.2",
"@types/node": "^22.13.1", "@types/node": "^22.13.1",
"@types/nodemailer": "^6.4.17",
"@types/prop-types": "^15.7.14", "@types/prop-types": "^15.7.14",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@types/react-is": "^19.0.0", "@types/react-is": "^19.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "~9.19.0", "eslint": "~9.20.0",
"eslint-config-next": "15.1.6", "eslint-config-next": "15.2.0-canary.46",
"eslint-config-prettier": "~10.0.1", "eslint-config-prettier": "~10.0.1",
"eslint-plugin-mdx": "~3.1.5", "eslint-plugin-mdx": "~3.1.5",
"eslint-plugin-prettier": "~5.2.3", "eslint-plugin-prettier": "~5.2.3",

606
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
types/index.d.ts vendored
View File

@ -1,3 +0,0 @@
export * from "./post";
export * from "./project";
export * from "./stats";

25
types/post.d.ts vendored
View File

@ -1,25 +0,0 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
export type PostFrontMatter = {
slug: string;
permalink: string;
date: string;
title: string;
htmlTitle?: string;
description?: string;
image?: string;
tags?: string[];
noComments?: boolean;
};
export type PostWithSource = {
// yaml metadata
frontMatter: PostFrontMatter;
// the final, compiled JSX by next-mdx-remote; see lib/helpers/posts.ts
source: Partial<Pick<MDXRemoteSerializeResult<Record<string, never>, Record<string, never>>>>;
};
export type PostsByYear = {
[year: string]: PostFrontMatter[];
};

12
types/project.d.ts vendored
View File

@ -1,12 +0,0 @@
export type Project = {
name: string;
url: string;
description?: string;
language?: {
name: string;
color?: string;
};
stars?: number;
forks?: number;
updatedAt: string;
};

10
types/stats.d.ts vendored
View File

@ -1,10 +0,0 @@
// a silly file, but this ensures that /api/count returns exactly what <HitCounter /> expects.
import type { hits as Hits } from "@prisma/client";
export type PageStats = Pick<Hits, "hits">;
export type SiteStats = {
total: PageStats;
pages: Hits[];
};