Add blog CMS with view tracking #1
@@ -6,17 +6,19 @@ Personal portfolio built with Next.js, React, TypeScript, and Tailwind CSS.
|
|||||||
|
|
||||||
- Hero dev-card, live-count stat, and work-experience timeline
|
- Hero dev-card, live-count stat, and work-experience timeline
|
||||||
- Projects (served from our DB) with a `/projects` page and homepage teaser
|
- Projects (served from our DB) with a `/projects` page and homepage teaser
|
||||||
|
- Blog system with DB-backed markdown posts, admin editing, and view tracking
|
||||||
- Luna section, DB-driven affiliates, and the SHSF Code Activity graph
|
- Luna section, DB-driven affiliates, and the SHSF Code Activity graph
|
||||||
- Admin CMS at `/admin` for projects, work experience, and affiliates
|
- Admin CMS at `/admin` for projects, blog posts, work experience, and affiliates
|
||||||
|
|
||||||
## Data layer
|
## Data layer
|
||||||
|
|
||||||
Content (projects, work experience, affiliates) lives in **Postgres** and is
|
Content (projects, blog posts, work experience, affiliates) lives in **Postgres** and is
|
||||||
managed through Prisma + our own Next.js API routes:
|
managed through Prisma + our own Next.js API routes:
|
||||||
|
|
||||||
- Public reads: `GET /api/projects`, `/api/experience`, `/api/affiliates`
|
- Public reads: `GET /api/projects`, `/api/blogs`, `/api/blogs/top`, `/api/experience`, `/api/affiliates`
|
||||||
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,experience,affiliates}/…`
|
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,blogs,experience,affiliates}/…`
|
||||||
(cookie session issued by `POST /api/admin/login`)
|
(cookie session issued by `POST /api/admin/login`)
|
||||||
|
- Blog content views increment when `/blogs/[slug]` or `GET /api/blogs/[slug]` is requested
|
||||||
- `/api/profile-image` serves the avatar (cached in memory)
|
- `/api/profile-image` serves the avatar (cached in memory)
|
||||||
- Only the **Code Activity** widget still uses the external SHSF API.
|
- Only the **Code Activity** widget still uses the external SHSF API.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+874
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BlogPost" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"excerpt" TEXT NOT NULL,
|
||||||
|
"coverImageUrl" TEXT,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"views" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"publishedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
|
||||||
@@ -64,3 +64,17 @@ model Affiliate {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BlogPost {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
slug String @unique
|
||||||
|
excerpt String
|
||||||
|
coverImageUrl String?
|
||||||
|
content String @db.Text
|
||||||
|
isPublished Boolean @default(true)
|
||||||
|
views Int @default(0)
|
||||||
|
publishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
+116
-3
@@ -4,13 +4,15 @@ 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 { PROJECT_SIZES, SIZE_LABELS, type ProjectSize } from "../../types";
|
||||||
|
import { MarkdownContent } from "@/components/MarkdownContent";
|
||||||
|
|
||||||
type Resource = "projects" | "experience" | "affiliates";
|
type Resource = "projects" | "experience" | "affiliates" | "blogs";
|
||||||
|
|
||||||
const RESOURCES: { key: Resource; label: string }[] = [
|
const RESOURCES: { key: Resource; label: string }[] = [
|
||||||
{ key: "projects", label: "Projects" },
|
{ key: "projects", label: "Projects" },
|
||||||
{ key: "experience", label: "Work Experience" },
|
{ key: "experience", label: "Work Experience" },
|
||||||
{ key: "affiliates", label: "Affiliates" },
|
{ key: "affiliates", label: "Affiliates" },
|
||||||
|
{ key: "blogs", label: "Blogs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// A record being edited; `_new` marks an unsaved draft (POST vs PUT).
|
// A record being edited; `_new` marks an unsaved draft (POST vs PUT).
|
||||||
@@ -56,6 +58,17 @@ const TEMPLATES: Record<Resource, () => Row> = {
|
|||||||
bad: [],
|
bad: [],
|
||||||
sortIndex: 0,
|
sortIndex: 0,
|
||||||
}),
|
}),
|
||||||
|
blogs: () => ({
|
||||||
|
_new: true,
|
||||||
|
title: "",
|
||||||
|
slug: "",
|
||||||
|
excerpt: "",
|
||||||
|
coverImageUrl: "",
|
||||||
|
content: "# New post\n\nStart writing here.",
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: "",
|
||||||
|
views: 0,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
@@ -69,6 +82,13 @@ function toDateInput(value: unknown): string {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDateTimeInput(value: unknown): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const d = new Date(String(value));
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -137,6 +157,8 @@ export default function AdminPage() {
|
|||||||
? json.map((item: Row) =>
|
? json.map((item: Row) =>
|
||||||
res === "experience"
|
res === "experience"
|
||||||
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
|
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
|
||||||
|
: res === "blogs"
|
||||||
|
? { ...item, publishedAt: toDateTimeInput(item.publishedAt) }
|
||||||
: item,
|
: item,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
@@ -169,7 +191,8 @@ export default function AdminPage() {
|
|||||||
setSavingId(row.id || `new-${idx}`);
|
setSavingId(row.id || `new-${idx}`);
|
||||||
try {
|
try {
|
||||||
const url = isNew ? `/api/admin/${resource}` : `/api/admin/${resource}/${row.id}`;
|
const url = isNew ? `/api/admin/${resource}` : `/api/admin/${resource}/${row.id}`;
|
||||||
const { _new, ...payload } = row;
|
const payload = { ...row };
|
||||||
|
delete payload._new;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: isNew ? "POST" : "PUT",
|
method: isNew ? "POST" : "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -257,7 +280,7 @@ export default function AdminPage() {
|
|||||||
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-10">
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black text-white">PORTFOLIO CMS</h1>
|
<h1 className="text-3xl font-black text-white">PORTFOLIO CMS</h1>
|
||||||
<p className="text-gray-500 text-sm">Manage your projects, experience & affiliates</p>
|
<p className="text-gray-500 text-sm">Manage your projects, experience, affiliates & blog posts</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={() => router.push("/")} className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm">
|
<button onClick={() => router.push("/")} className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm">
|
||||||
@@ -330,6 +353,7 @@ export default function AdminPage() {
|
|||||||
{resource === "projects" && <ProjectFields row={row} idx={idx} update={update} />}
|
{resource === "projects" && <ProjectFields row={row} idx={idx} update={update} />}
|
||||||
{resource === "experience" && <ExperienceFields row={row} idx={idx} update={update} />}
|
{resource === "experience" && <ExperienceFields row={row} idx={idx} update={update} />}
|
||||||
{resource === "affiliates" && <AffiliateFields row={row} idx={idx} update={update} />}
|
{resource === "affiliates" && <AffiliateFields row={row} idx={idx} update={update} />}
|
||||||
|
{resource === "blogs" && <BlogFields row={row} idx={idx} update={update} />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -541,3 +565,92 @@ function AffiliateFields({ row, idx, update }: FieldProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlogFields({ row, idx, update }: FieldProps) {
|
||||||
|
const slug = typeof row.slug === "string" ? row.slug : "";
|
||||||
|
const content = typeof row.content === "string" ? row.content : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Text row={row} idx={idx} update={update} field="title" label="Title" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className={labelCls}>Slug</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => update(idx, "slug", e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="my-post-slug"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => update(idx, "slug", slugify(String(row.title ?? "")))}
|
||||||
|
className="shrink-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-bold text-gray-300 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Use title
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Text row={row} idx={idx} update={update} field="coverImageUrl" label="Cover Image URL" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className={labelCls}>Publish At</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={(row.publishedAt as string) || ""}
|
||||||
|
onChange={(e) => update(idx, "publishedAt", e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Area row={row} idx={idx} update={update} field="excerpt" label="Excerpt" />
|
||||||
|
<Toggle row={row} idx={idx} update={update} field="isPublished" label="Published" />
|
||||||
|
</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>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">Public Post</p>
|
||||||
|
<p className="text-sm text-gray-400">{slug ? `/blogs/${slug}` : "Set a slug to get a public URL."}</p>
|
||||||
|
</div>
|
||||||
|
{slug ? (
|
||||||
|
<a
|
||||||
|
href={`/blogs/${slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Open post
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Views: {Number(row.views ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className={labelCls}>Markdown Content</label>
|
||||||
|
<textarea
|
||||||
|
rows={22}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => update(idx, "content", e.target.value)}
|
||||||
|
className={`${inputCls} min-h-[28rem] font-mono text-sm`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className={labelCls}>Live Preview</label>
|
||||||
|
<div className="min-h-[28rem] rounded-2xl border border-white/10 bg-black/30 p-5">
|
||||||
|
<MarkdownContent content={content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseBlogPost } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PUT(req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = parseBlogPost(await readBody(req));
|
||||||
|
const updated = await prisma.blogPost.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.blogPost.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseBlogPost } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
return run(async () => {
|
||||||
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
orderBy: [{ updatedAt: "desc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(posts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
return run(async () => {
|
||||||
|
const data = parseBlogPost(await readBody(req));
|
||||||
|
const created = await prisma.blogPost.create({ data });
|
||||||
|
return NextResponse.json(created, { status: 201 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { toBlogPost } from "@/lib/blogs";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(_req: Request, { params }: Params) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const post = await prisma.$transaction(async (tx) => {
|
||||||
|
const found = await tx.blogPost.findFirst({
|
||||||
|
where: { slug, isPublished: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.blogPost.update({
|
||||||
|
where: { id: found.id },
|
||||||
|
data: { views: { increment: 1 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(toBlogPost(post));
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { toBlogSummary } from "@/lib/blogs";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(posts.map(toBlogSummary));
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { toBlogSummary } from "@/lib/blogs";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
orderBy: [{ views: "desc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
|
||||||
|
take: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(posts.map(toBlogSummary));
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
import { Footer } from "@/sections/Footer";
|
||||||
|
import { MarkdownContent } from "@/components/MarkdownContent";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getReadingTimeMinutes } from "@/lib/blogs";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function formatDate(value: Date | null) {
|
||||||
|
if (!value) return "Draft";
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("en", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPost(slug: string) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const found = await tx.blogPost.findFirst({
|
||||||
|
where: { slug, isPublished: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
return tx.blogPost.update({
|
||||||
|
where: { id: found.id },
|
||||||
|
data: { views: { increment: 1 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Params): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = await prisma.blogPost.findFirst({
|
||||||
|
where: { slug, isPublished: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return {
|
||||||
|
title: "Post not found | Paul W. Portfolio",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${post.title} | Paul W. Portfolio`,
|
||||||
|
description: post.excerpt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({ params }: Params) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = await getPost(slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<div className="mx-auto w-full max-w-4xl px-4 pb-20 pt-28 sm:pt-32">
|
||||||
|
<article className="rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 sm:px-10 sm:py-14">
|
||||||
|
<div className="space-y-4 border-b border-white/10 pb-8">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
|
||||||
|
blog post
|
||||||
|
</p>
|
||||||
|
<h1 className="max-w-3xl text-4xl font-semibold text-white sm:text-5xl">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-sm leading-7 text-gray-400 sm:text-base">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.18em] text-gray-500">
|
||||||
|
<span>{formatDate(post.publishedAt)}</span>
|
||||||
|
<span>{post.views} views</span>
|
||||||
|
<span>{getReadingTimeMinutes(post.content)} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8">
|
||||||
|
<MarkdownContent content={post.content} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Blog | Paul W. Portfolio",
|
||||||
|
description: "Notes on infrastructure, tooling, and shipping software.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function BlogsPage() {
|
||||||
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const blogs = posts.map(toBlogSummary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 pb-20 pt-28 sm:pt-32">
|
||||||
|
<section className="rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 sm:px-10 sm:py-14">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
|
||||||
|
writing
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 max-w-3xl text-4xl font-semibold text-white sm:text-5xl">
|
||||||
|
Technical writing on infrastructure, software, and product work.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-sm leading-7 text-gray-400 sm:text-base">
|
||||||
|
Notes, updates, and technical writing across current projects and systems.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
{blogs.length === 0 ? (
|
||||||
|
<div className="rounded-[1.75rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-gray-400">
|
||||||
|
No published posts yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{blogs.map((blog) => (
|
||||||
|
<BlogCard key={blog.id} blog={blog} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-10 flex justify-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-6 py-3 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Back home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
import { useProfile } from "../context/ProfileContext";
|
import { useProfile } from "../context/ProfileContext";
|
||||||
import { Navbar } from "../components/Navbar";
|
import { Navbar } from "../components/Navbar";
|
||||||
import { Hero } from "../sections/Hero";
|
import { Hero } from "../sections/Hero";
|
||||||
import { Stats } from "../sections/Stats";
|
import { TopBlogs } from "../sections/TopBlogs";
|
||||||
import { WorkExperience } from "../sections/WorkExperience";
|
import { WorkExperience } from "../sections/WorkExperience";
|
||||||
import { FeaturedProjects } from "../sections/FeaturedProjects";
|
import { FeaturedProjects } from "../sections/FeaturedProjects";
|
||||||
import { Luna } from "../sections/Luna";
|
import { Luna } from "../sections/Luna";
|
||||||
@@ -19,7 +19,7 @@ export default function Home() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
|
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
|
||||||
<Hero />
|
<Hero />
|
||||||
<Stats />
|
<TopBlogs />
|
||||||
<WorkExperience experiences={experiences} />
|
<WorkExperience experiences={experiences} />
|
||||||
<FeaturedProjects />
|
<FeaturedProjects />
|
||||||
<Luna />
|
<Luna />
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { BlogPostSummary } from "@/types";
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
if (!value) return "Draft";
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("en", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlogCard({ blog, index }: { blog: BlogPostSummary; index?: number }) {
|
||||||
|
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]"
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{index !== undefined ? (
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-gray-500">
|
||||||
|
Top {index + 1}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<h3 className="text-2xl font-semibold text-white transition-colors group-hover:text-gray-100">
|
||||||
|
{blog.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-white/10 bg-black/40 px-3 py-1 text-xs font-medium text-gray-300">
|
||||||
|
{blog.views} views
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="flex-1 text-sm leading-7 text-gray-400">{blog.excerpt}</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
export function MarkdownContent({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 text-sm leading-7 text-gray-300 sm:text-base">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({ children }) => <h1 className="mt-8 text-3xl font-semibold text-white first:mt-0">{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 className="mt-8 text-2xl font-semibold text-white">{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 className="mt-6 text-xl font-semibold text-white">{children}</h3>,
|
||||||
|
p: ({ children }) => <p>{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="list-disc space-y-2 pl-6">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="list-decimal space-y-2 pl-6">{children}</ol>,
|
||||||
|
li: ({ children }) => <li>{children}</li>,
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-white underline decoration-white/30 underline-offset-4 hover:decoration-white"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-2 border-white/20 pl-4 italic text-gray-400">{children}</blockquote>
|
||||||
|
),
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
// Markdown images should feel deliberate in the article layout.
|
||||||
|
<img
|
||||||
|
src={src || ""}
|
||||||
|
alt={alt || ""}
|
||||||
|
className="my-6 w-full rounded-2xl border border-white/10 bg-black/40 object-cover"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="my-6 overflow-x-auto rounded-2xl border border-white/10">
|
||||||
|
<table className="min-w-full border-collapse text-left text-sm">{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }) => <thead className="bg-white/6 text-white">{children}</thead>,
|
||||||
|
tbody: ({ children }) => <tbody className="divide-y divide-white/10">{children}</tbody>,
|
||||||
|
tr: ({ children }) => <tr className="divide-x divide-white/10">{children}</tr>,
|
||||||
|
th: ({ children }) => <th className="px-4 py-3 font-semibold">{children}</th>,
|
||||||
|
td: ({ children }) => <td className="px-4 py-3 align-top text-gray-300">{children}</td>,
|
||||||
|
code: ({ className, children }) => {
|
||||||
|
const inline = !className;
|
||||||
|
if (inline) {
|
||||||
|
return <code className="rounded bg-white/8 px-1.5 py-0.5 font-mono text-[0.95em] text-white">{children}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<code className="block overflow-x-auto rounded-2xl border border-white/10 bg-black/50 p-4 font-mono text-sm text-gray-200">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => <pre>{children}</pre>,
|
||||||
|
hr: () => <hr className="border-white/10" />,
|
||||||
|
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export function Navbar() {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Home", path: "/" },
|
{ label: "Home", path: "/" },
|
||||||
{ label: "Projects", path: "/projects" },
|
{ label: "Projects", path: "/projects" },
|
||||||
|
{ label: "Blog", path: "/blogs" },
|
||||||
{ label: "Connect", path: "/contact" },
|
{ label: "Connect", path: "/contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,4 +41,3 @@ export function Navbar() {
|
|||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isAdmin } from "@/lib/auth";
|
import { isAdmin } from "@/lib/auth";
|
||||||
import { ValidationError } from "@/lib/dto";
|
import { ValidationError } from "@/lib/dto";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
/** Returns a 401 response when the caller isn't an authenticated admin, else null. */
|
/** Returns a 401 response when the caller isn't an authenticated admin, else null. */
|
||||||
export async function guard(): Promise<NextResponse | null> {
|
export async function guard(): Promise<NextResponse | null> {
|
||||||
@@ -18,6 +19,9 @@ export async function run(handler: () => Promise<NextResponse>): Promise<NextRes
|
|||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
return NextResponse.json({ error: err.message }, { status: 400 });
|
return NextResponse.json({ error: err.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||||
|
return NextResponse.json({ error: "A unique field already uses that value" }, { status: 409 });
|
||||||
|
}
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { BlogPost as PrismaBlogPost } from "@/generated/prisma/client";
|
||||||
|
import type { BlogPost, BlogPostSummary } from "@/types";
|
||||||
|
|
||||||
|
function countWords(content: string): number {
|
||||||
|
return content
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReadingTimeMinutes(content: string): number {
|
||||||
|
return Math.max(1, Math.ceil(countWords(content) / 220));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBlogSummary(post: PrismaBlogPost): BlogPostSummary {
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
coverImageUrl: post.coverImageUrl,
|
||||||
|
isPublished: post.isPublished,
|
||||||
|
views: post.views,
|
||||||
|
readingTimeMinutes: getReadingTimeMinutes(post.content),
|
||||||
|
publishedAt: post.publishedAt?.toISOString() ?? null,
|
||||||
|
createdAt: post.createdAt.toISOString(),
|
||||||
|
updatedAt: post.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBlogPost(post: PrismaBlogPost): BlogPost {
|
||||||
|
return {
|
||||||
|
...toBlogSummary(post),
|
||||||
|
content: post.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -111,3 +111,31 @@ export function parseAffiliate(body: Record<string, unknown>) {
|
|||||||
sortIndex: int(body.sortIndex),
|
sortIndex: int(body.sortIndex),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slug(value: unknown): string {
|
||||||
|
const normalized = str(value, "slug")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new ValidationError('"slug" is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlogPost(body: Record<string, unknown>) {
|
||||||
|
const isPublished = bool(body.isPublished);
|
||||||
|
const publishedAt = optDate(body.publishedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: str(body.title, "title"),
|
||||||
|
slug: slug(body.slug),
|
||||||
|
excerpt: str(body.excerpt, "excerpt"),
|
||||||
|
coverImageUrl: optStr(body.coverImageUrl),
|
||||||
|
content: str(body.content, "content"),
|
||||||
|
isPublished,
|
||||||
|
publishedAt: isPublished ? publishedAt ?? new Date() : publishedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function Activity() {
|
|||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2>
|
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2>
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
||||||
Today, i've already been coding for
|
Today, i've already been coding for
|
||||||
<span className="font-mono text-base text-green-400 ml-1">
|
<span className="font-mono text-base text-green-400 ml-1">
|
||||||
{dailyActivity || "..."}
|
{dailyActivity || "..."}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { GitBranch } from "lucide-react";
|
|
||||||
import { useProfile } from "../context/ProfileContext";
|
|
||||||
|
|
||||||
export function Stats() {
|
|
||||||
const { projects, loading } = useProfile();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mx-auto w-full max-w-5xl px-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:max-w-xs">
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-400">Projects</span>
|
|
||||||
<GitBranch className="h-4 w-4 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-bold text-white">
|
|
||||||
{loading ? "—" : projects.length}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">and many more private ones</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { BlogPostSummary } from "@/types";
|
||||||
|
import { BlogCard } from "@/components/BlogCard";
|
||||||
|
|
||||||
|
export function TopBlogs() {
|
||||||
|
const [blogs, setBlogs] = useState<BlogPostSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetch("/api/blogs/top")
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((json) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setBlogs(Array.isArray(json) ? json : []);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setBlogs([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-full max-w-6xl space-y-8 px-4">
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
|
||||||
|
blog
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl font-semibold text-white sm:text-4xl">
|
||||||
|
recent writing
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
|
||||||
|
Notes on infrastructure, software, and the things I am building.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-56 animate-pulse rounded-[1.75rem] border border-white/10 bg-white/[0.03]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : blogs.length === 0 ? (
|
||||||
|
<div className="rounded-[1.75rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-gray-400">
|
||||||
|
No blog posts published yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||||
|
{blogs.map((blog, index) => (
|
||||||
|
<BlogCard key={blog.id} blog={blog} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,24 @@ export interface Affiliate {
|
|||||||
sortIndex: number;
|
sortIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BlogPostSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
excerpt: string;
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
isPublished: boolean;
|
||||||
|
views: number;
|
||||||
|
readingTimeMinutes: number;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogPost extends BlogPostSummary {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const PROJECT_SIZES: ProjectSize[] = ["Big", "MediumSized", "Small"];
|
export const PROJECT_SIZES: ProjectSize[] = ["Big", "MediumSized", "Small"];
|
||||||
|
|
||||||
export const SIZE_LABELS: Record<ProjectSize, string> = {
|
export const SIZE_LABELS: Record<ProjectSize, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user