Compare commits

..

7 Commits

Author SHA1 Message Date
space 6552273ec0 Merge pull request 'Fix blog cover images on blog cards' (#3) from fix/blog-card-cover-images into main
Build Check / build (push) Successful in 29s
Build Check / push-image (push) Successful in 56s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #3
2026-07-18 01:26:24 +02:00
luna c0c3658628 Fix blog card cover images
Build Check / build (pull_request) Successful in 29s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 23:19:35 +00:00
Space-Banane 589656e09b Update Dockerfile to simplify server startup command
Build Check / build (push) Successful in 46s
Build Check / push-image (push) Successful in 2m10s
Build Check / deploy-coolify (push) Successful in 7s
2026-07-18 01:16:48 +02:00
Space-Banane 7074f8d1d6 Update Dockerfile to use npx for Prisma migration command
Build Check / build (push) Successful in 36s
Build Check / push-image (push) Successful in 1m10s
Build Check / deploy-coolify (push) Successful in 8s
2026-07-18 01:13:03 +02:00
Space-Banane 7e38f89e37 Update Dockerfile to run database migrations before starting the server
Build Check / build (push) Successful in 33s
Build Check / push-image (push) Successful in 55s
Build Check / deploy-coolify (push) Successful in 7s
2026-07-18 01:08:07 +02:00
space 3a4dc01b90 Merge pull request 'Add blog authors support' (#2) from feat/blog-authors into main
Build Check / build (push) Successful in 29s
Build Check / push-image (push) Successful in 57s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #2
2026-07-18 01:02:46 +02:00
luna 6df4983f74 Add blog authors support
Build Check / build (pull_request) Successful in 30s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:59:28 +00:00
16 changed files with 440 additions and 15 deletions
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "BlogAuthor" (
"id" TEXT NOT NULL,
"blogPostId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"website" TEXT,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlogAuthor_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BlogAuthor_blogPostId_sortOrder_idx" ON "BlogAuthor"("blogPostId", "sortOrder");
-- AddForeignKey
ALTER TABLE "BlogAuthor" ADD CONSTRAINT "BlogAuthor_blogPostId_fkey" FOREIGN KEY ("blogPostId") REFERENCES "BlogPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+16
View File
@@ -77,4 +77,20 @@ model BlogPost {
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authors BlogAuthor[]
}
model BlogAuthor {
id String @id @default(cuid())
blogPostId String
blogPost BlogPost @relation(fields: [blogPostId], references: [id], onDelete: Cascade)
sortOrder Int @default(0)
type String
name String
website String?
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([blogPostId, sortOrder])
}
+147 -2
View File
@@ -3,7 +3,13 @@
import { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation";
import { PROJECT_SIZES, SIZE_LABELS, type ProjectSize } from "../../types";
import {
BLOG_AUTHOR_TYPES,
PROJECT_SIZES,
SIZE_LABELS,
type BlogAuthorType,
type ProjectSize,
} from "../../types";
import { MarkdownContent } from "@/components/MarkdownContent";
type Resource = "projects" | "experience" | "affiliates" | "blogs";
@@ -17,6 +23,12 @@ const RESOURCES: { key: Resource; label: string }[] = [
// A record being edited; `_new` marks an unsaved draft (POST vs PUT).
type Row = Record<string, unknown> & { id?: string; _new?: boolean };
type BlogAuthorInput = {
type: BlogAuthorType;
name: string;
website: string;
imageUrl: string;
};
const TEMPLATES: Record<Resource, () => Row> = {
projects: () => ({
@@ -65,6 +77,7 @@ const TEMPLATES: Record<Resource, () => Row> = {
excerpt: "",
coverImageUrl: "",
content: "# New post\n\nStart writing here.",
authors: [],
isPublished: true,
publishedAt: "",
views: 0,
@@ -89,6 +102,24 @@ function toDateTimeInput(value: unknown): string {
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
function normalizeBlogAuthors(value: unknown): BlogAuthorInput[] {
if (!Array.isArray(value)) return [];
return value.map((author) => {
const item = author as Record<string, unknown>;
const type = BLOG_AUTHOR_TYPES.includes(item.type as BlogAuthorType)
? (item.type as BlogAuthorType)
: "author";
return {
type,
name: typeof item.name === "string" ? item.name : "",
website: typeof item.website === "string" ? item.website : "",
imageUrl: typeof item.imageUrl === "string" ? item.imageUrl : "",
};
});
}
export default function AdminPage() {
const router = useRouter();
@@ -158,7 +189,11 @@ export default function AdminPage() {
res === "experience"
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
: res === "blogs"
? { ...item, publishedAt: toDateTimeInput(item.publishedAt) }
? {
...item,
publishedAt: toDateTimeInput(item.publishedAt),
authors: normalizeBlogAuthors(item.authors),
}
: item,
)
: [];
@@ -573,9 +608,31 @@ function slugify(value: string): string {
.replace(/^-+|-+$/g, "");
}
function emptyAuthor(): BlogAuthorInput {
return {
type: "author",
name: "",
website: "",
imageUrl: "",
};
}
function BlogFields({ row, idx, update }: FieldProps) {
const slug = typeof row.slug === "string" ? row.slug : "";
const content = typeof row.content === "string" ? row.content : "";
const authors = normalizeBlogAuthors(row.authors);
const updateAuthors = (nextAuthors: BlogAuthorInput[]) => {
update(idx, "authors", nextAuthors);
};
const updateAuthor = (
authorIndex: number,
field: keyof BlogAuthorInput,
value: BlogAuthorInput[keyof BlogAuthorInput],
) => {
updateAuthors(authors.map((author, index) => (index === authorIndex ? { ...author, [field]: value } : author)));
};
return (
<div className="space-y-5">
@@ -614,6 +671,94 @@ function BlogFields({ row, idx, update }: FieldProps) {
<Toggle row={row} idx={idx} update={update} field="isPublished" label="Published" />
</div>
<div className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">Authors</p>
<p className="text-sm text-gray-400">Up to 5 authors shown on the cards and blog page.</p>
</div>
<button
type="button"
onClick={() => updateAuthors([...authors, emptyAuthor()])}
disabled={authors.length >= 5}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-bold text-gray-200 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
>
Add author
</button>
</div>
{authors.length === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-4 py-5 text-sm text-gray-500">
No authors yet.
</div>
) : (
<div className="space-y-3">
{authors.map((author, authorIndex) => (
<div key={authorIndex} className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">
Author {authorIndex + 1}
</p>
<button
type="button"
onClick={() => updateAuthors(authors.filter((_, index) => index !== authorIndex))}
className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs font-bold text-red-200 hover:bg-red-500/20"
>
Remove
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<label className={labelCls}>Type</label>
<select
value={author.type}
onChange={(e) => updateAuthor(authorIndex, "type", e.target.value as BlogAuthorType)}
className={inputCls}
>
{BLOG_AUTHOR_TYPES.map((option) => (
<option key={option} value={option} className="bg-[#0a0a0a]">
{option}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className={labelCls}>Name</label>
<input
type="text"
value={author.name}
onChange={(e) => updateAuthor(authorIndex, "name", e.target.value)}
className={inputCls}
placeholder="Luna"
/>
</div>
<div className="space-y-1">
<label className={labelCls}>Website</label>
<input
type="text"
value={author.website}
onChange={(e) => updateAuthor(authorIndex, "website", e.target.value)}
className={inputCls}
placeholder="https://example.com"
/>
</div>
<div className="space-y-1">
<label className={labelCls}>Image URL</label>
<input
type="text"
value={author.imageUrl}
onChange={(e) => updateAuthor(authorIndex, "imageUrl", e.target.value)}
className={inputCls}
placeholder="https://example.com/avatar.jpg"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
+18 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
type Params = { params: Promise<{ id: string }> };
@@ -12,7 +13,23 @@ export async function PUT(req: Request, { params }: Params) {
return run(async () => {
const { id } = await params;
const data = parseBlogPost(await readBody(req));
const updated = await prisma.blogPost.update({ where: { id }, data });
const updated = await prisma.blogPost.update({
where: { id },
data: {
title: data.title,
slug: data.slug,
excerpt: data.excerpt,
coverImageUrl: data.coverImageUrl,
content: data.content,
isPublished: data.isPublished,
publishedAt: data.publishedAt,
authors: {
deleteMany: {},
create: data.authors,
},
},
include: blogPostInclude,
});
return NextResponse.json(updated);
});
}
+17 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
export async function GET() {
const denied = await guard();
@@ -9,6 +10,7 @@ export async function GET() {
return run(async () => {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
orderBy: [{ updatedAt: "desc" }],
});
return NextResponse.json(posts);
@@ -21,7 +23,21 @@ export async function POST(req: Request) {
return run(async () => {
const data = parseBlogPost(await readBody(req));
const created = await prisma.blogPost.create({ data });
const created = await prisma.blogPost.create({
data: {
title: data.title,
slug: data.slug,
excerpt: data.excerpt,
coverImageUrl: data.coverImageUrl,
content: data.content,
isPublished: data.isPublished,
publishedAt: data.publishedAt,
authors: {
create: data.authors,
},
},
include: blogPostInclude,
});
return NextResponse.json(created, { status: 201 });
});
}
+2 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogPost } from "@/lib/blogs";
import { blogPostInclude, toBlogPost } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
@@ -21,6 +21,7 @@ export async function GET(_req: Request, { params }: Params) {
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
include: blogPostInclude,
});
});
+2 -1
View File
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
+2 -1
View File
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ views: "desc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
take: 3,
+4 -1
View File
@@ -3,8 +3,9 @@ import { notFound } from "next/navigation";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer";
import { MarkdownContent } from "@/components/MarkdownContent";
import { BlogAuthorList } from "@/components/BlogAuthorList";
import { prisma } from "@/lib/prisma";
import { getReadingTimeMinutes } from "@/lib/blogs";
import { blogPostInclude, getReadingTimeMinutes } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
@@ -31,6 +32,7 @@ async function getPost(slug: string) {
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
include: blogPostInclude,
});
});
}
@@ -81,6 +83,7 @@ export default async function BlogDetailPage({ params }: Params) {
<span>{post.views} views</span>
<span>{getReadingTimeMinutes(post.content)} min read</span>
</div>
{post.authors.length > 0 ? <BlogAuthorList authors={post.authors} /> : null}
</div>
<div className="pt-8">
+2 -1
View File
@@ -3,7 +3,7 @@ import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer";
import { BlogCard } from "@/components/BlogCard";
import { prisma } from "@/lib/prisma";
import { toBlogSummary } from "@/lib/blogs";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const metadata = {
title: "Blog | Paul W. Portfolio",
@@ -14,6 +14,7 @@ export const dynamic = "force-dynamic";
export default async function BlogsPage() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
+102
View File
@@ -0,0 +1,102 @@
type DisplayAuthor = {
id: string;
type: string;
name: string;
website?: string | null;
imageUrl?: string | null;
};
const ROLE_LABELS: Record<string, string> = {
author: "Author",
"co-author": "Co-author",
relating: "Relating",
};
function avatarFallback(name: string) {
return name
.split(/\s+/)
.map((part) => part[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
}
function AuthorAvatar({ author }: { author: DisplayAuthor }) {
if (author.imageUrl) {
return (
<img
src={author.imageUrl}
alt={author.name}
className="h-10 w-10 rounded-full border border-white/10 object-cover"
/>
);
}
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-xs font-semibold text-gray-300">
{avatarFallback(author.name)}
</div>
);
}
export function BlogAuthorList({ authors }: { authors: DisplayAuthor[] }) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{authors.map((author) => {
const content = (
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<AuthorAvatar author={author} />
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-500">
{ROLE_LABELS[author.type] ?? author.type}
</p>
<p className="truncate text-sm font-medium text-white">{author.name}</p>
</div>
</div>
);
if (!author.website) {
return <div key={author.id}>{content}</div>;
}
return (
<a
key={author.id}
href={author.website}
target="_blank"
rel="noreferrer"
className="transition-transform duration-200 hover:-translate-y-0.5"
>
{content}
</a>
);
})}
</div>
);
}
export function BlogAuthorChips({ authors }: { authors: DisplayAuthor[] }) {
return (
<div className="flex flex-wrap gap-2">
{authors.map((author) => (
<div
key={author.id}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5"
>
{author.imageUrl ? (
<img
src={author.imageUrl}
alt={author.name}
className="h-5 w-5 rounded-full border border-white/10 object-cover"
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full border border-white/10 bg-white/5 text-[10px] font-semibold text-gray-300">
{avatarFallback(author.name)}
</div>
)}
<span className="text-xs font-medium text-gray-200">{author.name}</span>
</div>
))}
</div>
);
}
+20 -1
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import type { BlogPostSummary } from "@/types";
import { BlogAuthorChips } from "@/components/BlogAuthorList";
function formatDate(value: string | null | undefined) {
if (!value) return "Draft";
@@ -15,8 +16,20 @@ export function BlogCard({ blog, index }: { blog: BlogPostSummary; index?: numbe
return (
<Link
href={`/blogs/${blog.slug}`}
className="group flex h-full flex-col rounded-[1.75rem] border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]"
className="group flex h-full flex-col overflow-hidden rounded-[1.75rem] border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]"
>
{blog.coverImageUrl ? (
<div className="mb-5 overflow-hidden rounded-[1.25rem] border border-white/10 bg-black/30">
{/* External CMS image URLs are stored directly, so a plain img is the least brittle option here. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={blog.coverImageUrl}
alt={blog.title}
className="h-48 w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
) : null}
<div className="mb-5 flex items-start justify-between gap-4">
<div className="space-y-2">
{index !== undefined ? (
@@ -35,6 +48,12 @@ export function BlogCard({ blog, index }: { blog: BlogPostSummary; index?: numbe
<p className="flex-1 text-sm leading-7 text-gray-400">{blog.excerpt}</p>
{blog.authors.length > 0 ? (
<div className="mt-5">
<BlogAuthorChips authors={blog.authors} />
</div>
) : null}
<div className="mt-6 flex items-center justify-between gap-4 text-xs uppercase tracking-[0.18em] text-gray-500">
<span>{formatDate(blog.publishedAt)}</span>
<span>{blog.readingTimeMinutes} min read</span>
+31 -4
View File
@@ -1,5 +1,21 @@
import type { BlogPost as PrismaBlogPost } from "@/generated/prisma/client";
import type { BlogPost, BlogPostSummary } from "@/types";
import type {
BlogAuthor as PrismaBlogAuthor,
BlogPost as PrismaBlogPost,
Prisma,
} from "@/generated/prisma/client";
import type { BlogAuthor, BlogPost, BlogPostSummary } from "@/types";
export const blogPostInclude = {
authors: {
orderBy: {
sortOrder: "asc",
},
},
} satisfies Prisma.BlogPostInclude;
type PrismaBlogPostWithAuthors = PrismaBlogPost & {
authors: PrismaBlogAuthor[];
};
function countWords(content: string): number {
return content
@@ -12,13 +28,24 @@ export function getReadingTimeMinutes(content: string): number {
return Math.max(1, Math.ceil(countWords(content) / 220));
}
export function toBlogSummary(post: PrismaBlogPost): BlogPostSummary {
function toBlogAuthor(author: PrismaBlogAuthor): BlogAuthor {
return {
id: author.id,
type: author.type as BlogAuthor["type"],
name: author.name,
website: author.website,
imageUrl: author.imageUrl,
};
}
export function toBlogSummary(post: PrismaBlogPostWithAuthors): BlogPostSummary {
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
coverImageUrl: post.coverImageUrl,
authors: post.authors.map(toBlogAuthor),
isPublished: post.isPublished,
views: post.views,
readingTimeMinutes: getReadingTimeMinutes(post.content),
@@ -28,7 +55,7 @@ export function toBlogSummary(post: PrismaBlogPost): BlogPostSummary {
};
}
export function toBlogPost(post: PrismaBlogPost): BlogPost {
export function toBlogPost(post: PrismaBlogPostWithAuthors): BlogPost {
return {
...toBlogSummary(post),
content: post.content,
+43
View File
@@ -2,6 +2,7 @@
// Prisma-ready data object or throws a ValidationError (mapped to HTTP 400).
import { ProjectSize } from "@/generated/prisma/client";
import { BLOG_AUTHOR_TYPES, type BlogAuthorType } from "@/types";
export class ValidationError extends Error {}
@@ -62,6 +63,47 @@ function optDate(value: unknown): Date | null {
return Number.isNaN(d.getTime()) ? null : d;
}
function parseBlogAuthorType(value: unknown, field: string): BlogAuthorType {
if (typeof value !== "string" || !BLOG_AUTHOR_TYPES.includes(value as BlogAuthorType)) {
throw new ValidationError(`"${field}" must be one of: ${BLOG_AUTHOR_TYPES.join(", ")}`);
}
return value as BlogAuthorType;
}
function parseBlogAuthors(value: unknown) {
if (!Array.isArray(value)) return [];
const authors = value
.map((entry, index) => {
if (!entry || typeof entry !== "object") return null;
const body = entry as Record<string, unknown>;
const name = optStr(body.name);
const website = optStr(body.website);
const imageUrl = optStr(body.imageUrl);
const hasValues = Boolean(name || website || imageUrl || body.type);
if (!hasValues) return null;
if (!name) throw new ValidationError(`"authors[${index}].name" is required`);
return {
type: parseBlogAuthorType(body.type ?? "author", `authors[${index}].type`),
name,
website,
imageUrl,
sortOrder: index,
};
})
.filter((author): author is NonNullable<typeof author> => author !== null);
if (authors.length > 5) {
throw new ValidationError("Too many authors (max 5)");
}
return authors;
}
export function parseProject(body: Record<string, unknown>) {
const size = String(body.size);
const validSize = (Object.values(ProjectSize) as string[]).includes(size)
@@ -135,6 +177,7 @@ export function parseBlogPost(body: Record<string, unknown>) {
excerpt: str(body.excerpt, "excerpt"),
coverImageUrl: optStr(body.coverImageUrl),
content: str(body.content, "content"),
authors: parseBlogAuthors(body.authors),
isPublished,
publishedAt: isPublished ? publishedAt ?? new Date() : publishedAt,
};
+13
View File
@@ -47,12 +47,25 @@ export interface Affiliate {
sortIndex: number;
}
export const BLOG_AUTHOR_TYPES = ["author", "co-author", "relating"] as const;
export type BlogAuthorType = (typeof BLOG_AUTHOR_TYPES)[number];
export interface BlogAuthor {
id: string;
type: BlogAuthorType;
name: string;
website?: string | null;
imageUrl?: string | null;
}
export interface BlogPostSummary {
id: string;
title: string;
slug: string;
excerpt: string;
coverImageUrl?: string | null;
authors: BlogAuthor[];
isPublished: boolean;
views: number;
readingTimeMinutes: number;