Skip to content

Commit 957ccb8

Browse files
committed
feat: Introduce blog post publishing dates, optimize public stats with caching, and enhance PWA installation prompts.
1 parent 1662541 commit 957ccb8

16 files changed

Lines changed: 589 additions & 110 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- AlterTable
2+
ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3);
3+
4+
-- Backfill first-publish timestamp for existing published rows
5+
UPDATE "BlogPost"
6+
SET "publishedAt" = "createdAt"
7+
WHERE "published" = true
8+
AND "publishedAt" IS NULL;
9+
10+
-- CreateIndex
11+
CREATE INDEX "BlogPost_published_publishedAt_idx" ON "BlogPost"("published", "publishedAt" DESC);

prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,11 @@ model BlogPost {
258258
category String
259259
image String
260260
published Boolean @default(false)
261+
publishedAt DateTime?
261262
createdAt DateTime @default(now())
262263
updatedAt DateTime @updatedAt
263264
265+
@@index([published, publishedAt(sort: Desc)])
264266
@@index([published, createdAt(sort: Desc)])
265267
@@index([slug])
266268
}

scripts/seed-blog.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ async function main() {
3838
category: post.category,
3939
image: post.image,
4040
published: true,
41+
publishedAt: new Date(),
4142
},
4243
});
4344
console.log(`- Seeded unique post: ${post.slug}`);

src/app/admin/blog/[id]/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import BlogEditor from "@/components/admin/BlogEditor";
2-
import { getPostBySlug, getAllPosts } from "@/lib/services/blog-service";
32
import { prisma } from "@/lib/db";
43
import { notFound } from "next/navigation";
54

src/app/admin/blog/actions.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"use server";
22

3-
import { upsertPost, deletePost as deletePostFromDb } from "@/lib/services/blog-service";
3+
import { savePost, deletePost as deletePostFromDb, type SavePostInput } from "@/lib/services/blog-service";
44
import { auth } from "@/lib/auth";
55
import { isAdminUser } from "@/lib/admin-auth";
66
import { revalidatePath } from "next/cache";
77
import { redirect } from "next/navigation";
8-
import { BlogPost } from "@prisma/client";
98

109
async function checkAdmin() {
1110
const session = await auth();
@@ -14,27 +13,21 @@ async function checkAdmin() {
1413
}
1514
}
1615

17-
export async function savePostAction(data: Partial<BlogPost>) {
16+
export async function savePostAction(data: SavePostInput) {
1817
await checkAdmin();
19-
20-
const result = await upsertPost(data);
21-
18+
19+
const result = await savePost(data);
20+
2221
revalidatePath("/admin/blog");
23-
revalidatePath("/blog");
24-
if (result.slug) {
25-
revalidatePath(`/blog/${result.slug}`);
26-
}
27-
2822
return result;
2923
}
3024

3125
export async function deletePostAction(id: string) {
3226
await checkAdmin();
33-
27+
3428
await deletePostFromDb(id);
35-
29+
3630
revalidatePath("/admin/blog");
37-
revalidatePath("/blog");
38-
31+
3932
redirect("/admin/blog");
4033
}

src/app/admin/blog/page.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,23 @@ export default async function BlogAdminPage() {
6363
</td>
6464
<td className="px-6 py-4">
6565
<div className="flex items-center gap-3">
66-
<Link
67-
href={`/blog/${post.slug}`}
68-
target="_blank"
69-
className="p-1.5 text-zinc-500 hover:text-blue-400 hover:bg-blue-400/5 rounded-lg transition-all"
70-
title="View Live"
71-
>
72-
<Eye size={16} />
73-
</Link>
66+
{post.published ? (
67+
<Link
68+
href={`/blog/${post.slug}`}
69+
target="_blank"
70+
className="p-1.5 text-zinc-500 hover:text-blue-400 hover:bg-blue-400/5 rounded-lg transition-all"
71+
title="View Live"
72+
>
73+
<Eye size={16} />
74+
</Link>
75+
) : (
76+
<span
77+
className="p-1.5 text-zinc-700 rounded-lg cursor-not-allowed"
78+
title="Drafts are not publicly visible"
79+
>
80+
<Eye size={16} />
81+
</span>
82+
)}
7483
<Link
7584
href={`/admin/blog/${post.id}`}
7685
className="p-1.5 text-zinc-500 hover:text-purple-400 hover:bg-purple-400/5 rounded-lg transition-all"

src/app/blog/[slug]/page.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { getPublishedPosts, getPostBySlug } from "@/lib/services/blog-service";
1+
import { getPublishedPostBySlug, getPublishedPosts } from "@/lib/services/blog-service";
22
import { notFound } from "next/navigation";
33
import Link from "next/link";
44
import Image from "next/image";
55
import { ArrowLeft, Clock, Share2 } from "lucide-react";
66
import Footer from "@/components/Footer";
77
import { EnhancedMarkdown } from "@/components/EnhancedMarkdown";
88
import { BlogPost } from "@prisma/client";
9+
import { Metadata } from "next";
910

1011
// Generates static params for all blog posts
1112
export async function generateStaticParams() {
@@ -15,9 +16,56 @@ export async function generateStaticParams() {
1516
}));
1617
}
1718

19+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
20+
const { slug } = await params;
21+
const post = await getPublishedPostBySlug(slug);
22+
23+
if (!post) {
24+
return {
25+
title: "Post Not Found",
26+
robots: {
27+
index: false,
28+
follow: false,
29+
},
30+
};
31+
}
32+
33+
const canonicalPath = `/blog/${post.slug}`;
34+
const publishedTime = (post.publishedAt ?? post.createdAt).toISOString();
35+
36+
return {
37+
title: post.title,
38+
description: post.excerpt,
39+
alternates: {
40+
canonical: canonicalPath,
41+
},
42+
openGraph: {
43+
title: post.title,
44+
description: post.excerpt,
45+
type: "article",
46+
url: canonicalPath,
47+
images: [
48+
{
49+
url: post.image,
50+
alt: post.title,
51+
},
52+
],
53+
publishedTime,
54+
modifiedTime: post.updatedAt.toISOString(),
55+
authors: [post.author],
56+
},
57+
twitter: {
58+
card: "summary_large_image",
59+
title: post.title,
60+
description: post.excerpt,
61+
images: [post.image],
62+
},
63+
};
64+
}
65+
1866
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
1967
const { slug } = await params;
20-
const post = await getPostBySlug(slug);
68+
const post = await getPublishedPostBySlug(slug);
2169

2270
if (!post) {
2371
notFound();

src/app/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Suspense } from 'react';
22
import { Metadata } from 'next';
33
import HomeClient from './HomeClient';
4-
import { getPublishedPosts } from '@/lib/services/blog-service';
4+
import { getHomepagePosts } from '@/lib/services/blog-service';
55

66
export const metadata: Metadata = {
77
alternates: {
@@ -10,8 +10,7 @@ export const metadata: Metadata = {
1010
};
1111

1212
export default async function Home() {
13-
const posts = await getPublishedPosts();
14-
const latestPosts = posts.slice(0, 3);
13+
const latestPosts = await getHomepagePosts();
1514

1615
return (
1716
<Suspense fallback={null}>

src/app/sitemap.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { getPublishedPostsMock, getCanonicalSiteUrlMock, existsSyncMock } = vi.hoisted(() => ({
4+
getPublishedPostsMock: vi.fn(),
5+
getCanonicalSiteUrlMock: vi.fn(),
6+
existsSyncMock: vi.fn(),
7+
}));
8+
9+
vi.mock("fs", () => ({
10+
default: {
11+
existsSync: existsSyncMock,
12+
readFileSync: vi.fn(),
13+
},
14+
existsSync: existsSyncMock,
15+
readFileSync: vi.fn(),
16+
}));
17+
18+
vi.mock("@/lib/services/blog-service", () => ({
19+
getPublishedPosts: getPublishedPostsMock,
20+
}));
21+
22+
vi.mock("@/lib/site-url", () => ({
23+
getCanonicalSiteUrl: getCanonicalSiteUrlMock,
24+
}));
25+
26+
import sitemap from "@/app/sitemap";
27+
28+
describe("sitemap blog metadata", () => {
29+
beforeEach(() => {
30+
getPublishedPostsMock.mockReset();
31+
getCanonicalSiteUrlMock.mockReset();
32+
existsSyncMock.mockReset();
33+
34+
getCanonicalSiteUrlMock.mockReturnValue("https://repomind.in");
35+
existsSyncMock.mockReturnValue(false);
36+
});
37+
38+
it("uses each blog post updatedAt as sitemap lastModified", async () => {
39+
const updatedAt = new Date("2026-03-14T12:00:00.000Z");
40+
getPublishedPostsMock.mockResolvedValue([
41+
{
42+
slug: "my-post",
43+
updatedAt,
44+
},
45+
]);
46+
47+
const routes = await sitemap();
48+
const blogRoute = routes.find((entry) => entry.url === "https://repomind.in/blog/my-post");
49+
50+
expect(blogRoute?.lastModified).toEqual(updatedAt);
51+
});
52+
});

src/app/sitemap.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
4848
},
4949
...blogPosts.map((post) => ({
5050
url: `${baseUrl}/blog/${post.slug}`,
51-
lastModified: new Date(),
51+
lastModified: post.updatedAt,
5252
changeFrequency: "monthly" as const,
5353
priority: 0.7,
5454
})),

0 commit comments

Comments
 (0)