1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 19:15:30 -04:00

homebrew comments system

This commit is contained in:
2025-05-14 09:47:51 -04:00
parent cce48e558f
commit b196249f25
61 changed files with 3616 additions and 397 deletions
+8
View File
@@ -0,0 +1,8 @@
import { env } from "@/lib/env";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: env.NEXT_PUBLIC_BASE_URL,
});
export const { signIn, signUp, useSession } = authClient;
+31
View File
@@ -0,0 +1,31 @@
import { env } from "@/lib/env";
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema";
export const auth = betterAuth({
baseURL: env.NEXT_PUBLIC_BASE_URL,
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
plugins: [nextCookies()],
socialProviders: {
github: {
clientId: env.AUTH_GITHUB_CLIENT_ID,
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
scope: ["read:user"],
disableDefaultScope: true,
mapProfileToUser(profile) {
return {
name: profile.login,
email: profile.email,
emailVerified: true,
image: profile.avatar_url,
};
},
},
},
} satisfies BetterAuthOptions);
+13
View File
@@ -0,0 +1,13 @@
import { env } from "@/lib/env";
import { format, formatISO } from "date-fns";
import { enUS } from "date-fns/locale";
import { tz } from "@date-fns/tz";
import { utc } from "@date-fns/utc";
export const formatDate = (date: string | number | Date, formatStr = "PPpp") => {
return format(date, formatStr, { in: tz(env.NEXT_PUBLIC_SITE_TZ), locale: enUS });
};
export const formatDateISO = (date: string | number | Date) => {
return formatISO(date, { in: utc });
};
+6
View File
@@ -0,0 +1,6 @@
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql });
@@ -0,0 +1,68 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "comment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"content" text NOT NULL,
"page_slug" text NOT NULL,
"parent_id" uuid,
"user_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "page" (
"slug" text PRIMARY KEY NOT NULL,
"views" integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean NOT NULL,
"image" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp,
"updated_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment" ADD CONSTRAINT "comment_page_slug_page_slug_fk" FOREIGN KEY ("page_slug") REFERENCES "public"."page"("slug") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment" ADD CONSTRAINT "comment_parent_id_comment_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment" ADD CONSTRAINT "comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
+443
View File
@@ -0,0 +1,443 @@
{
"id": "d126927d-35cf-4b6f-ab97-3872b8db26a7",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.comment": {
"name": "comment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"page_slug": {
"name": "page_slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"comment_page_slug_page_slug_fk": {
"name": "comment_page_slug_page_slug_fk",
"tableFrom": "comment",
"tableTo": "page",
"columnsFrom": [
"page_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"comment_parent_id_comment_id_fk": {
"name": "comment_parent_id_comment_id_fk",
"tableFrom": "comment",
"tableTo": "comment",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"comment_user_id_user_id_fk": {
"name": "comment_user_id_user_id_fk",
"tableFrom": "comment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.page": {
"name": "page",
"schema": "",
"columns": {
"slug": {
"name": "slug",
"type": "text",
"primaryKey": true,
"notNull": true
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1747229716675,
"tag": "0000_puzzling_sphinx",
"breakpoints": true
}
]
}
+70
View File
@@ -0,0 +1,70 @@
import { pgTable, text, timestamp, boolean, integer, uuid, AnyPgColumn } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
});
export const page = pgTable("page", {
slug: text("slug").primaryKey(),
views: integer("views").notNull().default(1),
});
export const comment = pgTable("comment", {
id: uuid("id").defaultRandom().primaryKey(),
content: text("content").notNull(),
pageSlug: text("page_slug")
.notNull()
.references(() => page.slug),
parentId: uuid("parent_id").references((): AnyPgColumn => comment.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
+29 -34
View File
@@ -3,6 +3,35 @@ import * as v from "valibot";
export const env = createEnv({
server: {
/**
* Required. A random value used for authentication encryption.
*
* @see https://www.better-auth.com/docs/installation#set-environment-variables
*/
AUTH_SECRET: v.string(),
/**
* Required. The client ID from the GitHub Developer Portal for this site's OAuth App.
*
* @see https://www.better-auth.com/docs/authentication/github
*/
AUTH_GITHUB_CLIENT_ID: v.string(),
/**
* Required. A client secret from the GitHub Developer Portal for this site's OAuth App.
*
* @see https://www.better-auth.com/docs/authentication/github
*/
AUTH_GITHUB_CLIENT_SECRET: v.string(),
/**
* Required. Database connection string for a Postgres database. May be set automatically by Vercel's Neon
* integration.
*
* @see https://vercel.com/integrations/neon
*/
DATABASE_URL: v.pipe(v.string(), v.startsWith("postgres://")),
/**
* Required. GitHub API token used for [/projects](../app/projects/page.tsx) grid. Only needs the `public_repo`
* scope since we don't need/want to change anything, obviously.
@@ -11,24 +40,6 @@ export const env = createEnv({
*/
GITHUB_TOKEN: v.optional(v.pipe(v.string(), v.startsWith("ghp_"))),
/**
* Required. Redis storage credentials for hit counter's [server component](../app/notes/[slug]/counter.tsx) and API
* endpoint. Currently set automatically by Vercel's Upstash integration.
*
* @see https://upstash.com/docs/redis/sdks/ts/getstarted
* @see https://vercel.com/marketplace/upstash
*/
KV_REST_API_TOKEN: v.string(),
/**
* Required. Redis storage credentials for hit counter's [server component](../app/notes/[slug]/counter.tsx) and API
* endpoint. Currently set automatically by Vercel's Upstash integration.
*
* @see https://upstash.com/docs/redis/sdks/ts/getstarted
* @see https://vercel.com/marketplace/upstash
*/
KV_REST_API_URL: v.pipe(v.string(), v.url(), v.startsWith("https://"), v.endsWith(".upstash.io")),
/**
* Required. Uses Resend API to send contact form submissions via a [server action](../app/contact/action.ts). May
* be set automatically by Vercel's Resend integration.
@@ -102,20 +113,6 @@ export const env = createEnv({
: "development"
),
/**
* Optional. Enables comments on blog posts via GitHub discussions.
*
* @see https://giscus.app/
*/
NEXT_PUBLIC_GISCUS_CATEGORY_ID: v.optional(v.string()),
/**
* Optional. Enables comments on blog posts via GitHub discussions.
*
* @see https://giscus.app/
*/
NEXT_PUBLIC_GISCUS_REPO_ID: v.optional(v.string()),
/** Required. GitHub repository for the site in the format of `{username}/{repo}`. */
NEXT_PUBLIC_GITHUB_REPO: v.pipe(v.string(), v.includes("/")),
@@ -157,8 +154,6 @@ export const env = createEnv({
experimental__runtimeEnv: {
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_ENV: process.env.NEXT_PUBLIC_ENV,
NEXT_PUBLIC_GISCUS_CATEGORY_ID: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
NEXT_PUBLIC_GISCUS_REPO_ID: process.env.NEXT_PUBLIC_GISCUS_REPO_ID,
NEXT_PUBLIC_GITHUB_REPO: process.env.NEXT_PUBLIC_GITHUB_REPO,
NEXT_PUBLIC_GITHUB_USERNAME: process.env.NEXT_PUBLIC_GITHUB_USERNAME,
NEXT_PUBLIC_ONION_DOMAIN: process.env.NEXT_PUBLIC_ONION_DOMAIN,
+3 -55
View File
@@ -1,10 +1,10 @@
import { env } from "@/lib/env";
import { cache } from "react";
import { kv } from "@vercel/kv";
import path from "path";
import fs from "fs/promises";
import glob from "fast-glob";
import { unified } from "unified";
import { decode } from "html-entities";
import {
remarkParse,
remarkSmartypants,
@@ -13,10 +13,8 @@ import {
remarkMdx,
remarkStripMdxImportsExports,
} from "@/lib/remark";
import { decode } from "html-entities";
import { rehypeSanitize, rehypeStringify } from "@/lib/rehype";
import { POSTS_DIR } from "@/lib/config/constants";
import rehypeSanitize from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";
export type FrontMatter = {
slug: string;
@@ -112,57 +110,6 @@ export const getFrontMatter: {
}
);
export const getViews: {
/**
* Retrieves the number of views for ALL posts
*/
(): Promise<Record<string, number>>;
/**
* Retrieves the number of views for a given slug, or undefined if the slug does not exist
*/
(slug: string): Promise<number | undefined>;
} = cache(
async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slug?: any
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise<any> => {
// ensure the prefix is consistent for all keys in the KV store
const KEY_PREFIX = `hits:${POSTS_DIR}/`;
if (typeof slug === "string") {
try {
const views = await kv.get<string>(`${KEY_PREFIX}${slug}`);
return views ? parseInt(views, 10) : undefined;
} catch (error) {
console.error(`Failed to retrieve view count for post with slug "${slug}":`, error);
return undefined;
}
}
if (!slug) {
try {
const allSlugs = await getSlugs();
const pages: Record<string, number> = {};
// get the value (number of views) for each key (the slug of the page)
const values = await kv.mget<string[]>(...allSlugs.map((slug) => `${KEY_PREFIX}${slug}`));
// pair the slugs with their view counts
allSlugs.forEach((slug, index) => (pages[slug.replace(KEY_PREFIX, "")] = parseInt(values[index], 10)));
return pages;
} catch (error) {
console.error("Failed to retrieve view counts:", error);
return undefined;
}
}
throw new Error("getViews() called with invalid argument.");
}
);
/** Returns the content of a post with very limited processing to include in RSS feeds */
export const getContent = cache(async (slug: string): Promise<string | undefined> => {
try {
@@ -182,6 +129,7 @@ export const getContent = cache(async (slug: string): Promise<string | undefined
"code",
"pre",
"blockquote",
"del",
"h1",
"h2",
"h3",
+1
View File
@@ -1,3 +1,4 @@
export { default as rehypeExternalLinks } from "rehype-external-links";
export { default as rehypeMdxCodeProps } from "rehype-mdx-code-props";
export { default as rehypeMdxImportMedia } from "rehype-mdx-import-media";
export { default as rehypeSanitize } from "rehype-sanitize";
+143
View File
@@ -0,0 +1,143 @@
"use server";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { eq, desc } from "drizzle-orm";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema";
import { auth } from "@/lib/auth";
export type CommentWithUser = typeof schema.comment.$inferSelect & {
user: Pick<typeof schema.user.$inferSelect, "id" | "name" | "image">;
};
export const getComments = async (pageSlug: string): Promise<CommentWithUser[]> => {
try {
// Fetch all comments for the page with user details
const commentsWithUsers = await db
.select()
.from(schema.comment)
.innerJoin(schema.user, eq(schema.comment.userId, schema.user.id))
.where(eq(schema.comment.pageSlug, pageSlug))
.orderBy(desc(schema.comment.createdAt));
return commentsWithUsers.map(({ comment, user }) => ({
...comment,
user: {
// we're namely worried about keeping the user's email private here, but nothing sensitive is stored in the db
id: user.id,
name: user.name,
image: user.image,
},
}));
} catch (error) {
console.error("[server/comments] error fetching comments:", error);
throw new Error("Failed to fetch comments");
}
};
export const createComment = async (data: { content: string; pageSlug: string; parentId?: string }) => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session || !session.user) {
throw new Error("You must be logged in to comment");
}
try {
// Insert the comment
await db.insert(schema.comment).values({
content: data.content,
pageSlug: data.pageSlug,
parentId: data.parentId || null,
userId: session.user.id,
});
// Revalidate the page to show the new comment
revalidatePath(`/${data.pageSlug}`);
} catch (error) {
console.error("[server/comments] error creating comment:", error);
throw new Error("Failed to create comment");
}
};
export const updateComment = async (commentId: string, content: string) => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session || !session.user) {
throw new Error("You must be logged in to update a comment");
}
try {
// Get the comment to verify ownership
const comment = await db
.select({ userId: schema.comment.userId, pageSlug: schema.comment.pageSlug })
.from(schema.comment)
.where(eq(schema.comment.id, commentId))
.then((results) => results[0]);
if (!comment) {
throw new Error("Comment not found");
}
// Verify ownership
if (comment.userId !== session.user.id) {
throw new Error("You can only edit your own comments");
}
// Update the comment
await db
.update(schema.comment)
.set({
content,
updatedAt: new Date(),
})
.where(eq(schema.comment.id, commentId));
// Revalidate the page to show the updated comment
revalidatePath(`/${comment.pageSlug}`);
} catch (error) {
console.error("[server/comments] error updating comment:", error);
throw new Error("Failed to update comment");
}
};
export const deleteComment = async (commentId: string) => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session || !session.user) {
throw new Error("You must be logged in to delete a comment");
}
try {
// Get the comment to verify ownership and get the page_slug for revalidation
const comment = await db
.select({ userId: schema.comment.userId, pageSlug: schema.comment.pageSlug })
.from(schema.comment)
.where(eq(schema.comment.id, commentId))
.then((results) => results[0]);
if (!comment) {
throw new Error("Comment not found");
}
// Verify ownership
if (comment.userId !== session.user.id) {
throw new Error("You can only delete your own comments");
}
// Delete the comment
await db.delete(schema.comment).where(eq(schema.comment.id, commentId));
// Revalidate the page to update the comments list
revalidatePath(`/${comment.pageSlug}`);
} catch (error) {
console.error("[server/comments] error deleting comment:", error);
throw new Error("Failed to delete comment");
}
};
+143
View File
@@ -0,0 +1,143 @@
import "server-only";
import { env } from "@/lib/env";
import * as cheerio from "cheerio";
import { graphql } from "@octokit/graphql";
import type { Repository, User } from "@octokit/graphql-schema";
export const getContributions = async (): Promise<
Array<{
date: string;
count: number;
level: number;
}>
> => {
// thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts
try {
const response = await fetch(`https://github.com/users/${env.NEXT_PUBLIC_GITHUB_USERNAME}/contributions`, {
headers: {
referer: `https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`,
"x-requested-with": "XMLHttpRequest",
},
cache: "force-cache",
next: {
revalidate: 3600, // 1 hour
tags: ["github-contributions"],
},
});
const $ = cheerio.load(await response.text());
const days = $(".js-calendar-graph-table .ContributionCalendar-day")
.get()
.sort((a, b) => {
const dateA = a.attribs["data-date"] ?? "";
const dateB = b.attribs["data-date"] ?? "";
return dateA.localeCompare(dateB, "en");
});
const dayTooltips = $(".js-calendar-graph tool-tip")
.toArray()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.reduce<Record<string, any>>((map, elem) => {
map[elem.attribs["for"]] = elem;
return map;
}, {});
return days.map((day) => {
const attr = {
id: day.attribs["id"],
date: day.attribs["data-date"],
level: day.attribs["data-level"],
};
let count = 0;
if (dayTooltips[attr.id]) {
const text = dayTooltips[attr.id].firstChild;
if (text) {
const countMatch = text.data.trim().match(/^\d+/);
if (countMatch) {
count = parseInt(countMatch[0]);
}
}
}
const level = parseInt(attr.level);
return {
date: attr.date,
count,
level,
};
});
} catch (error) {
console.error("[server/github] Failed to fetch contributions:", error);
return [];
}
};
export const getRepos = async (): Promise<Repository[] | undefined> => {
try {
// https://docs.github.com/en/graphql/reference/objects#repository
const { user } = await graphql<{ user: User }>(
`
query ($username: String!, $sort: RepositoryOrderField!, $limit: Int) {
user(login: $username) {
repositories(
first: $limit
isLocked: false
isFork: false
ownerAffiliations: OWNER
privacy: PUBLIC
orderBy: { field: $sort, direction: DESC }
) {
edges {
node {
name
url
description
pushedAt
stargazerCount
forkCount
primaryLanguage {
name
color
}
}
}
}
}
}
`,
{
username: env.NEXT_PUBLIC_GITHUB_USERNAME,
sort: "STARGAZERS",
limit: 12,
headers: {
accept: "application/vnd.github.v3+json",
authorization: `token ${env.GITHUB_TOKEN}`,
},
request: {
// override fetch() to use next's extension to cache the response
// https://nextjs.org/docs/app/api-reference/functions/fetch#fetchurl-options
fetch: (url: string | URL | Request, options?: RequestInit) => {
return fetch(url, {
...options,
cache: "force-cache",
next: {
revalidate: 3600, // 1 hour
tags: ["github-repos"],
},
});
},
},
}
);
return user.repositories.edges?.map((edge) => edge!.node as Repository);
} catch (error) {
console.error("[server/github] Failed to fetch repositories:", error);
return [];
}
};
+107
View File
@@ -0,0 +1,107 @@
"use server";
import { env } from "@/lib/env";
import { headers } from "next/headers";
import * as v from "valibot";
import { Resend } from "resend";
import siteConfig from "@/lib/config/site";
const ContactSchema = v.object({
name: v.message(v.pipe(v.string(), v.trim(), v.nonEmpty()), "Your name is required."),
email: v.message(v.pipe(v.string(), v.trim(), v.nonEmpty(), v.email()), "Your email address is required."),
message: v.message(v.pipe(v.string(), v.trim(), v.minLength(15)), "Your message must be at least 15 characters."),
"cf-turnstile-response": v.message(
v.pipe(
// token wasn't submitted at _all_, most likely a direct POST request by a spam bot
v.string(),
// form submitted properly but token was missing, might be a forgetful human
v.nonEmpty(),
// very rudimentary length check based on Cloudflare's docs
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
v.maxLength(2048),
v.readonly()
),
"Are you sure you're not a robot...? 🤖"
),
});
export type ContactInput = v.InferInput<typeof ContactSchema>;
export type ContactState = {
success: boolean;
message: string;
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
};
export const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
// TODO: remove after debugging why automated spam entries are causing 500 errors
console.debug("[server/resend] received payload:", payload);
try {
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
if (!data.success) {
return {
success: false,
message: "Please make sure all fields are filled in.",
errors: v.flatten(data.issues).nested,
};
}
// try to get the client IP (for turnstile accuracy, not logging!) but no biggie if we can't
let remoteip;
try {
remoteip = (await headers()).get("x-forwarded-for");
} catch {} // eslint-disable-line no-empty
// validate captcha
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: data.output["cf-turnstile-response"],
remoteip,
}),
cache: "no-store",
});
if (!turnstileResponse || !turnstileResponse.ok) {
throw new Error(`[server/resend] turnstile validation failed: ${turnstileResponse.status}`);
}
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
if (!turnstileData.success) {
return {
success: false,
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
};
}
if (env.RESEND_FROM_EMAIL === "onboarding@resend.dev") {
// https://resend.com/docs/api-reference/emails/send-email
console.warn("[server/resend] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
}
// send email
const resend = new Resend(env.RESEND_API_KEY);
await resend.emails.send({
from: `${data.output.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
replyTo: `${data.output.name} <${data.output.email}>`,
to: [env.RESEND_TO_EMAIL],
subject: `[${siteConfig.name}] Contact Form Submission`,
text: data.output.message,
});
return { success: true, message: "Thanks! You should hear from me soon." };
} catch (error) {
console.error("[server/resend] fatal error:", error);
return {
success: false,
message: "Internal server error. Please try again later or shoot me an email.",
};
}
};
+85
View File
@@ -0,0 +1,85 @@
import "server-only";
import { cache } from "react";
import { eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db";
import { page } from "@/lib/db/schema";
export const incrementViews = async (slug: string): Promise<number> => {
try {
// First, try to find the existing record
const existingHit = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
if (existingHit.length === 0) {
// Create new record if it doesn't exist
await db.insert(page).values({ slug, views: 1 }).execute();
return 1; // New record starts with 1 hit
} else {
// Calculate new hit count
const newViewCount = existingHit[0].views + 1;
// Update existing record by incrementing hits
await db.update(page).set({ views: newViewCount }).where(eq(page.slug, slug)).execute();
return newViewCount;
}
} catch (error) {
console.error("[view-counter] fatal error:", error);
throw new Error("Failed to increment views");
}
};
export const getViews: {
/**
* Retrieves the number of views for a given slug, or null if the slug does not exist
*/
(slug: string): Promise<number | null>;
/**
* Retrieves the numbers of views for an array of slugs
*/
(slug: string[]): Promise<Record<string, number | null>>;
/**
* Retrieves the numbers of views for ALL slugs
*/
(): Promise<Record<string, number>>;
} = cache(
async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slug?: any
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise<any> => {
try {
// return one page
if (typeof slug === "string") {
const pages = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
return pages[0].views;
}
// return multiple pages
if (Array.isArray(slug)) {
const pages = await db.select().from(page).where(inArray(page.slug, slug));
return pages.reduce(
(acc, page, index) => {
acc[slug[index]] = page.views;
return acc;
},
{} as Record<string, number | null>
);
}
// return ALL pages
const pages = await db.select().from(page);
return pages.reduce(
(acc, page) => {
acc[page.slug] = page.views;
return acc;
},
{} as Record<string, number>
);
} catch (error) {
console.error("[server/views] fatal error:", error);
throw new Error("Failed to get views");
}
}
);