Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
723ad53
feat(blog): add revision schema and expand author access control
RsbhThakur Sep 3, 2026
8f32134
feat(api): implement staged blog revisions and admin approval endpoints
RsbhThakur Sep 3, 2026
0006d00
feat(blog-ui): add member revision workflow and public edit link
RsbhThakur Sep 3, 2026
5b44a8f
feat(admin-blog): add revision review UI and integration test suite
RsbhThakur Sep 3, 2026
e3da3bc
fix(blog-api): use VALIDATION_ERROR AppErrorCode for invalid revision…
RsbhThakur Sep 3, 2026
6d01d92
test(blog): fix audit actor access and image upload assertions for pu…
RsbhThakur Sep 3, 2026
034a3b8
fix(dashboard): enhance author userId matching and edit button styling
RsbhThakur Sep 3, 2026
c77fdb1
fix(dashboard): import mongoose in dashboard page
RsbhThakur Sep 3, 2026
0ddb558
fix(audit): support graceful fallback for standalone MongoDB instance…
RsbhThakur Sep 3, 2026
79206a1
feat(blog): separate withdraw review request from discard changes act…
RsbhThakur Sep 3, 2026
7bf0412
feat(admin-blog): add inline revision diff inspector and admin review…
RsbhThakur Sep 3, 2026
53137a4
fix(styles): fix mixins relative import path in RevisionDiffViewer.mo…
RsbhThakur Sep 3, 2026
eff17b4
fix(blog): include all admins in revision notification
RsbhThakur Sep 3, 2026
5ba2846
refactor(blog): remove notification dispatch to separate issue
RsbhThakur Sep 3, 2026
48707ac
feat: Styling
maydayv7 Sep 4, 2026
09c0208
fix(blog): address review comments on PR #39
RsbhThakur Sep 4, 2026
fdda727
fix: Review fixes
maydayv7 Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app/(protected)/admin/blog/AdminBlog.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@
@include badge-warning;
}

.revisionPending {
@include badge-warning;
}

.revisionDraft {
@include badge-info;
}

.rowAuthor {
color: var(--muted);
}
Expand Down
24 changes: 0 additions & 24 deletions src/app/(protected)/admin/blog/[slug]/edit/EditPost.module.scss

This file was deleted.

256 changes: 200 additions & 56 deletions src/app/(protected)/admin/blog/[slug]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,153 @@
"use client";

import Link from "next/link";
import { Check, X as IconX } from "lucide-react";
import { useRouter } from "next/navigation";
import { use, useEffect, useState } from "react";
import { ExternalLink as IconExternalLink } from "lucide-react";

import BlogEditor, { BlogEditorData } from "@/components/blog/BlogEditor";
import BlogEditorHeading from "@/components/blog/BlogEditorHeading";
import BlogEditorToolbar from "@/components/blog/BlogEditorToolbar";
import RevisionDiffViewer from "@/components/blog/RevisionDiffViewer";
import RevisionPanel from "@/components/blog/RevisionPanel";
import Button from "@/components/shared/Button";
import ConfirmDialog from "@/components/shared/ConfirmDialog";
import InlineNotice from "@/components/shared/InlineNotice";
import { FormSkeletonContent } from "@/components/shared/skeletons/FormSkeleton";
import { expectAppData } from "@/lib/api/result";
import type { BlogStatus } from "@/lib/constants";
import type { ImageFocalPoint } from "@/lib/imageFocalPoint";

import BlogEditor from "@/components/blog/BlogEditor";
import BackLink from "@/components/shared/BackLink";

import styles from "./EditPost.module.scss";
import { FormSkeletonContent } from "@/components/shared/skeletons/FormSkeleton";
import {
DEFAULT_IMAGE_FOCAL_POINT,
type ImageFocalPoint,
} from "@/lib/imageFocalPoint";
import { formatDateTime } from "@/lib/utils";

interface Props {
params: Promise<{ slug: string }>;
}

interface RevisionData {
title: string;
content: string;
excerpt: string;
coverImage: string;
coverFocalPoint?: ImageFocalPoint;
tags: string[];
updatedAt: string;
submittedAt: string | null;
}

interface EditablePost {
title: string;
content: string;
excerpt: string;
coverImage: string;
coverFocalPoint?: ImageFocalPoint;
tags: string[];
status: BlogStatus;
authors: { userId: string; name: string }[];
slug: string;
updatedAt: string;
pendingRevision?: RevisionData | null;
}

type ReviewAction = "approve" | "reject";

interface Notice {
message: string;
tone: "error" | "success";
}

export default function EditBlogPostPage({ params }: Props) {
const { slug } = use(params);
const router = useRouter();
const [post, setPost] = useState<any>(null);
const [post, setPost] = useState<EditablePost | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reviewAction, setReviewAction] = useState<ReviewAction | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [notice, setNotice] = useState<Notice | null>(null);

useEffect(() => {
let cancelled = false;

async function fetchPost() {
try {
const res = await fetch(`/api/admin/blog/${slug}`);
const data = await expectAppData(res);
setPost(data.post);
const response = await fetch(`/api/admin/blog/${slug}`);
const data = await expectAppData(response);
if (!cancelled) setPost(data.post);
} catch {
setError("Failed to load post.");
if (!cancelled) setError("Failed to load post.");
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
}

void fetchPost();
return () => {
cancelled = true;
};
}, [slug]);

const handleSave = async (data: {
title: string;
content: string;
excerpt: string;
coverImage: string;
coverFocalPoint: ImageFocalPoint;
tags: string[];
status: BlogStatus;
authors: { userId: string; name: string }[];
}) => {
const res = await fetch(`/api/admin/blog/${slug}`, {
const handleSave = async (data: BlogEditorData) => {
const response = await fetch(`/api/admin/blog/${slug}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});

const updated = await expectAppData(res);
// If slug changed (shouldn't normally), redirect
const updated = await expectAppData(response);
setPost(updated.post);
setNotice({ message: "Live post saved.", tone: "success" });
if (updated.post?.slug && updated.post.slug !== slug) {
router.push(`/admin/blog/${updated.post.slug}/edit`);
}
};

const submitReviewAction = async (action: ReviewAction) => {
setActionLoading(true);
setNotice(null);
try {
const response = await fetch(`/api/admin/blog/${slug}/revision`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
const data = await expectAppData(response);
setPost(data.post);
setReviewAction(null);
setNotice({
message:
action === "approve"
? "Changes approved and published."
: "Proposed changes rejected and discarded.",
tone: "success",
});
} catch {
setReviewAction(null);
setNotice({
message:
action === "approve"
? "Failed to approve the revision."
: "Failed to reject the revision.",
tone: "error",
});
} finally {
setActionLoading(false);
}
};

const toolbar = (
<BlogEditorToolbar
backHref="/admin/blog"
backLabel="Back to Blog Management"
liveHref={post?.status === "published" ? `/blog/${slug}` : undefined}
/>
);

if (loading) {
return (
<div>
<div className={styles.topBar}>
<BackLink href="/admin/blog" label="Back to Blog Management" />
</div>
{toolbar}
<FormSkeletonContent label="the editor" fields={5} />
</div>
);
Expand All @@ -79,41 +156,108 @@ export default function EditBlogPostPage({ params }: Props) {
if (error || !post) {
return (
<div>
<p className={styles.error}>{error || "Post not found."}</p>
<BackLink href="/admin/blog" label="Back to Blog Management" />
{toolbar}
<InlineNotice tone="error">{error || "Post not found."}</InlineNotice>
</div>
);
}

const revision = post.pendingRevision;
const isSubmitted = Boolean(revision?.submittedAt);
const liveEditorData = {
title: post.title,
content: post.content,
excerpt: post.excerpt,
coverImage: post.coverImage,
coverFocalPoint: post.coverFocalPoint || DEFAULT_IMAGE_FOCAL_POINT,
tags: post.tags,
status: post.status,
authors: post.authors || [],
};

return (
<div>
<div className={styles.topBar}>
<BackLink href="/admin/blog" label="Back to Blog Management" />
{post.status === "published" && (
<Link
href={`/blog/${slug}`}
className={styles.viewLink}
target="_blank"
rel="noreferrer"
>
View Published Post <IconExternalLink width={12} height={12} />
</Link>
)}
</div>
{toolbar}

<BlogEditorHeading
kicker="Admin blog editor"
title={post.title}
description="Edit the live article and/or review staged author changes."
/>

{notice && (
<InlineNotice tone={notice.tone}>{notice.message}</InlineNotice>
)}

{revision && (
<RevisionPanel
title={
isSubmitted
? "Revision ready for review"
: "Author draft in progress"
}
badge={isSubmitted ? "Review requested" : "Draft staged"}
tone={isSubmitted ? "warning" : "info"}
description={
isSubmitted
? `Submitted ${formatDateTime(revision.submittedAt)}. Compare the proposal with the live post before publishing or rejecting it.`
: `Last saved ${formatDateTime(revision.updatedAt)}. The author has not requested review yet, so publishing actions are unavailable. The live post remains unchanged.`
}
actions={
isSubmitted ? (
<>
<Button
variant="primary"
size="small"
onClick={() => setReviewAction("approve")}
disabled={actionLoading}
>
<Check width={14} height={14} /> Approve &amp; publish
</Button>
<Button
variant="danger"
size="small"
onClick={() => setReviewAction("reject")}
disabled={actionLoading}
>
<IconX width={14} height={14} /> Reject changes
</Button>
</>
) : undefined
}
>
<RevisionDiffViewer livePost={post} revision={revision} />
</RevisionPanel>
)}

<BlogEditor
initialData={{
title: post.title,
content: post.content,
excerpt: post.excerpt,
coverImage: post.coverImage,
coverFocalPoint: post.coverFocalPoint,
tags: post.tags,
status: post.status,
authors: post.authors || [],
}}
key={`admin-live-${post.updatedAt}`}
initialData={liveEditorData}
onSave={handleSave}
/>

{reviewAction && (
<ConfirmDialog
title={
reviewAction === "approve"
? "Publish this revision?"
: "Reject this revision?"
}
description={
reviewAction === "approve"
? "The proposed fields will replace the live article immediately."
: "The proposed changes will be permanently discarded. The live article will not change."
}
confirmLabel={
reviewAction === "approve" ? "Approve & publish" : "Reject changes"
}
busyLabel={reviewAction === "approve" ? "Publishing…" : "Rejecting…"}
variant={reviewAction === "approve" ? "primary" : "danger"}
busy={actionLoading}
onCancel={() => setReviewAction(null)}
onConfirm={() => void submitReviewAction(reviewAction)}
/>
)}
</div>
);
}
Loading
Loading