Add blog authors support
This commit is contained in:
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user