Add blog CMS with tracked public posts
Build Check / build (pull_request) Successful in 34s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped

This commit is contained in:
2026-07-17 22:02:25 +00:00
parent ef63164d58
commit 912e5edb81
24 changed files with 1667 additions and 36 deletions
+116 -3
View File
@@ -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 &amp; affiliates</p>
<p className="text-gray-500 text-sm">Manage your projects, experience, affiliates &amp; 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>
);
}