Add blog authors support #2

Merged
space merged 1 commits from feat/blog-authors into main 2026-07-18 01:02:46 +02:00
15 changed files with 426 additions and 13 deletions
Showing only changes of commit 6df4983f74 - Show all commits
@@ -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? publishedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PROJECT_SIZES, SIZE_LABELS, type ProjectSize } from "../../types"; import {
BLOG_AUTHOR_TYPES,
PROJECT_SIZES,
SIZE_LABELS,
type BlogAuthorType,
type ProjectSize,
} from "../../types";
import { MarkdownContent } from "@/components/MarkdownContent"; import { MarkdownContent } from "@/components/MarkdownContent";
type Resource = "projects" | "experience" | "affiliates" | "blogs"; 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). // A record being edited; `_new` marks an unsaved draft (POST vs PUT).
type Row = Record<string, unknown> & { id?: string; _new?: boolean }; type Row = Record<string, unknown> & { id?: string; _new?: boolean };
type BlogAuthorInput = {
type: BlogAuthorType;
name: string;
website: string;
imageUrl: string;
};
const TEMPLATES: Record<Resource, () => Row> = { const TEMPLATES: Record<Resource, () => Row> = {
projects: () => ({ projects: () => ({
@@ -65,6 +77,7 @@ const TEMPLATES: Record<Resource, () => Row> = {
excerpt: "", excerpt: "",
coverImageUrl: "", coverImageUrl: "",
content: "# New post\n\nStart writing here.", content: "# New post\n\nStart writing here.",
authors: [],
isPublished: true, isPublished: true,
publishedAt: "", publishedAt: "",
views: 0, views: 0,
@@ -89,6 +102,24 @@ function toDateTimeInput(value: unknown): string {
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); 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() { export default function AdminPage() {
const router = useRouter(); const router = useRouter();
@@ -158,7 +189,11 @@ export default function AdminPage() {
res === "experience" res === "experience"
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) } ? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
: res === "blogs" : res === "blogs"
? { ...item, publishedAt: toDateTimeInput(item.publishedAt) } ? {
...item,
publishedAt: toDateTimeInput(item.publishedAt),
authors: normalizeBlogAuthors(item.authors),
}
: item, : item,
) )
: []; : [];
@@ -573,9 +608,31 @@ function slugify(value: string): string {
.replace(/^-+|-+$/g, ""); .replace(/^-+|-+$/g, "");
} }
function emptyAuthor(): BlogAuthorInput {
return {
type: "author",
name: "",
website: "",
imageUrl: "",
};
}
function BlogFields({ row, idx, update }: FieldProps) { function BlogFields({ row, idx, update }: FieldProps) {
const slug = typeof row.slug === "string" ? row.slug : ""; const slug = typeof row.slug === "string" ? row.slug : "";
const content = typeof row.content === "string" ? row.content : ""; 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 ( return (
<div className="space-y-5"> <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" /> <Toggle row={row} idx={idx} update={update} field="isPublished" label="Published" />
</div> </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="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
+18 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto"; import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute"; import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
type Params = { params: Promise<{ id: string }> }; type Params = { params: Promise<{ id: string }> };
@@ -12,7 +13,23 @@ export async function PUT(req: Request, { params }: Params) {
return run(async () => { return run(async () => {
const { id } = await params; const { id } = await params;
const data = parseBlogPost(await readBody(req)); 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); return NextResponse.json(updated);
}); });
} }
+17 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto"; import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute"; import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
export async function GET() { export async function GET() {
const denied = await guard(); const denied = await guard();
@@ -9,6 +10,7 @@ export async function GET() {
return run(async () => { return run(async () => {
const posts = await prisma.blogPost.findMany({ const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
orderBy: [{ updatedAt: "desc" }], orderBy: [{ updatedAt: "desc" }],
}); });
return NextResponse.json(posts); return NextResponse.json(posts);
@@ -21,7 +23,21 @@ export async function POST(req: Request) {
return run(async () => { return run(async () => {
const data = parseBlogPost(await readBody(req)); 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 }); return NextResponse.json(created, { status: 201 });
}); });
} }
+2 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { toBlogPost } from "@/lib/blogs"; import { blogPostInclude, toBlogPost } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> }; type Params = { params: Promise<{ slug: string }> };
@@ -21,6 +21,7 @@ export async function GET(_req: Request, { params }: Params) {
return tx.blogPost.update({ return tx.blogPost.update({
where: { id: found.id }, where: { id: found.id },
data: { views: { increment: 1 } }, data: { views: { increment: 1 } },
include: blogPostInclude,
}); });
}); });
+2 -1
View File
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs"; import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
const posts = await prisma.blogPost.findMany({ const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true }, where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }], orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
}); });
+2 -1
View File
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs"; import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
const posts = await prisma.blogPost.findMany({ const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true }, where: { isPublished: true },
orderBy: [{ views: "desc" }, { publishedAt: "desc" }, { createdAt: "desc" }], orderBy: [{ views: "desc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
take: 3, take: 3,
+4 -1
View File
@@ -3,8 +3,9 @@ import { notFound } from "next/navigation";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer"; import { Footer } from "@/sections/Footer";
import { MarkdownContent } from "@/components/MarkdownContent"; import { MarkdownContent } from "@/components/MarkdownContent";
import { BlogAuthorList } from "@/components/BlogAuthorList";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getReadingTimeMinutes } from "@/lib/blogs"; import { blogPostInclude, getReadingTimeMinutes } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> }; type Params = { params: Promise<{ slug: string }> };
@@ -31,6 +32,7 @@ async function getPost(slug: string) {
return tx.blogPost.update({ return tx.blogPost.update({
where: { id: found.id }, where: { id: found.id },
data: { views: { increment: 1 } }, data: { views: { increment: 1 } },
include: blogPostInclude,
}); });
}); });
} }
@@ -81,6 +83,7 @@ export default async function BlogDetailPage({ params }: Params) {
<span>{post.views} views</span> <span>{post.views} views</span>
<span>{getReadingTimeMinutes(post.content)} min read</span> <span>{getReadingTimeMinutes(post.content)} min read</span>
</div> </div>
{post.authors.length > 0 ? <BlogAuthorList authors={post.authors} /> : null}
</div> </div>
<div className="pt-8"> <div className="pt-8">
+2 -1
View File
@@ -3,7 +3,7 @@ import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer"; import { Footer } from "@/sections/Footer";
import { BlogCard } from "@/components/BlogCard"; import { BlogCard } from "@/components/BlogCard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs"; import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const metadata = { export const metadata = {
title: "Blog | Paul W. Portfolio", title: "Blog | Paul W. Portfolio",
@@ -14,6 +14,7 @@ export const dynamic = "force-dynamic";
export default async function BlogsPage() { export default async function BlogsPage() {
const posts = await prisma.blogPost.findMany({ const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true }, where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }], 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 Link from "next/link";
import type { BlogPostSummary } from "@/types"; import type { BlogPostSummary } from "@/types";
import { BlogAuthorChips } from "@/components/BlogAuthorList";
function formatDate(value: string | null | undefined) { function formatDate(value: string | null | undefined) {
if (!value) return "Draft"; 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> <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"> <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>{formatDate(blog.publishedAt)}</span>
<span>{blog.readingTimeMinutes} min read</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 {
import type { BlogPost, BlogPostSummary } from "@/types"; 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 { function countWords(content: string): number {
return content return content
@@ -12,13 +28,24 @@ export function getReadingTimeMinutes(content: string): number {
return Math.max(1, Math.ceil(countWords(content) / 220)); 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 { return {
id: post.id, id: post.id,
title: post.title, title: post.title,
slug: post.slug, slug: post.slug,
excerpt: post.excerpt, excerpt: post.excerpt,
coverImageUrl: post.coverImageUrl, coverImageUrl: post.coverImageUrl,
authors: post.authors.map(toBlogAuthor),
isPublished: post.isPublished, isPublished: post.isPublished,
views: post.views, views: post.views,
readingTimeMinutes: getReadingTimeMinutes(post.content), 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 { return {
...toBlogSummary(post), ...toBlogSummary(post),
content: post.content, content: post.content,
+43
View File
@@ -2,6 +2,7 @@
// Prisma-ready data object or throws a ValidationError (mapped to HTTP 400). // Prisma-ready data object or throws a ValidationError (mapped to HTTP 400).
import { ProjectSize } from "@/generated/prisma/client"; import { ProjectSize } from "@/generated/prisma/client";
import { BLOG_AUTHOR_TYPES, type BlogAuthorType } from "@/types";
export class ValidationError extends Error {} export class ValidationError extends Error {}
@@ -62,6 +63,47 @@ function optDate(value: unknown): Date | null {
return Number.isNaN(d.getTime()) ? null : d; 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>) { export function parseProject(body: Record<string, unknown>) {
const size = String(body.size); const size = String(body.size);
const validSize = (Object.values(ProjectSize) as string[]).includes(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"), excerpt: str(body.excerpt, "excerpt"),
coverImageUrl: optStr(body.coverImageUrl), coverImageUrl: optStr(body.coverImageUrl),
content: str(body.content, "content"), content: str(body.content, "content"),
authors: parseBlogAuthors(body.authors),
isPublished, isPublished,
publishedAt: isPublished ? publishedAt ?? new Date() : publishedAt, publishedAt: isPublished ? publishedAt ?? new Date() : publishedAt,
}; };
+13
View File
@@ -47,12 +47,25 @@ export interface Affiliate {
sortIndex: number; 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 { export interface BlogPostSummary {
id: string; id: string;
title: string; title: string;
slug: string; slug: string;
excerpt: string; excerpt: string;
coverImageUrl?: string | null; coverImageUrl?: string | null;
authors: BlogAuthor[];
isPublished: boolean; isPublished: boolean;
views: number; views: number;
readingTimeMinutes: number; readingTimeMinutes: number;