Add blog CMS with view tracking #1

Merged
space merged 3 commits from feat/blog-cms into main 2026-07-18 00:28:24 +02:00
24 changed files with 1579 additions and 36 deletions
+6 -4
View File
@@ -6,17 +6,19 @@ Personal portfolio built with Next.js, React, TypeScript, and Tailwind CSS.
- Hero dev-card, live-count stat, and work-experience timeline - Hero dev-card, live-count stat, and work-experience timeline
- Projects (served from our DB) with a `/projects` page and homepage teaser - Projects (served from our DB) with a `/projects` page and homepage teaser
- Blog system with DB-backed markdown posts, admin editing, and view tracking
- Luna section, DB-driven affiliates, and the SHSF Code Activity graph - Luna section, DB-driven affiliates, and the SHSF Code Activity graph
- Admin CMS at `/admin` for projects, work experience, and affiliates - Admin CMS at `/admin` for projects, blog posts, work experience, and affiliates
## Data layer ## Data layer
Content (projects, work experience, affiliates) lives in **Postgres** and is Content (projects, blog posts, work experience, affiliates) lives in **Postgres** and is
managed through Prisma + our own Next.js API routes: managed through Prisma + our own Next.js API routes:
- Public reads: `GET /api/projects`, `/api/experience`, `/api/affiliates` - Public reads: `GET /api/projects`, `/api/blogs`, `/api/blogs/top`, `/api/experience`, `/api/affiliates`
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,experience,affiliates}/…` - Admin writes: `POST/PUT/DELETE /api/admin/{projects,blogs,experience,affiliates}/…`
(cookie session issued by `POST /api/admin/login`) (cookie session issued by `POST /api/admin/login`)
- Blog content views increment when `/blogs/[slug]` or `GET /api/blogs/[slug]` is requested
- `/api/profile-image` serves the avatar (cached in memory) - `/api/profile-image` serves the avatar (cached in memory)
- Only the **Code Activity** widget still uses the external SHSF API. - Only the **Code Activity** widget still uses the external SHSF API.
+2
View File
@@ -20,7 +20,9 @@
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
+874
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "BlogPost" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"excerpt" TEXT NOT NULL,
"coverImageUrl" TEXT,
"content" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"views" INTEGER NOT NULL DEFAULT 0,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
+14
View File
@@ -64,3 +64,17 @@ model Affiliate {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model BlogPost {
id String @id @default(cuid())
title String
slug String @unique
excerpt String
coverImageUrl String?
content String @db.Text
isPublished Boolean @default(true)
views Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+116 -3
View File
@@ -4,13 +4,15 @@ import { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PROJECT_SIZES, SIZE_LABELS, type ProjectSize } from "../../types"; import { PROJECT_SIZES, SIZE_LABELS, type ProjectSize } from "../../types";
import { MarkdownContent } from "@/components/MarkdownContent";
type Resource = "projects" | "experience" | "affiliates"; type Resource = "projects" | "experience" | "affiliates" | "blogs";
const RESOURCES: { key: Resource; label: string }[] = [ const RESOURCES: { key: Resource; label: string }[] = [
{ key: "projects", label: "Projects" }, { key: "projects", label: "Projects" },
{ key: "experience", label: "Work Experience" }, { key: "experience", label: "Work Experience" },
{ key: "affiliates", label: "Affiliates" }, { key: "affiliates", label: "Affiliates" },
{ key: "blogs", label: "Blogs" },
]; ];
// A record being edited; `_new` marks an unsaved draft (POST vs PUT). // A record being edited; `_new` marks an unsaved draft (POST vs PUT).
@@ -56,6 +58,17 @@ const TEMPLATES: Record<Resource, () => Row> = {
bad: [], bad: [],
sortIndex: 0, sortIndex: 0,
}), }),
blogs: () => ({
_new: true,
title: "",
slug: "",
excerpt: "",
coverImageUrl: "",
content: "# New post\n\nStart writing here.",
isPublished: true,
publishedAt: "",
views: 0,
}),
}; };
const inputCls = const inputCls =
@@ -69,6 +82,13 @@ function toDateInput(value: unknown): string {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
function toDateTimeInput(value: unknown): string {
if (!value) return "";
const d = new Date(String(value));
if (Number.isNaN(d.getTime())) return "";
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
export default function AdminPage() { export default function AdminPage() {
const router = useRouter(); const router = useRouter();
@@ -137,6 +157,8 @@ export default function AdminPage() {
? json.map((item: Row) => ? json.map((item: Row) =>
res === "experience" res === "experience"
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) } ? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
: res === "blogs"
? { ...item, publishedAt: toDateTimeInput(item.publishedAt) }
: item, : item,
) )
: []; : [];
@@ -169,7 +191,8 @@ export default function AdminPage() {
setSavingId(row.id || `new-${idx}`); setSavingId(row.id || `new-${idx}`);
try { try {
const url = isNew ? `/api/admin/${resource}` : `/api/admin/${resource}/${row.id}`; const url = isNew ? `/api/admin/${resource}` : `/api/admin/${resource}/${row.id}`;
const { _new, ...payload } = row; const payload = { ...row };
delete payload._new;
const res = await fetch(url, { const res = await fetch(url, {
method: isNew ? "POST" : "PUT", method: isNew ? "POST" : "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -257,7 +280,7 @@ export default function AdminPage() {
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-10"> <header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-10">
<div> <div>
<h1 className="text-3xl font-black text-white">PORTFOLIO CMS</h1> <h1 className="text-3xl font-black text-white">PORTFOLIO CMS</h1>
<p className="text-gray-500 text-sm">Manage your projects, experience &amp; affiliates</p> <p className="text-gray-500 text-sm">Manage your projects, experience, affiliates &amp; blog posts</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={() => router.push("/")} className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm"> <button onClick={() => router.push("/")} className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm">
@@ -330,6 +353,7 @@ export default function AdminPage() {
{resource === "projects" && <ProjectFields row={row} idx={idx} update={update} />} {resource === "projects" && <ProjectFields row={row} idx={idx} update={update} />}
{resource === "experience" && <ExperienceFields row={row} idx={idx} update={update} />} {resource === "experience" && <ExperienceFields row={row} idx={idx} update={update} />}
{resource === "affiliates" && <AffiliateFields row={row} idx={idx} update={update} />} {resource === "affiliates" && <AffiliateFields row={row} idx={idx} update={update} />}
{resource === "blogs" && <BlogFields row={row} idx={idx} update={update} />}
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -541,3 +565,92 @@ function AffiliateFields({ row, idx, update }: FieldProps) {
</div> </div>
); );
} }
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function BlogFields({ row, idx, update }: FieldProps) {
const slug = typeof row.slug === "string" ? row.slug : "";
const content = typeof row.content === "string" ? row.content : "";
return (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Text row={row} idx={idx} update={update} field="title" label="Title" />
<div className="space-y-1">
<label className={labelCls}>Slug</label>
<div className="flex gap-2">
<input
type="text"
value={slug}
onChange={(e) => update(idx, "slug", e.target.value)}
className={inputCls}
placeholder="my-post-slug"
/>
<button
type="button"
onClick={() => update(idx, "slug", slugify(String(row.title ?? "")))}
className="shrink-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-bold text-gray-300 hover:bg-white/10"
>
Use title
</button>
</div>
</div>
<Text row={row} idx={idx} update={update} field="coverImageUrl" label="Cover Image URL" />
<div className="space-y-1">
<label className={labelCls}>Publish At</label>
<input
type="datetime-local"
value={(row.publishedAt as string) || ""}
onChange={(e) => update(idx, "publishedAt", e.target.value)}
className={inputCls}
/>
</div>
<Area row={row} idx={idx} update={update} field="excerpt" label="Excerpt" />
<Toggle row={row} idx={idx} update={update} field="isPublished" label="Published" />
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">Public Post</p>
<p className="text-sm text-gray-400">{slug ? `/blogs/${slug}` : "Set a slug to get a public URL."}</p>
</div>
{slug ? (
<a
href={`/blogs/${slug}`}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-bold text-gray-200 hover:bg-white/10"
>
Open post
</a>
) : null}
</div>
<p className="text-xs text-gray-500">Views: {Number(row.views ?? 0)}</p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="space-y-1">
<label className={labelCls}>Markdown Content</label>
<textarea
rows={22}
value={content}
onChange={(e) => update(idx, "content", e.target.value)}
className={`${inputCls} min-h-[28rem] font-mono text-sm`}
/>
</div>
<div className="space-y-1">
<label className={labelCls}>Live Preview</label>
<div className="min-h-[28rem] rounded-2xl border border-white/10 bg-black/30 p-5">
<MarkdownContent content={content} />
</div>
</div>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
const data = parseBlogPost(await readBody(req));
const updated = await prisma.blogPost.update({ where: { id }, data });
return NextResponse.json(updated);
});
}
export async function DELETE(_req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
await prisma.blogPost.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const posts = await prisma.blogPost.findMany({
orderBy: [{ updatedAt: "desc" }],
});
return NextResponse.json(posts);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseBlogPost(await readBody(req));
const created = await prisma.blogPost.create({ data });
return NextResponse.json(created, { status: 201 });
});
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogPost } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
export const dynamic = "force-dynamic";
export async function GET(_req: Request, { params }: Params) {
const { slug } = await params;
const post = await prisma.$transaction(async (tx) => {
const found = await tx.blogPost.findFirst({
where: { slug, isPublished: true },
});
if (!found) {
return null;
}
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
});
});
if (!post) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(toBlogPost(post));
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
return NextResponse.json(posts.map(toBlogSummary));
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
where: { isPublished: true },
orderBy: [{ views: "desc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
take: 3,
});
return NextResponse.json(posts.map(toBlogSummary));
}
+94
View File
@@ -0,0 +1,94 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer";
import { MarkdownContent } from "@/components/MarkdownContent";
import { prisma } from "@/lib/prisma";
import { getReadingTimeMinutes } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
export const dynamic = "force-dynamic";
function formatDate(value: Date | null) {
if (!value) return "Draft";
return new Intl.DateTimeFormat("en", {
day: "2-digit",
month: "long",
year: "numeric",
}).format(value);
}
async function getPost(slug: string) {
return prisma.$transaction(async (tx) => {
const found = await tx.blogPost.findFirst({
where: { slug, isPublished: true },
});
if (!found) return null;
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
});
});
}
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const post = await prisma.blogPost.findFirst({
where: { slug, isPublished: true },
});
if (!post) {
return {
title: "Post not found | Paul W. Portfolio",
};
}
return {
title: `${post.title} | Paul W. Portfolio`,
description: post.excerpt,
};
}
export default async function BlogDetailPage({ params }: Params) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return (
<>
<Navbar />
<div className="mx-auto w-full max-w-4xl px-4 pb-20 pt-28 sm:pt-32">
<article className="rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 sm:px-10 sm:py-14">
<div className="space-y-4 border-b border-white/10 pb-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
blog post
</p>
<h1 className="max-w-3xl text-4xl font-semibold text-white sm:text-5xl">
{post.title}
</h1>
<p className="max-w-2xl text-sm leading-7 text-gray-400 sm:text-base">
{post.excerpt}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.18em] text-gray-500">
<span>{formatDate(post.publishedAt)}</span>
<span>{post.views} views</span>
<span>{getReadingTimeMinutes(post.content)} min read</span>
</div>
</div>
<div className="pt-8">
<MarkdownContent content={post.content} />
</div>
</article>
</div>
<Footer />
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer";
import { BlogCard } from "@/components/BlogCard";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
export const metadata = {
title: "Blog | Paul W. Portfolio",
description: "Notes on infrastructure, tooling, and shipping software.",
};
export const dynamic = "force-dynamic";
export default async function BlogsPage() {
const posts = await prisma.blogPost.findMany({
where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
const blogs = posts.map(toBlogSummary);
return (
<>
<Navbar />
<div className="mx-auto w-full max-w-6xl px-4 pb-20 pt-28 sm:pt-32">
<section className="rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 sm:px-10 sm:py-14">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
writing
</p>
<h1 className="mt-4 max-w-3xl text-4xl font-semibold text-white sm:text-5xl">
Technical writing on infrastructure, software, and product work.
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-gray-400 sm:text-base">
Notes, updates, and technical writing across current projects and systems.
</p>
</section>
<section className="mt-10">
{blogs.length === 0 ? (
<div className="rounded-[1.75rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-gray-400">
No published posts yet.
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{blogs.map((blog) => (
<BlogCard key={blog.id} blog={blog} />
))}
</div>
)}
</section>
<div className="mt-10 flex justify-center">
<Link
href="/"
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-6 py-3 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
>
Back home
</Link>
</div>
</div>
<Footer />
</>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@
import { useProfile } from "../context/ProfileContext"; import { useProfile } from "../context/ProfileContext";
import { Navbar } from "../components/Navbar"; import { Navbar } from "../components/Navbar";
import { Hero } from "../sections/Hero"; import { Hero } from "../sections/Hero";
import { Stats } from "../sections/Stats"; import { TopBlogs } from "../sections/TopBlogs";
import { WorkExperience } from "../sections/WorkExperience"; import { WorkExperience } from "../sections/WorkExperience";
import { FeaturedProjects } from "../sections/FeaturedProjects"; import { FeaturedProjects } from "../sections/FeaturedProjects";
import { Luna } from "../sections/Luna"; import { Luna } from "../sections/Luna";
@@ -19,7 +19,7 @@ export default function Home() {
<Navbar /> <Navbar />
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32"> <div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
<Hero /> <Hero />
<Stats /> <TopBlogs />
<WorkExperience experiences={experiences} /> <WorkExperience experiences={experiences} />
<FeaturedProjects /> <FeaturedProjects />
<Luna /> <Luna />
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import type { BlogPostSummary } from "@/types";
function formatDate(value: string | null | undefined) {
if (!value) return "Draft";
return new Intl.DateTimeFormat("en", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
}
export function BlogCard({ blog, index }: { blog: BlogPostSummary; index?: number }) {
return (
<Link
href={`/blogs/${blog.slug}`}
className="group flex h-full flex-col rounded-[1.75rem] border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]"
>
<div className="mb-5 flex items-start justify-between gap-4">
<div className="space-y-2">
{index !== undefined ? (
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-gray-500">
Top {index + 1}
</p>
) : null}
<h3 className="text-2xl font-semibold text-white transition-colors group-hover:text-gray-100">
{blog.title}
</h3>
</div>
<div className="rounded-full border border-white/10 bg-black/40 px-3 py-1 text-xs font-medium text-gray-300">
{blog.views} views
</div>
</div>
<p className="flex-1 text-sm leading-7 text-gray-400">{blog.excerpt}</p>
<div className="mt-6 flex items-center justify-between gap-4 text-xs uppercase tracking-[0.18em] text-gray-500">
<span>{formatDate(blog.publishedAt)}</span>
<span>{blog.readingTimeMinutes} min read</span>
</div>
</Link>
);
}
+69
View File
@@ -0,0 +1,69 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="space-y-4 text-sm leading-7 text-gray-300 sm:text-base">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className="mt-8 text-3xl font-semibold text-white first:mt-0">{children}</h1>,
h2: ({ children }) => <h2 className="mt-8 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mt-6 text-xl font-semibold text-white">{children}</h3>,
p: ({ children }) => <p>{children}</p>,
ul: ({ children }) => <ul className="list-disc space-y-2 pl-6">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal space-y-2 pl-6">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-white underline decoration-white/30 underline-offset-4 hover:decoration-white"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-white/20 pl-4 italic text-gray-400">{children}</blockquote>
),
img: ({ src, alt }) => (
// Markdown images should feel deliberate in the article layout.
<img
src={src || ""}
alt={alt || ""}
className="my-6 w-full rounded-2xl border border-white/10 bg-black/40 object-cover"
/>
),
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-2xl border border-white/10">
<table className="min-w-full border-collapse text-left text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-white/6 text-white">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-white/10">{children}</tbody>,
tr: ({ children }) => <tr className="divide-x divide-white/10">{children}</tr>,
th: ({ children }) => <th className="px-4 py-3 font-semibold">{children}</th>,
td: ({ children }) => <td className="px-4 py-3 align-top text-gray-300">{children}</td>,
code: ({ className, children }) => {
const inline = !className;
if (inline) {
return <code className="rounded bg-white/8 px-1.5 py-0.5 font-mono text-[0.95em] text-white">{children}</code>;
}
return (
<code className="block overflow-x-auto rounded-2xl border border-white/10 bg-black/50 p-4 font-mono text-sm text-gray-200">
{children}
</code>
);
},
pre: ({ children }) => <pre>{children}</pre>,
hr: () => <hr className="border-white/10" />,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
}}
>
{content}
</ReactMarkdown>
</div>
);
}
+1 -1
View File
@@ -11,6 +11,7 @@ export function Navbar() {
const navItems = [ const navItems = [
{ label: "Home", path: "/" }, { label: "Home", path: "/" },
{ label: "Projects", path: "/projects" }, { label: "Projects", path: "/projects" },
{ label: "Blog", path: "/blogs" },
{ label: "Connect", path: "/contact" }, { label: "Connect", path: "/contact" },
]; ];
@@ -40,4 +41,3 @@ export function Navbar() {
</nav> </nav>
); );
} }
+4
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isAdmin } from "@/lib/auth"; import { isAdmin } from "@/lib/auth";
import { ValidationError } from "@/lib/dto"; import { ValidationError } from "@/lib/dto";
import { Prisma } from "@/generated/prisma/client";
/** Returns a 401 response when the caller isn't an authenticated admin, else null. */ /** Returns a 401 response when the caller isn't an authenticated admin, else null. */
export async function guard(): Promise<NextResponse | null> { export async function guard(): Promise<NextResponse | null> {
@@ -18,6 +19,9 @@ export async function run(handler: () => Promise<NextResponse>): Promise<NextRes
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
return NextResponse.json({ error: err.message }, { status: 400 }); return NextResponse.json({ error: err.message }, { status: 400 });
} }
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
return NextResponse.json({ error: "A unique field already uses that value" }, { status: 409 });
}
console.error(err); console.error(err);
return NextResponse.json({ error: "Internal error" }, { status: 500 }); return NextResponse.json({ error: "Internal error" }, { status: 500 });
} }
+36
View File
@@ -0,0 +1,36 @@
import type { BlogPost as PrismaBlogPost } from "@/generated/prisma/client";
import type { BlogPost, BlogPostSummary } from "@/types";
function countWords(content: string): number {
return content
.trim()
.split(/\s+/)
.filter(Boolean).length;
}
export function getReadingTimeMinutes(content: string): number {
return Math.max(1, Math.ceil(countWords(content) / 220));
}
export function toBlogSummary(post: PrismaBlogPost): BlogPostSummary {
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
coverImageUrl: post.coverImageUrl,
isPublished: post.isPublished,
views: post.views,
readingTimeMinutes: getReadingTimeMinutes(post.content),
publishedAt: post.publishedAt?.toISOString() ?? null,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
};
}
export function toBlogPost(post: PrismaBlogPost): BlogPost {
return {
...toBlogSummary(post),
content: post.content,
};
}
+28
View File
@@ -111,3 +111,31 @@ export function parseAffiliate(body: Record<string, unknown>) {
sortIndex: int(body.sortIndex), sortIndex: int(body.sortIndex),
}; };
} }
function slug(value: unknown): string {
const normalized = str(value, "slug")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (!normalized) {
throw new ValidationError('"slug" is invalid');
}
return normalized;
}
export function parseBlogPost(body: Record<string, unknown>) {
const isPublished = bool(body.isPublished);
const publishedAt = optDate(body.publishedAt);
return {
title: str(body.title, "title"),
slug: slug(body.slug),
excerpt: str(body.excerpt, "excerpt"),
coverImageUrl: optStr(body.coverImageUrl),
content: str(body.content, "content"),
isPublished,
publishedAt: isPublished ? publishedAt ?? new Date() : publishedAt,
};
}
+1 -1
View File
@@ -26,7 +26,7 @@ export function Activity() {
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2> <h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base"> <p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
Today, i've already been coding for Today, i&apos;ve already been coding for
<span className="font-mono text-base text-green-400 ml-1"> <span className="font-mono text-base text-green-400 ml-1">
{dailyActivity || "..."} {dailyActivity || "..."}
</span> </span>
-25
View File
@@ -1,25 +0,0 @@
"use client";
import { GitBranch } from "lucide-react";
import { useProfile } from "../context/ProfileContext";
export function Stats() {
const { projects, loading } = useProfile();
return (
<section className="mx-auto w-full max-w-5xl px-4">
<div className="grid grid-cols-1 gap-4 sm:max-w-xs">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-gray-400">Projects</span>
<GitBranch className="h-4 w-4 text-gray-500" />
</div>
<p className="text-3xl font-bold text-white">
{loading ? "—" : projects.length}
</p>
<p className="mt-1 text-xs text-gray-500">and many more private ones</p>
</div>
</div>
</section>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import type { BlogPostSummary } from "@/types";
import { BlogCard } from "@/components/BlogCard";
export function TopBlogs() {
const [blogs, setBlogs] = useState<BlogPostSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch("/api/blogs/top")
.then((res) => (res.ok ? res.json() : []))
.then((json) => {
if (!cancelled) {
setBlogs(Array.isArray(json) ? json : []);
}
})
.catch(() => {
if (!cancelled) setBlogs([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4">
<div className="space-y-3 text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
blog
</p>
<h2 className="text-3xl font-semibold text-white sm:text-4xl">
recent writing
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
Notes on infrastructure, software, and the things I am building.
</p>
</div>
{loading ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-56 animate-pulse rounded-[1.75rem] border border-white/10 bg-white/[0.03]"
/>
))}
</div>
) : blogs.length === 0 ? (
<div className="rounded-[1.75rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-gray-400">
No blog posts published yet.
</div>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{blogs.map((blog, index) => (
<BlogCard key={blog.id} blog={blog} index={index} />
))}
</div>
)}
</section>
);
}
+18
View File
@@ -47,6 +47,24 @@ export interface Affiliate {
sortIndex: number; sortIndex: number;
} }
export interface BlogPostSummary {
id: string;
title: string;
slug: string;
excerpt: string;
coverImageUrl?: string | null;
isPublished: boolean;
views: number;
readingTimeMinutes: number;
publishedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface BlogPost extends BlogPostSummary {
content: string;
}
export const PROJECT_SIZES: ProjectSize[] = ["Big", "MediumSized", "Small"]; export const PROJECT_SIZES: ProjectSize[] = ["Big", "MediumSized", "Small"];
export const SIZE_LABELS: Record<ProjectSize, string> = { export const SIZE_LABELS: Record<ProjectSize, string> = {