Compare commits

..

11 Commits

Author SHA1 Message Date
space 6552273ec0 Merge pull request 'Fix blog cover images on blog cards' (#3) from fix/blog-card-cover-images into main
Build Check / build (push) Successful in 29s
Build Check / push-image (push) Successful in 56s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #3
2026-07-18 01:26:24 +02:00
luna c0c3658628 Fix blog card cover images
Build Check / build (pull_request) Successful in 29s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 23:19:35 +00:00
Space-Banane 589656e09b Update Dockerfile to simplify server startup command
Build Check / build (push) Successful in 46s
Build Check / push-image (push) Successful in 2m10s
Build Check / deploy-coolify (push) Successful in 7s
2026-07-18 01:16:48 +02:00
Space-Banane 7074f8d1d6 Update Dockerfile to use npx for Prisma migration command
Build Check / build (push) Successful in 36s
Build Check / push-image (push) Successful in 1m10s
Build Check / deploy-coolify (push) Successful in 8s
2026-07-18 01:13:03 +02:00
Space-Banane 7e38f89e37 Update Dockerfile to run database migrations before starting the server
Build Check / build (push) Successful in 33s
Build Check / push-image (push) Successful in 55s
Build Check / deploy-coolify (push) Successful in 7s
2026-07-18 01:08:07 +02:00
space 3a4dc01b90 Merge pull request 'Add blog authors support' (#2) from feat/blog-authors into main
Build Check / build (push) Successful in 29s
Build Check / push-image (push) Successful in 57s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #2
2026-07-18 01:02:46 +02:00
luna 6df4983f74 Add blog authors support
Build Check / build (pull_request) Successful in 30s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:59:28 +00:00
space 7ae6aba990 Merge pull request 'Add blog CMS with view tracking' (#1) from feat/blog-cms into main
Build Check / build (push) Successful in 32s
Build Check / push-image (push) Successful in 58s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #1
2026-07-18 00:28:24 +02:00
luna ffb57a9f34 Polish blog landing copy
Build Check / build (pull_request) Successful in 32s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:17:53 +00:00
luna 76b1c79104 Refine blog copy and remove seeded posts
Build Check / build (pull_request) Successful in 32s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:14:59 +00:00
luna 912e5edb81 Add blog CMS with tracked public posts
Build Check / build (pull_request) Successful in 34s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:02:25 +00:00
27 changed files with 2006 additions and 38 deletions
+1 -1
View File
@@ -30,4 +30,4 @@ COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
CMD ["node", "server.js"]
+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
- 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.
+2
View File
@@ -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": {
+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");
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "BlogAuthor" (
"id" TEXT NOT NULL,
"blogPostId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"website" TEXT,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlogAuthor_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BlogAuthor_blogPostId_sortOrder_idx" ON "BlogAuthor"("blogPostId", "sortOrder");
-- AddForeignKey
ALTER TABLE "BlogAuthor" ADD CONSTRAINT "BlogAuthor_blogPostId_fkey" FOREIGN KEY ("blogPostId") REFERENCES "BlogPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+30
View File
@@ -64,3 +64,33 @@ 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
authors BlogAuthor[]
}
model BlogAuthor {
id String @id @default(cuid())
blogPostId String
blogPost BlogPost @relation(fields: [blogPostId], references: [id], onDelete: Cascade)
sortOrder Int @default(0)
type String
name String
website String?
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([blogPostId, sortOrder])
}
+262 -4
View File
@@ -3,18 +3,32 @@
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 {
BLOG_AUTHOR_TYPES,
PROJECT_SIZES,
SIZE_LABELS,
type BlogAuthorType,
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).
type Row = Record<string, unknown> & { id?: string; _new?: boolean };
type BlogAuthorInput = {
type: BlogAuthorType;
name: string;
website: string;
imageUrl: string;
};
const TEMPLATES: Record<Resource, () => Row> = {
projects: () => ({
@@ -56,6 +70,18 @@ const TEMPLATES: Record<Resource, () => Row> = {
bad: [],
sortIndex: 0,
}),
blogs: () => ({
_new: true,
title: "",
slug: "",
excerpt: "",
coverImageUrl: "",
content: "# New post\n\nStart writing here.",
authors: [],
isPublished: true,
publishedAt: "",
views: 0,
}),
};
const inputCls =
@@ -69,6 +95,31 @@ 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);
}
function normalizeBlogAuthors(value: unknown): BlogAuthorInput[] {
if (!Array.isArray(value)) return [];
return value.map((author) => {
const item = author as Record<string, unknown>;
const type = BLOG_AUTHOR_TYPES.includes(item.type as BlogAuthorType)
? (item.type as BlogAuthorType)
: "author";
return {
type,
name: typeof item.name === "string" ? item.name : "",
website: typeof item.website === "string" ? item.website : "",
imageUrl: typeof item.imageUrl === "string" ? item.imageUrl : "",
};
});
}
export default function AdminPage() {
const router = useRouter();
@@ -137,6 +188,12 @@ 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),
authors: normalizeBlogAuthors(item.authors),
}
: item,
)
: [];
@@ -169,7 +226,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 +315,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 &amp; affiliates</p>
<p className="text-gray-500 text-sm">Manage your projects, experience, affiliates &amp; 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 +388,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 +600,202 @@ function AffiliateFields({ row, idx, update }: FieldProps) {
</div>
);
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function emptyAuthor(): BlogAuthorInput {
return {
type: "author",
name: "",
website: "",
imageUrl: "",
};
}
function BlogFields({ row, idx, update }: FieldProps) {
const slug = typeof row.slug === "string" ? row.slug : "";
const content = typeof row.content === "string" ? row.content : "";
const authors = normalizeBlogAuthors(row.authors);
const updateAuthors = (nextAuthors: BlogAuthorInput[]) => {
update(idx, "authors", nextAuthors);
};
const updateAuthor = (
authorIndex: number,
field: keyof BlogAuthorInput,
value: BlogAuthorInput[keyof BlogAuthorInput],
) => {
updateAuthors(authors.map((author, index) => (index === authorIndex ? { ...author, [field]: value } : author)));
};
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="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">Authors</p>
<p className="text-sm text-gray-400">Up to 5 authors shown on the cards and blog page.</p>
</div>
<button
type="button"
onClick={() => updateAuthors([...authors, emptyAuthor()])}
disabled={authors.length >= 5}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
Add author
</button>
</div>
{authors.length === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-4 py-5 text-sm text-gray-500">
No authors yet.
</div>
) : (
<div className="space-y-3">
{authors.map((author, authorIndex) => (
<div key={authorIndex} className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">
Author {authorIndex + 1}
</p>
<button
type="button"
onClick={() => updateAuthors(authors.filter((_, index) => index !== authorIndex))}
className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs font-bold text-red-200 hover:bg-red-500/20"
>
Remove
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<label className={labelCls}>Type</label>
<select
value={author.type}
onChange={(e) => updateAuthor(authorIndex, "type", e.target.value as BlogAuthorType)}
className={inputCls}
>
{BLOG_AUTHOR_TYPES.map((option) => (
<option key={option} value={option} className="bg-[#0a0a0a]">
{option}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className={labelCls}>Name</label>
<input
type="text"
value={author.name}
onChange={(e) => updateAuthor(authorIndex, "name", e.target.value)}
className={inputCls}
placeholder="Luna"
/>
</div>
<div className="space-y-1">
<label className={labelCls}>Website</label>
<input
type="text"
value={author.website}
onChange={(e) => updateAuthor(authorIndex, "website", e.target.value)}
className={inputCls}
placeholder="https://example.com"
/>
</div>
<div className="space-y-1">
<label className={labelCls}>Image URL</label>
<input
type="text"
value={author.imageUrl}
onChange={(e) => updateAuthor(authorIndex, "imageUrl", e.target.value)}
className={inputCls}
placeholder="https://example.com/avatar.jpg"
/>
</div>
</div>
</div>
))}
</div>
)}
</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>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
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: {
title: data.title,
slug: data.slug,
excerpt: data.excerpt,
coverImageUrl: data.coverImageUrl,
content: data.content,
isPublished: data.isPublished,
publishedAt: data.publishedAt,
authors: {
deleteMany: {},
create: data.authors,
},
},
include: blogPostInclude,
});
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 });
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
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: {
title: data.title,
slug: data.slug,
excerpt: data.excerpt,
coverImageUrl: data.coverImageUrl,
content: data.content,
isPublished: data.isPublished,
publishedAt: data.publishedAt,
authors: {
create: data.authors,
},
},
include: blogPostInclude,
});
return NextResponse.json(created, { status: 201 });
});
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, 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 } },
include: blogPostInclude,
});
});
if (!post) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(toBlogPost(post));
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
return NextResponse.json(posts.map(toBlogSummary));
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ views: "desc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
take: 3,
});
return NextResponse.json(posts.map(toBlogSummary));
}
+97
View File
@@ -0,0 +1,97 @@
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 { BlogAuthorList } from "@/components/BlogAuthorList";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, 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 } },
include: blogPostInclude,
});
});
}
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>
{post.authors.length > 0 ? <BlogAuthorList authors={post.authors} /> : null}
</div>
<div className="pt-8">
<MarkdownContent content={post.content} />
</div>
</article>
</div>
<Footer />
</>
);
}
+66
View File
@@ -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 { blogPostInclude, 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({
include: blogPostInclude,
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 { 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 />
+102
View File
@@ -0,0 +1,102 @@
type DisplayAuthor = {
id: string;
type: string;
name: string;
website?: string | null;
imageUrl?: string | null;
};
const ROLE_LABELS: Record<string, string> = {
author: "Author",
"co-author": "Co-author",
relating: "Relating",
};
function avatarFallback(name: string) {
return name
.split(/\s+/)
.map((part) => part[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
}
function AuthorAvatar({ author }: { author: DisplayAuthor }) {
if (author.imageUrl) {
return (
<img
src={author.imageUrl}
alt={author.name}
className="h-10 w-10 rounded-full border border-white/10 object-cover"
/>
);
}
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-xs font-semibold text-gray-300">
{avatarFallback(author.name)}
</div>
);
}
export function BlogAuthorList({ authors }: { authors: DisplayAuthor[] }) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{authors.map((author) => {
const content = (
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<AuthorAvatar author={author} />
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-500">
{ROLE_LABELS[author.type] ?? author.type}
</p>
<p className="truncate text-sm font-medium text-white">{author.name}</p>
</div>
</div>
);
if (!author.website) {
return <div key={author.id}>{content}</div>;
}
return (
<a
key={author.id}
href={author.website}
target="_blank"
rel="noreferrer"
className="transition-transform duration-200 hover:-translate-y-0.5"
>
{content}
</a>
);
})}
</div>
);
}
export function BlogAuthorChips({ authors }: { authors: DisplayAuthor[] }) {
return (
<div className="flex flex-wrap gap-2">
{authors.map((author) => (
<div
key={author.id}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5"
>
{author.imageUrl ? (
<img
src={author.imageUrl}
alt={author.name}
className="h-5 w-5 rounded-full border border-white/10 object-cover"
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full border border-white/10 bg-white/5 text-[10px] font-semibold text-gray-300">
{avatarFallback(author.name)}
</div>
)}
<span className="text-xs font-medium text-gray-200">{author.name}</span>
</div>
))}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import Link from "next/link";
import type { BlogPostSummary } from "@/types";
import { BlogAuthorChips } from "@/components/BlogAuthorList";
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 overflow-hidden 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]"
>
{blog.coverImageUrl ? (
<div className="mb-5 overflow-hidden rounded-[1.25rem] border border-white/10 bg-black/30">
{/* External CMS image URLs are stored directly, so a plain img is the least brittle option here. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={blog.coverImageUrl}
alt={blog.title}
className="h-48 w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
) : null}
<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>
{blog.authors.length > 0 ? (
<div className="mt-5">
<BlogAuthorChips authors={blog.authors} />
</div>
) : null}
<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 = [
{ label: "Home", path: "/" },
{ label: "Projects", path: "/projects" },
{ label: "Blog", path: "/blogs" },
{ label: "Connect", path: "/contact" },
];
@@ -40,4 +41,3 @@ export function Navbar() {
</nav>
);
}
+4
View File
@@ -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 });
}
+63
View File
@@ -0,0 +1,63 @@
import type {
BlogAuthor as PrismaBlogAuthor,
BlogPost as PrismaBlogPost,
Prisma,
} from "@/generated/prisma/client";
import type { BlogAuthor, BlogPost, BlogPostSummary } from "@/types";
export const blogPostInclude = {
authors: {
orderBy: {
sortOrder: "asc",
},
},
} satisfies Prisma.BlogPostInclude;
type PrismaBlogPostWithAuthors = PrismaBlogPost & {
authors: PrismaBlogAuthor[];
};
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));
}
function toBlogAuthor(author: PrismaBlogAuthor): BlogAuthor {
return {
id: author.id,
type: author.type as BlogAuthor["type"],
name: author.name,
website: author.website,
imageUrl: author.imageUrl,
};
}
export function toBlogSummary(post: PrismaBlogPostWithAuthors): BlogPostSummary {
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
coverImageUrl: post.coverImageUrl,
authors: post.authors.map(toBlogAuthor),
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: PrismaBlogPostWithAuthors): BlogPost {
return {
...toBlogSummary(post),
content: post.content,
};
}
+71
View File
@@ -2,6 +2,7 @@
// Prisma-ready data object or throws a ValidationError (mapped to HTTP 400).
import { ProjectSize } from "@/generated/prisma/client";
import { BLOG_AUTHOR_TYPES, type BlogAuthorType } from "@/types";
export class ValidationError extends Error {}
@@ -62,6 +63,47 @@ function optDate(value: unknown): Date | null {
return Number.isNaN(d.getTime()) ? null : d;
}
function parseBlogAuthorType(value: unknown, field: string): BlogAuthorType {
if (typeof value !== "string" || !BLOG_AUTHOR_TYPES.includes(value as BlogAuthorType)) {
throw new ValidationError(`"${field}" must be one of: ${BLOG_AUTHOR_TYPES.join(", ")}`);
}
return value as BlogAuthorType;
}
function parseBlogAuthors(value: unknown) {
if (!Array.isArray(value)) return [];
const authors = value
.map((entry, index) => {
if (!entry || typeof entry !== "object") return null;
const body = entry as Record<string, unknown>;
const name = optStr(body.name);
const website = optStr(body.website);
const imageUrl = optStr(body.imageUrl);
const hasValues = Boolean(name || website || imageUrl || body.type);
if (!hasValues) return null;
if (!name) throw new ValidationError(`"authors[${index}].name" is required`);
return {
type: parseBlogAuthorType(body.type ?? "author", `authors[${index}].type`),
name,
website,
imageUrl,
sortOrder: index,
};
})
.filter((author): author is NonNullable<typeof author> => author !== null);
if (authors.length > 5) {
throw new ValidationError("Too many authors (max 5)");
}
return authors;
}
export function parseProject(body: Record<string, unknown>) {
const size = String(body.size);
const validSize = (Object.values(ProjectSize) as string[]).includes(size)
@@ -111,3 +153,32 @@ 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"),
authors: parseBlogAuthors(body.authors),
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">
<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&apos;ve already been coding for
<span className="font-mono text-base text-green-400 ml-1">
{dailyActivity || "..."}
</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>
);
}
+31
View File
@@ -47,6 +47,37 @@ export interface Affiliate {
sortIndex: number;
}
export const BLOG_AUTHOR_TYPES = ["author", "co-author", "relating"] as const;
export type BlogAuthorType = (typeof BLOG_AUTHOR_TYPES)[number];
export interface BlogAuthor {
id: string;
type: BlogAuthorType;
name: string;
website?: string | null;
imageUrl?: string | null;
}
export interface BlogPostSummary {
id: string;
title: string;
slug: string;
excerpt: string;
coverImageUrl?: string | null;
authors: BlogAuthor[];
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> = {