Merge pull request 'Add blog authors support' (#2) from feat/blog-authors into main
Reviewed-on: #2
This commit was merged in pull request #2.
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?
|
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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user