From 6df4983f748e73bdac227319c75e3d41d0539525 Mon Sep 17 00:00:00 2001 From: luna Date: Fri, 17 Jul 2026 22:59:28 +0000 Subject: [PATCH] Add blog authors support --- .../migration.sql | 20 +++ prisma/schema.prisma | 16 ++ src/app/admin/page.tsx | 149 +++++++++++++++++- src/app/api/admin/blogs/[id]/route.ts | 19 ++- src/app/api/admin/blogs/route.ts | 18 ++- src/app/api/blogs/[slug]/route.ts | 3 +- src/app/api/blogs/route.ts | 3 +- src/app/api/blogs/top/route.ts | 3 +- src/app/blogs/[slug]/page.tsx | 5 +- src/app/blogs/page.tsx | 3 +- src/components/BlogAuthorList.tsx | 102 ++++++++++++ src/components/BlogCard.tsx | 7 + src/lib/blogs.ts | 35 +++- src/lib/dto.ts | 43 +++++ src/types.ts | 13 ++ 15 files changed, 426 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260717225516_add_blog_authors/migration.sql create mode 100644 src/components/BlogAuthorList.tsx diff --git a/prisma/migrations/20260717225516_add_blog_authors/migration.sql b/prisma/migrations/20260717225516_add_blog_authors/migration.sql new file mode 100644 index 0000000..6404ca2 --- /dev/null +++ b/prisma/migrations/20260717225516_add_blog_authors/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aafd27e..c5556c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index cc06118..433fbf6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 & { id?: string; _new?: boolean }; +type BlogAuthorInput = { + type: BlogAuthorType; + name: string; + website: string; + imageUrl: string; +}; const TEMPLATES: Record Row> = { projects: () => ({ @@ -65,6 +77,7 @@ const TEMPLATES: Record 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; + 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 (
@@ -614,6 +671,94 @@ function BlogFields({ row, idx, update }: FieldProps) {
+
+
+
+

Authors

+

Up to 5 authors shown on the cards and blog page.

+
+ +
+ + {authors.length === 0 ? ( +
+ No authors yet. +
+ ) : ( +
+ {authors.map((author, authorIndex) => ( +
+
+

+ Author {authorIndex + 1} +

+ +
+
+
+ + +
+
+ + updateAuthor(authorIndex, "name", e.target.value)} + className={inputCls} + placeholder="Luna" + /> +
+
+ + updateAuthor(authorIndex, "website", e.target.value)} + className={inputCls} + placeholder="https://example.com" + /> +
+
+ + updateAuthor(authorIndex, "imageUrl", e.target.value)} + className={inputCls} + placeholder="https://example.com/avatar.jpg" + /> +
+
+
+ ))} +
+ )} +
+
diff --git a/src/app/api/admin/blogs/[id]/route.ts b/src/app/api/admin/blogs/[id]/route.ts index cd27ef2..d7a66e7 100644 --- a/src/app/api/admin/blogs/[id]/route.ts +++ b/src/app/api/admin/blogs/[id]/route.ts @@ -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); }); } diff --git a/src/app/api/admin/blogs/route.ts b/src/app/api/admin/blogs/route.ts index 9099f6e..cec3576 100644 --- a/src/app/api/admin/blogs/route.ts +++ b/src/app/api/admin/blogs/route.ts @@ -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 }); }); } diff --git a/src/app/api/blogs/[slug]/route.ts b/src/app/api/blogs/[slug]/route.ts index 0737acd..3f7d4df 100644 --- a/src/app/api/blogs/[slug]/route.ts +++ b/src/app/api/blogs/[slug]/route.ts @@ -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, }); }); diff --git a/src/app/api/blogs/route.ts b/src/app/api/blogs/route.ts index 8905481..e2837f3 100644 --- a/src/app/api/blogs/route.ts +++ b/src/app/api/blogs/route.ts @@ -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" }], }); diff --git a/src/app/api/blogs/top/route.ts b/src/app/api/blogs/top/route.ts index e0f3df3..3889936 100644 --- a/src/app/api/blogs/top/route.ts +++ b/src/app/api/blogs/top/route.ts @@ -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, diff --git a/src/app/blogs/[slug]/page.tsx b/src/app/blogs/[slug]/page.tsx index 83f606e..b0a6639 100644 --- a/src/app/blogs/[slug]/page.tsx +++ b/src/app/blogs/[slug]/page.tsx @@ -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) { {post.views} views {getReadingTimeMinutes(post.content)} min read
+ {post.authors.length > 0 ? : null}
diff --git a/src/app/blogs/page.tsx b/src/app/blogs/page.tsx index 12d15c1..96a0281 100644 --- a/src/app/blogs/page.tsx +++ b/src/app/blogs/page.tsx @@ -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" }], }); diff --git a/src/components/BlogAuthorList.tsx b/src/components/BlogAuthorList.tsx new file mode 100644 index 0000000..b985df0 --- /dev/null +++ b/src/components/BlogAuthorList.tsx @@ -0,0 +1,102 @@ +type DisplayAuthor = { + id: string; + type: string; + name: string; + website?: string | null; + imageUrl?: string | null; +}; + +const ROLE_LABELS: Record = { + 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 ( + {author.name} + ); + } + + return ( +
+ {avatarFallback(author.name)} +
+ ); +} + +export function BlogAuthorList({ authors }: { authors: DisplayAuthor[] }) { + return ( +
+ {authors.map((author) => { + const content = ( +
+ +
+

+ {ROLE_LABELS[author.type] ?? author.type} +

+

{author.name}

+
+
+ ); + + if (!author.website) { + return
{content}
; + } + + return ( + + {content} + + ); + })} +
+ ); +} + +export function BlogAuthorChips({ authors }: { authors: DisplayAuthor[] }) { + return ( +
+ {authors.map((author) => ( +
+ {author.imageUrl ? ( + {author.name} + ) : ( +
+ {avatarFallback(author.name)} +
+ )} + {author.name} +
+ ))} +
+ ); +} diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index 6c3e7fa..9feafeb 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -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

{blog.excerpt}

+ {blog.authors.length > 0 ? ( +
+ +
+ ) : null} +
{formatDate(blog.publishedAt)} {blog.readingTimeMinutes} min read diff --git a/src/lib/blogs.ts b/src/lib/blogs.ts index fa19b26..5358972 100644 --- a/src/lib/blogs.ts +++ b/src/lib/blogs.ts @@ -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, diff --git a/src/lib/dto.ts b/src/lib/dto.ts index d8cb67d..cd7cfea 100644 --- a/src/lib/dto.ts +++ b/src/lib/dto.ts @@ -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; + 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 => author !== null); + + if (authors.length > 5) { + throw new ValidationError("Too many authors (max 5)"); + } + + return authors; +} + export function parseProject(body: Record) { const size = String(body.size); const validSize = (Object.values(ProjectSize) as string[]).includes(size) @@ -135,6 +177,7 @@ export function parseBlogPost(body: Record) { 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, }; diff --git a/src/types.ts b/src/types.ts index d5615f7..5415ce0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; -- 2.39.5