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:
parent
fa5edc003f
commit
37375b766f
@ -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": {
|
||||||
|
@ -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
|
||||||
|
@ -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 { 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
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;
|
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;
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
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;
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
19
app/page.tsx
19
app/page.tsx
@ -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>
|
||||||
,{" "}
|
,{" "}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 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);
|
||||||
|
|
||||||
|
@ -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 () => [
|
||||||
{
|
{
|
||||||
|
15
package.json
15
package.json
@ -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
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