mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-06-05 19:15:30 -04:00
Enhance notes page with comment counts and display. Update data fetching to include comment counts alongside views, and integrate comment count badges in both the notes listing and individual post pages.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { unstable_cache as cache } from "next/cache";
|
||||
import { getViews as _getViews } from "@/lib/views";
|
||||
import { getViewCounts as _getViewCounts } from "@/lib/views";
|
||||
|
||||
const getViews = cache(_getViews, undefined, {
|
||||
const getViewCounts = cache(_getViewCounts, undefined, {
|
||||
revalidate: 300, // 5 minutes
|
||||
tags: ["hits"],
|
||||
});
|
||||
@@ -19,7 +19,7 @@ export const GET = async (): Promise<
|
||||
}>
|
||||
> => {
|
||||
// note: while hits have been renamed to views in most places, this API shouldn't change due to it being snapshotted
|
||||
const views = await getViews();
|
||||
const views = await getViewCounts();
|
||||
|
||||
const total = {
|
||||
hits: Object.values(views).reduce((acc, curr) => acc + curr, 0),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { env } from "@/lib/env";
|
||||
import { Suspense } from "react";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import { formatDate, formatDateISO } from "@/lib/date";
|
||||
import { CalendarDaysIcon, TagIcon, SquarePenIcon, EyeIcon } from "lucide-react";
|
||||
import { CalendarDaysIcon, TagIcon, SquarePenIcon, EyeIcon, MessagesSquareIcon } from "lucide-react";
|
||||
import Link from "@/components/link";
|
||||
import ViewCounter from "@/components/view-counter";
|
||||
import Comments from "@/components/comments/comments";
|
||||
@@ -12,6 +12,7 @@ import { createMetadata } from "@/lib/metadata";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
import authorConfig from "@/lib/config/author";
|
||||
import { size as ogImageSize } from "./opengraph-image";
|
||||
import { getCommentCounts } from "@/lib/server/comments";
|
||||
import type { Metadata } from "next";
|
||||
import type { BlogPosting } from "schema-dts";
|
||||
|
||||
@@ -54,6 +55,7 @@ export const generateMetadata = async ({ params }: { params: Promise<{ slug: str
|
||||
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
const { slug } = await params;
|
||||
const frontmatter = await getFrontMatter(slug);
|
||||
const commentCount = await getCommentCounts(`${POSTS_DIR}/${slug}`);
|
||||
|
||||
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`);
|
||||
|
||||
@@ -120,6 +122,15 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
<span>Improve This Post</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/${POSTS_DIR}/${frontmatter!.slug}#comments`}
|
||||
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)} ${commentCount === 1 ? "comment" : "comments"}`}
|
||||
className="text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"
|
||||
>
|
||||
<MessagesSquareIcon className="inline size-4 shrink-0" />
|
||||
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)}</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap">
|
||||
<EyeIcon className="inline size-4 shrink-0" />
|
||||
<Suspense
|
||||
|
||||
+22
-5
@@ -1,11 +1,12 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { EyeIcon } from "lucide-react";
|
||||
import { EyeIcon, MessagesSquareIcon } from "lucide-react";
|
||||
import Link from "@/components/link";
|
||||
import { getFrontMatter, POSTS_DIR, type FrontMatter } from "@/lib/posts";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { formatDate, formatDateISO } from "@/lib/date";
|
||||
import authorConfig from "@/lib/config/author";
|
||||
import { getViews } from "@/lib/views";
|
||||
import { getViewCounts } from "@/lib/views";
|
||||
import { getCommentCounts } from "@/lib/server/comments";
|
||||
|
||||
export const revalidate = 300; // 5 minutes
|
||||
|
||||
@@ -17,10 +18,10 @@ export const metadata = createMetadata({
|
||||
|
||||
const Page = async () => {
|
||||
// parse the year of each post and group them together
|
||||
const [posts, views] = await Promise.all([getFrontMatter(), getViews()]);
|
||||
const [posts, views, comments] = await Promise.all([getFrontMatter(), getViewCounts(), getCommentCounts()]);
|
||||
|
||||
const postsByYear: {
|
||||
[year: string]: (FrontMatter & { views: number })[];
|
||||
[year: string]: (FrontMatter & { views: number; comments: number })[];
|
||||
} = {};
|
||||
|
||||
posts.forEach((post) => {
|
||||
@@ -28,6 +29,7 @@ const Page = async () => {
|
||||
(postsByYear[year] || (postsByYear[year] = [])).push({
|
||||
...post,
|
||||
views: views[`${POSTS_DIR}/${post.slug}`] || 0,
|
||||
comments: comments[`${POSTS_DIR}/${post.slug}`] || 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +42,7 @@ const Page = async () => {
|
||||
{year}
|
||||
</h2>
|
||||
<ul className="space-y-4">
|
||||
{posts.map(({ slug, date, title, htmlTitle, views }) => (
|
||||
{posts.map(({ slug, date, title, htmlTitle, views, comments }) => (
|
||||
<li className="flex text-base leading-relaxed" key={slug}>
|
||||
<span className="text-muted-foreground w-18 shrink-0 md:w-22">
|
||||
<time dateTime={formatDateISO(date)} title={formatDate(date, "MMM d, y, h:mm a O")}>
|
||||
@@ -62,6 +64,21 @@ const Page = async () => {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{comments > 0 && (
|
||||
<Link
|
||||
href={`/${POSTS_DIR}/${slug}#comments`}
|
||||
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(comments)} ${comments === 1 ? "comment" : "comments"}`}
|
||||
className="inline-flex hover:no-underline"
|
||||
>
|
||||
<span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none">
|
||||
<MessagesSquareIcon className="inline-block size-3 shrink-0" />
|
||||
<span className="inline-block leading-none">
|
||||
{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(comments)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
+78
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { eq, desc, inArray, sql } from "drizzle-orm";
|
||||
import { checkBotId } from "botid/server";
|
||||
import { db } from "@/lib/db";
|
||||
import * as schema from "@/lib/db/schema";
|
||||
@@ -37,6 +37,75 @@ export const getComments = async (pageSlug: string): Promise<CommentWithUser[]>
|
||||
}
|
||||
};
|
||||
|
||||
export const getCommentCounts: {
|
||||
/**
|
||||
* Retrieves the number of comments for a given slug
|
||||
*/
|
||||
(slug: string): Promise<number>;
|
||||
/**
|
||||
* Retrieves the numbers of comments for an array of slugs
|
||||
*/
|
||||
(slug: string[]): Promise<Record<string, number>>;
|
||||
/**
|
||||
* Retrieves the numbers of comments for ALL slugs
|
||||
*/
|
||||
(): Promise<Record<string, number>>;
|
||||
} = 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 result = await db
|
||||
.select({
|
||||
count: sql<number>`cast(count(${schema.comment.id}) as int)`,
|
||||
})
|
||||
.from(schema.comment)
|
||||
.where(eq(schema.comment.pageSlug, slug));
|
||||
|
||||
return Number(result[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
// return multiple pages
|
||||
if (Array.isArray(slug)) {
|
||||
const rows = await db
|
||||
.select({
|
||||
pageSlug: schema.comment.pageSlug,
|
||||
count: sql<number>`cast(count(${schema.comment.id}) as int)`,
|
||||
})
|
||||
.from(schema.comment)
|
||||
.where(inArray(schema.comment.pageSlug, slug))
|
||||
.groupBy(schema.comment.pageSlug);
|
||||
|
||||
const map: Record<string, number> = Object.fromEntries(slug.map((s: string) => [s, 0]));
|
||||
for (const row of rows) {
|
||||
map[row.pageSlug] = Number(row.count ?? 0);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// return ALL pages
|
||||
const rows = await db
|
||||
.select({
|
||||
pageSlug: schema.comment.pageSlug,
|
||||
count: sql<number>`cast(count(${schema.comment.id}) as int)`,
|
||||
})
|
||||
.from(schema.comment)
|
||||
.groupBy(schema.comment.pageSlug);
|
||||
|
||||
const map: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
map[row.pageSlug] = Number(row.count ?? 0);
|
||||
}
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.error("[server/comments] error fetching comment counts:", error);
|
||||
throw new Error("Failed to fetch comment counts");
|
||||
}
|
||||
};
|
||||
|
||||
export const createComment = async (data: { content: string; pageSlug: string; parentId?: string }) => {
|
||||
// BotID server-side verification
|
||||
const verification = await checkBotId();
|
||||
@@ -64,6 +133,8 @@ export const createComment = async (data: { content: string; pageSlug: string; p
|
||||
|
||||
// Revalidate the page to show the new comment
|
||||
revalidatePath(`/${data.pageSlug}`);
|
||||
// Also revalidate the notes listing to update comment count badges
|
||||
revalidatePath("/notes");
|
||||
} catch (error) {
|
||||
console.error("[server/comments] error creating comment:", error);
|
||||
throw new Error("Failed to create comment");
|
||||
@@ -114,6 +185,9 @@ export const updateComment = async (commentId: string, content: string) => {
|
||||
|
||||
// Revalidate the page to show the updated comment
|
||||
revalidatePath(`/${comment.pageSlug}`);
|
||||
// Also revalidate the notes listing to update comment count badges
|
||||
// TODO: make this more generic in case we want to add comments to non-note pages
|
||||
revalidatePath("/notes");
|
||||
} catch (error) {
|
||||
console.error("[server/comments] error updating comment:", error);
|
||||
throw new Error("Failed to update comment");
|
||||
@@ -158,6 +232,9 @@ export const deleteComment = async (commentId: string) => {
|
||||
|
||||
// Revalidate the page to update the comments list
|
||||
revalidatePath(`/${comment.pageSlug}`);
|
||||
// Also revalidate the notes listing to update comment count badges
|
||||
// TODO: make this more generic in case we want to add comments to non-note pages
|
||||
revalidatePath("/notes");
|
||||
} catch (error) {
|
||||
console.error("[server/comments] error deleting comment:", error);
|
||||
throw new Error("Failed to delete comment");
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export const incrementViews = async (slug: string): Promise<number> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getViews: {
|
||||
export const getViewCounts: {
|
||||
/**
|
||||
* Retrieves the number of views for a given slug, or null if the slug does not exist
|
||||
*/
|
||||
|
||||
+6
-6
@@ -55,7 +55,7 @@
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"fast-glob": "^3.3.3",
|
||||
"feed": "^5.1.0",
|
||||
"geist": "^1.4.2",
|
||||
"geist": "^1.5.1",
|
||||
"html-entities": "^2.6.0",
|
||||
"lucide-react": "0.542.0",
|
||||
"next": "15.5.1-canary.31",
|
||||
@@ -91,25 +91,25 @@
|
||||
"shiki": "^3.12.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"unified": "^11.0.5",
|
||||
"zod": "4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@jakejarvis/eslint-config": "^4.0.7",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"cross-env": "^10.0.0",
|
||||
"dotenv": "^17.2.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-next": "15.5.1-canary.31",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
|
||||
Generated
+274
-289
File diff suppressed because it is too large
Load Diff
+18
-5
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,7 +15,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -20,9 +24,18 @@
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user