Add blog CMS with tracked public posts
This commit is contained in:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user