mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-04-26 04:25:22 -04:00
server all the actions!
This commit is contained in:
parent
fa5edc003f
commit
37375b766f
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:22-bookworm",
|
||||
"name": "Node.js",
|
||||
"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'",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
|
@ -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.
|
||||
- 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.
|
||||
- [🧮 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.
|
||||
|
||||
## 📜 License
|
||||
|
@ -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;
|
||||
};
|
@ -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 });
|
||||
}
|
@ -1,10 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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 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
|
||||
const pages = await prisma.hits.findMany({
|
||||
orderBy: [
|
||||
|
60
app/contact/actions.ts
Normal file
60
app/contact/actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -25,23 +25,6 @@
|
||||
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;
|
||||
|
@ -1,199 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { useState, useActionState } from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import Turnstile from "react-turnstile";
|
||||
import clsx from "clsx";
|
||||
import Link from "../../components/Link";
|
||||
import useTheme from "../../hooks/useTheme";
|
||||
import { sendMessage } from "./actions";
|
||||
import { GoCheck, GoX } from "react-icons/go";
|
||||
import { SiMarkdown } from "react-icons/si";
|
||||
import type { FormikHelpers, FormikProps, FieldInputProps, FieldMetaProps } from "formik";
|
||||
|
||||
import styles from "./form.module.css";
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
"cf-turnstile-response": string;
|
||||
};
|
||||
|
||||
export type ContactFormProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ContactForm = ({ className }: ContactFormProps) => {
|
||||
const ContactForm = () => {
|
||||
const { activeTheme } = useTheme();
|
||||
|
||||
// status/feedback:
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers<FormValues>) => {
|
||||
// once a user attempts a submission, this is true and stays true whether or not the next attempt(s) are successful
|
||||
setSubmitted(true);
|
||||
|
||||
// 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));
|
||||
};
|
||||
const [formState, formAction, pending] = useActionState<
|
||||
Partial<{ success: boolean; message: string; payload: FormData }>,
|
||||
FormData
|
||||
>(sendMessage, {});
|
||||
const [turnstileToken, setTurnstileToken] = useState<string>("");
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={handleSubmit}
|
||||
initialValues={{
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
"cf-turnstile-response": "",
|
||||
}}
|
||||
validate={(values: FormValues) => {
|
||||
const errors: Partial<Record<keyof FormValues, boolean>> = {};
|
||||
<form action={formAction}>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
required
|
||||
className={styles.input}
|
||||
defaultValue={(formState.payload?.get("name") || "") as string}
|
||||
disabled={formState.success}
|
||||
/>
|
||||
|
||||
errors.name = !values.name;
|
||||
errors.email = !values.email; // also loosely validated that it's email-like via browser (not foolproof)
|
||||
errors.message = !values.message;
|
||||
errors["cf-turnstile-response"] = !values["cf-turnstile-response"];
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
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"]) {
|
||||
setFeedback("");
|
||||
return {};
|
||||
} else {
|
||||
setSuccess(false);
|
||||
setFeedback("Please make sure that all fields are properly filled in.");
|
||||
}
|
||||
<TextareaAutosize
|
||||
name="message"
|
||||
placeholder="Write something..."
|
||||
minRows={5}
|
||||
required
|
||||
className={styles.input}
|
||||
defaultValue={(formState.payload?.get("message") || "") as string}
|
||||
disabled={formState.success}
|
||||
/>
|
||||
|
||||
return errors;
|
||||
}}
|
||||
>
|
||||
{({ setFieldValue, isSubmitting }: FormikProps<FormValues>) => (
|
||||
<Form className={className} name="contact">
|
||||
<Field name="name">
|
||||
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
disabled={success}
|
||||
className={clsx(styles.input, { [styles.missing]: !!(meta.error && meta.touched) })}
|
||||
{...field}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.825em",
|
||||
lineHeight: 1.75,
|
||||
}}
|
||||
>
|
||||
<SiMarkdown
|
||||
style={{
|
||||
display: "inline",
|
||||
width: "1.25em",
|
||||
height: "1.25em",
|
||||
verticalAlign: "-0.25em",
|
||||
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">
|
||||
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
|
||||
<input
|
||||
type="email"
|
||||
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>.
|
||||
{formState.message && (
|
||||
<div className={clsx(styles.result, formState.success ? styles.success : styles.error)}>
|
||||
{formState.success ? <GoCheck className={styles.resultIcon} /> : <GoX className={styles.resultIcon} />}{" "}
|
||||
{formState.message}
|
||||
</div>
|
||||
|
||||
<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"}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
27
app/notes/[slug]/counter.tsx
Normal file
27
app/notes/[slug]/counter.tsx
Normal 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;
|
@ -35,7 +35,6 @@
|
||||
white-space: nowrap;
|
||||
margin-right: 0.75em;
|
||||
}
|
||||
|
||||
.meta .tag:before {
|
||||
content: "\0023"; /* cosmetically hashtagify tags */
|
||||
padding-right: 0.125em;
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
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 Loading from "../../../components/Loading";
|
||||
import HitCounter from "./counter";
|
||||
import { getPostSlugs, getPostData } from "../../../lib/helpers/posts";
|
||||
import * as mdxComponents from "../../../lib/helpers/mdx-components";
|
||||
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
|
||||
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() {
|
||||
const slugs = await getPostSlugs();
|
||||
|
||||
@ -135,20 +140,21 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
|
||||
{/* 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}>
|
||||
<ErrorBoundary fallback={null}>
|
||||
<div
|
||||
className={styles.item}
|
||||
style={{
|
||||
// fix potential layout shift when number of hits loads
|
||||
minWidth: "7em",
|
||||
marginRight: 0,
|
||||
}}
|
||||
>
|
||||
<FiEye className={styles.icon} />
|
||||
<HitCounter slug={`notes/${frontMatter.slug}`} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<Suspense fallback={<Loading boxes={3} width={20} />}>
|
||||
<HitCounter slug={`notes/${frontMatter.slug}`} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
@ -6,7 +6,7 @@ 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 type { FrontMatter } from "../../lib/helpers/posts";
|
||||
|
||||
import styles from "./page.module.css";
|
||||
|
||||
@ -27,7 +27,9 @@ export const metadata: Metadata = {
|
||||
export default async function Page() {
|
||||
// parse the year of each note and group them together
|
||||
const notes = await getAllPosts();
|
||||
const notesByYear: PostsByYear = {};
|
||||
const notesByYear: {
|
||||
[year: string]: FrontMatter[];
|
||||
} = {};
|
||||
|
||||
notes.forEach((note) => {
|
||||
const year = new Date(note.date).getUTCFullYear();
|
||||
@ -45,7 +47,11 @@ export default async function Page() {
|
||||
<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 }} />
|
||||
<Link
|
||||
href={`/notes/${slug}` as Route}
|
||||
prefetch={null}
|
||||
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
@ -30,18 +30,6 @@
|
||||
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;
|
||||
@ -85,15 +73,15 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.h1 {
|
||||
.page h1 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
.page h2 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
.page p {
|
||||
font-size: 0.925em;
|
||||
line-height: 1.825;
|
||||
}
|
||||
|
19
app/page.tsx
19
app/page.tsx
@ -254,8 +254,23 @@ export default function Page() {
|
||||
underline={false}
|
||||
openInNewTab
|
||||
>
|
||||
<GoLock size="1.25em" className={styles.pgpIcon} />{" "}
|
||||
<span className={styles.pgpKey}>2B0C 9CF2 51E6 9A39</span>
|
||||
<GoLock
|
||||
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>
|
||||
</sup>
|
||||
,{" "}
|
||||
|
@ -69,18 +69,3 @@
|
||||
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);
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ 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";
|
||||
|
||||
@ -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> {
|
||||
// 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 === "") {
|
||||
@ -164,9 +176,26 @@ export default async function Page() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className={styles.viewMore}>
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginBottom: 0,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</p>
|
||||
</Content>
|
||||
|
@ -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;
|
@ -1,2 +0,0 @@
|
||||
export * from "./HitCounter";
|
||||
export { default } from "./HitCounter";
|
@ -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;
|
@ -5,17 +5,28 @@ import pMap from "p-map";
|
||||
import pMemoize from "p-memoize";
|
||||
import matter from "gray-matter";
|
||||
import { formatDate } from "./format-date";
|
||||
import type { PostFrontMatter } from "../../types";
|
||||
import { metadata as defaultMetadata } from "../../app/layout";
|
||||
|
||||
// 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
|
||||
export const getPostData = async (
|
||||
slug: string
|
||||
): Promise<{
|
||||
frontMatter: PostFrontMatter;
|
||||
frontMatter: FrontMatter;
|
||||
markdown: string;
|
||||
}> => {
|
||||
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 {
|
||||
frontMatter: {
|
||||
...(data as Partial<PostFrontMatter>),
|
||||
...(data as Partial<FrontMatter>),
|
||||
// zero markdown title:
|
||||
title,
|
||||
htmlTitle,
|
||||
@ -81,7 +92,7 @@ export const getPostSlugs = pMemoize(async (): Promise<string[]> => {
|
||||
});
|
||||
|
||||
// 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
|
||||
const data = await pMap(await getPostSlugs(), async (slug) => (await getPostData(slug)).frontMatter);
|
||||
|
||||
|
@ -19,13 +19,14 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
ppr: "incremental", // https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering#using-partial-prerendering
|
||||
cssChunking: true,
|
||||
typedRoutes: true,
|
||||
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: {
|
||||
// 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 () => [
|
||||
{
|
||||
|
15
package.json
15
package.json
@ -23,7 +23,7 @@
|
||||
"@giscus/react": "^3.1.0",
|
||||
"@libsql/client": "0.15.0-pre.1",
|
||||
"@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-schema": "^15.25.0",
|
||||
"@prisma/adapter-libsql": "^6.3.1",
|
||||
@ -36,11 +36,9 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"fast-glob": "^3.3.3",
|
||||
"feed": "^4.2.2",
|
||||
"formik": "^2.4.6",
|
||||
"gray-matter": "^4.0.3",
|
||||
"modern-normalize": "^3.0.1",
|
||||
"next": "15.1.6",
|
||||
"nodemailer": "^6.10.0",
|
||||
"next": "15.2.0-canary.46",
|
||||
"obj-str": "^1.1.0",
|
||||
"p-map": "^7.0.3",
|
||||
"p-memoize": "^7.1.1",
|
||||
@ -65,23 +63,22 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"swr": "^2.3.2",
|
||||
"resend": "^4.1.2",
|
||||
"unified": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@jakejarvis/eslint-config": "~4.0.7",
|
||||
"@types/comma-number": "^2.1.2",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/prop-types": "^15.7.14",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-is": "^19.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "~9.19.0",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"eslint": "~9.20.0",
|
||||
"eslint-config-next": "15.2.0-canary.46",
|
||||
"eslint-config-prettier": "~10.0.1",
|
||||
"eslint-plugin-mdx": "~3.1.5",
|
||||
"eslint-plugin-prettier": "~5.2.3",
|
||||
|
606
pnpm-lock.yaml
generated
606
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
types/index.d.ts
vendored
3
types/index.d.ts
vendored
@ -1,3 +0,0 @@
|
||||
export * from "./post";
|
||||
export * from "./project";
|
||||
export * from "./stats";
|
25
types/post.d.ts
vendored
25
types/post.d.ts
vendored
@ -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
12
types/project.d.ts
vendored
@ -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
10
types/stats.d.ts
vendored
@ -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[];
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user