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

This commit is contained in:
2026-07-17 22:59:28 +00:00
parent 7ae6aba990
commit 6df4983f74
15 changed files with 426 additions and 13 deletions
@@ -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;
+16
View File
@@ -77,4 +77,20 @@ model BlogPost {
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])
}
+147 -2
View File
@@ -3,7 +3,13 @@
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" | "blogs";
@@ -17,6 +23,12 @@ const RESOURCES: { key: Resource; label: string }[] = [
// 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: () => ({
@@ -65,6 +77,7 @@ const TEMPLATES: Record<Resource, () => Row> = {
excerpt: "",
coverImageUrl: "",
content: "# New post\n\nStart writing here.",
authors: [],
isPublished: true,
publishedAt: "",
views: 0,
@@ -89,6 +102,24 @@ function toDateTimeInput(value: unknown): string {
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();
@@ -158,7 +189,11 @@ export default function AdminPage() {
res === "experience"
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
: res === "blogs"
? { ...item, publishedAt: toDateTimeInput(item.publishedAt) }
? {
...item,
publishedAt: toDateTimeInput(item.publishedAt),
authors: normalizeBlogAuthors(item.authors),
}
: item,
)
: [];
@@ -573,9 +608,31 @@ function slugify(value: string): string {
.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">
@@ -614,6 +671,94 @@ function BlogFields({ row, idx, update }: FieldProps) {
<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>
+18 -1
View File
@@ -2,6 +2,7 @@ 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 }> };
@@ -12,7 +13,23 @@ export async function PUT(req: Request, { params }: Params) {
return run(async () => {
const { id } = await params;
const data = parseBlogPost(await readBody(req));
const updated = await prisma.blogPost.update({ where: { id }, data });
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);
});
}
+17 -1
View File
@@ -2,6 +2,7 @@ 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();
@@ -9,6 +10,7 @@ export async function GET() {
return run(async () => {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
orderBy: [{ updatedAt: "desc" }],
});
return NextResponse.json(posts);
@@ -21,7 +23,21 @@ export async function POST(req: Request) {
return run(async () => {
const data = parseBlogPost(await readBody(req));
const created = await prisma.blogPost.create({ data });
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 });
});
}
+2 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogPost } from "@/lib/blogs";
import { blogPostInclude, toBlogPost } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
@@ -21,6 +21,7 @@ export async function GET(_req: Request, { params }: Params) {
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
include: blogPostInclude,
});
});
+2 -1
View File
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
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" }],
});
+2 -1
View File
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
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,
+4 -1
View File
@@ -3,8 +3,9 @@ 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 { getReadingTimeMinutes } from "@/lib/blogs";
import { blogPostInclude, getReadingTimeMinutes } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
@@ -31,6 +32,7 @@ async function getPost(slug: string) {
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
include: blogPostInclude,
});
});
}
@@ -81,6 +83,7 @@ export default async function BlogDetailPage({ params }: Params) {
<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">
+2 -1
View File
@@ -3,7 +3,7 @@ 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";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const metadata = {
title: "Blog | Paul W. Portfolio",
@@ -14,6 +14,7 @@ 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" }],
});
+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>
);
}
+7
View File
@@ -1,5 +1,6 @@
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";
@@ -35,6 +36,12 @@ export function BlogCard({ blog, index }: { blog: BlogPostSummary; index?: numbe
<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>
+31 -4
View File
@@ -1,5 +1,21 @@
import type { BlogPost as PrismaBlogPost } from "@/generated/prisma/client";
import type { BlogPost, BlogPostSummary } from "@/types";
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
@@ -12,13 +28,24 @@ export function getReadingTimeMinutes(content: string): number {
return Math.max(1, Math.ceil(countWords(content) / 220));
}
export function toBlogSummary(post: PrismaBlogPost): BlogPostSummary {
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),
@@ -28,7 +55,7 @@ export function toBlogSummary(post: PrismaBlogPost): BlogPostSummary {
};
}
export function toBlogPost(post: PrismaBlogPost): BlogPost {
export function toBlogPost(post: PrismaBlogPostWithAuthors): BlogPost {
return {
...toBlogSummary(post),
content: post.content,
+43
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)
@@ -135,6 +177,7 @@ export function parseBlogPost(body: Record<string, unknown>) {
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,
};
+13
View File
@@ -47,12 +47,25 @@ 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;