Merge pull request 'Add blog CMS with view tracking' (#1) from feat/blog-cms into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||
- 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
|
||||
- Admin CMS at `/admin` for projects, work experience, and affiliates
|
||||
- Admin CMS at `/admin` for projects, blog posts, work experience, and affiliates
|
||||
|
||||
## 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:
|
||||
|
||||
- Public reads: `GET /api/projects`, `/api/experience`, `/api/affiliates`
|
||||
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,experience,affiliates}/…`
|
||||
- Public reads: `GET /api/projects`, `/api/blogs`, `/api/blogs/top`, `/api/experience`, `/api/affiliates`
|
||||
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,blogs,experience,affiliates}/…`
|
||||
(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)
|
||||
- Only the **Code Activity** widget still uses the external SHSF API.
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"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())
|
||||
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 { useRouter } from "next/navigation";
|
||||
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 }[] = [
|
||||
{ key: "projects", label: "Projects" },
|
||||
{ key: "experience", label: "Work Experience" },
|
||||
{ key: "affiliates", label: "Affiliates" },
|
||||
{ key: "blogs", label: "Blogs" },
|
||||
];
|
||||
|
||||
// A record being edited; `_new` marks an unsaved draft (POST vs PUT).
|
||||
@@ -56,6 +58,17 @@ const TEMPLATES: Record<Resource, () => Row> = {
|
||||
bad: [],
|
||||
sortIndex: 0,
|
||||
}),
|
||||
blogs: () => ({
|
||||
_new: true,
|
||||
title: "",
|
||||
slug: "",
|
||||
excerpt: "",
|
||||
coverImageUrl: "",
|
||||
content: "# New post\n\nStart writing here.",
|
||||
isPublished: true,
|
||||
publishedAt: "",
|
||||
views: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
@@ -69,6 +82,13 @@ function toDateInput(value: unknown): string {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -137,6 +157,8 @@ export default function AdminPage() {
|
||||
? json.map((item: Row) =>
|
||||
res === "experience"
|
||||
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
|
||||
: res === "blogs"
|
||||
? { ...item, publishedAt: toDateTimeInput(item.publishedAt) }
|
||||
: item,
|
||||
)
|
||||
: [];
|
||||
@@ -169,7 +191,8 @@ export default function AdminPage() {
|
||||
setSavingId(row.id || `new-${idx}`);
|
||||
try {
|
||||
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, {
|
||||
method: isNew ? "POST" : "PUT",
|
||||
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">
|
||||
<div>
|
||||
<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 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">
|
||||
@@ -330,6 +353,7 @@ export default function AdminPage() {
|
||||
{resource === "projects" && <ProjectFields 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 === "blogs" && <BlogFields row={row} idx={idx} update={update} />}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -541,3 +565,92 @@ function AffiliateFields({ row, idx, update }: FieldProps) {
|
||||
</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 { Navbar } from "../components/Navbar";
|
||||
import { Hero } from "../sections/Hero";
|
||||
import { Stats } from "../sections/Stats";
|
||||
import { TopBlogs } from "../sections/TopBlogs";
|
||||
import { WorkExperience } from "../sections/WorkExperience";
|
||||
import { FeaturedProjects } from "../sections/FeaturedProjects";
|
||||
import { Luna } from "../sections/Luna";
|
||||
@@ -19,7 +19,7 @@ export default function Home() {
|
||||
<Navbar />
|
||||
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
|
||||
<Hero />
|
||||
<Stats />
|
||||
<TopBlogs />
|
||||
<WorkExperience experiences={experiences} />
|
||||
<FeaturedProjects />
|
||||
<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 = [
|
||||
{ label: "Home", path: "/" },
|
||||
{ label: "Projects", path: "/projects" },
|
||||
{ label: "Blog", path: "/blogs" },
|
||||
{ label: "Connect", path: "/contact" },
|
||||
];
|
||||
|
||||
@@ -40,4 +41,3 @@ export function Navbar() {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
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. */
|
||||
export async function guard(): Promise<NextResponse | null> {
|
||||
@@ -18,6 +19,9 @@ export async function run(handler: () => Promise<NextResponse>): Promise<NextRes
|
||||
if (err instanceof ValidationError) {
|
||||
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);
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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">
|
||||
<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">
|
||||
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">
|
||||
{dailyActivity || "..."}
|
||||
</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;
|
||||
}
|
||||
|
||||
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 SIZE_LABELS: Record<ProjectSize, string> = {
|
||||
|
||||
Reference in New Issue
Block a user