Add blog CMS with tracked public posts
This commit is contained in:
@@ -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
|
||||
- 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
|
||||
- Admin CMS at `/admin` for projects, work experience, and affiliates
|
||||
- Admin CMS at `/admin` for projects, blog posts, work experience, and affiliates
|
||||
|
||||
## 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:
|
||||
|
||||
- Public reads: `GET /api/projects`, `/api/experience`, `/api/affiliates`
|
||||
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,experience,affiliates}/…`
|
||||
- Public reads: `GET /api/projects`, `/api/blogs`, `/api/blogs/top`, `/api/experience`, `/api/affiliates`
|
||||
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,blogs,experience,affiliates}/…`
|
||||
(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)
|
||||
- Only the **Code Activity** widget still uses the external SHSF API.
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+874
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
-- 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");
|
||||
|
||||
-- Seed starter posts
|
||||
INSERT INTO "BlogPost" ("id", "title", "slug", "excerpt", "coverImageUrl", "content", "isPublished", "views", "publishedAt", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(
|
||||
'blog_bootstrapping_luna',
|
||||
'Bootstrapping Luna on Home Infra',
|
||||
'bootstrapping-luna-on-home-infra',
|
||||
'How I wired a local-first assistant onto my own infrastructure without making the whole stack brittle.',
|
||||
NULL,
|
||||
$$# Bootstrapping Luna on Home Infra
|
||||
|
||||
Running an assistant from your own box sounds fun until the first reboot eats half the state and the other half lives in five unrelated scripts.
|
||||
|
||||
## The actual goal
|
||||
|
||||
I wanted a setup that stayed **local-first**, reacted quickly, and did not fall apart the second a service restarted. That meant a few rules:
|
||||
|
||||
- keep the state in places I control
|
||||
- make tools callable without browser hacks
|
||||
- prefer plain files and tiny APIs over magic
|
||||
|
||||
## What worked
|
||||
|
||||
The best decision was treating memory as part of the product instead of an afterthought. Notes, small docs, and project context live close to the runtime, so the assistant does not have to guess everything from chat history.
|
||||
|
||||
## What still sucks
|
||||
|
||||
The annoying bits are always the same: auth edges, stale sessions, and making sure background jobs stay visible instead of silently drifting.
|
||||
|
||||
## The payoff
|
||||
|
||||
Once the boring plumbing was stable, the fun part happened: the assistant could actually help with real work instead of just answering one-off questions.
|
||||
$$,
|
||||
true,
|
||||
182,
|
||||
TIMESTAMP '2026-07-12 18:30:00',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
),
|
||||
(
|
||||
'blog_prisma_content_systems',
|
||||
'What Prisma Gets Right for Small Content Systems',
|
||||
'what-prisma-gets-right-for-small-content-systems',
|
||||
'Why a tiny Prisma-backed CMS is often cleaner than bolting markdown files onto a growing product.',
|
||||
NULL,
|
||||
$$# What Prisma Gets Right for Small Content Systems
|
||||
|
||||
When a site starts growing beyond a landing page, content sneaks in everywhere. Suddenly there are projects, changelogs, notes, announcements, and blog posts.
|
||||
|
||||
## Why I keep reaching for Prisma
|
||||
|
||||
Prisma gives me a couple things I care about:
|
||||
|
||||
1. typed data access
|
||||
2. migrations I can inspect
|
||||
3. one place to validate how content actually looks
|
||||
|
||||
That matters because the problem is rarely “where do I put the text?” The problem is keeping **editing**, **rendering**, and **publishing** from drifting apart.
|
||||
|
||||
## Markdown still wins
|
||||
|
||||
I still like markdown for the authoring format. It is fast, portable, and not weird to diff. The database just becomes the durable source of truth around that markdown.
|
||||
|
||||
## The tradeoff
|
||||
|
||||
You do lose some git-native niceness compared to file-based content. But once non-technical editing or richer admin flows matter, the database route gets way less annoying.
|
||||
$$,
|
||||
true,
|
||||
141,
|
||||
TIMESTAMP '2026-07-09 16:45:00',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
),
|
||||
(
|
||||
'blog_shipping_small_tools',
|
||||
'Shipping Small Tools Without Making a Mess',
|
||||
'shipping-small-tools-without-making-a-mess',
|
||||
'A few rules I use to keep side tools useful, maintainable, and not instantly cursed.',
|
||||
NULL,
|
||||
$$# Shipping Small Tools Without Making a Mess
|
||||
|
||||
Tiny tools are dangerous because they feel too small to design properly. That is how you end up with “temporary” scripts haunting production six months later.
|
||||
|
||||
## My rule set
|
||||
|
||||
- name things like they will still exist next month
|
||||
- write down the weird assumptions
|
||||
- keep input and output dead simple
|
||||
- make deletion easier than expansion
|
||||
|
||||
## The hidden trick
|
||||
|
||||
A good small tool usually does **less** than you first wanted. The moment a script starts collecting flags, modes, and special cases, it is begging to become a real service.
|
||||
|
||||
## Final thought
|
||||
|
||||
Shipping fast is good. Shipping something you can still understand later is better.
|
||||
$$,
|
||||
true,
|
||||
96,
|
||||
TIMESTAMP '2026-07-05 10:20:00',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -64,3 +64,17 @@ model Affiliate {
|
||||
createdAt DateTime @default(now())
|
||||
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
@@ -4,13 +4,15 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 }[] = [
|
||||
{ key: "projects", label: "Projects" },
|
||||
{ key: "experience", label: "Work Experience" },
|
||||
{ key: "affiliates", label: "Affiliates" },
|
||||
{ key: "blogs", label: "Blogs" },
|
||||
];
|
||||
|
||||
// A record being edited; `_new` marks an unsaved draft (POST vs PUT).
|
||||
@@ -56,6 +58,17 @@ const TEMPLATES: Record<Resource, () => Row> = {
|
||||
bad: [],
|
||||
sortIndex: 0,
|
||||
}),
|
||||
blogs: () => ({
|
||||
_new: true,
|
||||
title: "",
|
||||
slug: "",
|
||||
excerpt: "",
|
||||
coverImageUrl: "",
|
||||
content: "# New post\n\nStart writing here.",
|
||||
isPublished: true,
|
||||
publishedAt: "",
|
||||
views: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
@@ -69,6 +82,13 @@ function toDateInput(value: unknown): string {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -137,6 +157,8 @@ export default function AdminPage() {
|
||||
? json.map((item: Row) =>
|
||||
res === "experience"
|
||||
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
|
||||
: res === "blogs"
|
||||
? { ...item, publishedAt: toDateTimeInput(item.publishedAt) }
|
||||
: item,
|
||||
)
|
||||
: [];
|
||||
@@ -169,7 +191,8 @@ export default function AdminPage() {
|
||||
setSavingId(row.id || `new-${idx}`);
|
||||
try {
|
||||
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, {
|
||||
method: isNew ? "POST" : "PUT",
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-white">PORTFOLIO CMS</h1>
|
||||
<p className="text-gray-500 text-sm">Manage your projects, experience & affiliates</p>
|
||||
<p className="text-gray-500 text-sm">Manage your projects, experience, affiliates & blog posts</p>
|
||||
</div>
|
||||
<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">
|
||||
@@ -330,6 +353,7 @@ export default function AdminPage() {
|
||||
{resource === "projects" && <ProjectFields 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 === "blogs" && <BlogFields row={row} idx={idx} update={update} />}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -541,3 +565,92 @@ function AffiliateFields({ row, idx, update }: FieldProps) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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">
|
||||
Build notes, infra thoughts, and the occasional strongly held opinion.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-gray-400 sm:text-base">
|
||||
Posts live in the same database-backed admin system as the rest of the site, with markdown
|
||||
authoring and public view tracking.
|
||||
</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
@@ -3,7 +3,7 @@
|
||||
import { useProfile } from "../context/ProfileContext";
|
||||
import { Navbar } from "../components/Navbar";
|
||||
import { Hero } from "../sections/Hero";
|
||||
import { Stats } from "../sections/Stats";
|
||||
import { TopBlogs } from "../sections/TopBlogs";
|
||||
import { WorkExperience } from "../sections/WorkExperience";
|
||||
import { FeaturedProjects } from "../sections/FeaturedProjects";
|
||||
import { Luna } from "../sections/Luna";
|
||||
@@ -19,7 +19,7 @@ export default function Home() {
|
||||
<Navbar />
|
||||
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
|
||||
<Hero />
|
||||
<Stats />
|
||||
<TopBlogs />
|
||||
<WorkExperience experiences={experiences} />
|
||||
<FeaturedProjects />
|
||||
<Luna />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
),
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export function Navbar() {
|
||||
const navItems = [
|
||||
{ label: "Home", path: "/" },
|
||||
{ label: "Projects", path: "/projects" },
|
||||
{ label: "Blog", path: "/blogs" },
|
||||
{ label: "Connect", path: "/contact" },
|
||||
];
|
||||
|
||||
@@ -40,4 +41,3 @@ export function Navbar() {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
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. */
|
||||
export async function guard(): Promise<NextResponse | null> {
|
||||
@@ -18,6 +19,9 @@ export async function run(handler: () => Promise<NextResponse>): Promise<NextRes
|
||||
if (err instanceof ValidationError) {
|
||||
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);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -111,3 +111,31 @@ export function parseAffiliate(body: Record<string, unknown>) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Activity() {
|
||||
<div className="text-center space-y-4">
|
||||
<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">
|
||||
Today, i've already been coding for
|
||||
Today, i've already been coding for
|
||||
<span className="font-mono text-base text-green-400 ml-1">
|
||||
{dailyActivity || "..."}
|
||||
</span>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
most read
|
||||
</p>
|
||||
<h2 className="text-3xl font-semibold text-white sm:text-4xl">
|
||||
top blog posts by views
|
||||
</h2>
|
||||
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
|
||||
The three posts people keep opening first.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,24 @@ export interface Affiliate {
|
||||
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 SIZE_LABELS: Record<ProjectSize, string> = {
|
||||
|
||||
Reference in New Issue
Block a user