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:
@@ -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
@@ -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
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1747229716675,
|
||||
"tag": "0000_puzzling_sphinx",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user