Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 16 additions & 34 deletions app/(admin)/admin/moderation/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,39 +40,21 @@ const reasonLabels: Record<ReportReason, string> = {
const chipBase =
"rounded-full px-2 py-0.5 font-mono text-xs uppercase tracking-label";

type PreviewablePost = {
type: string | null;
slug: string | null;
externalUrl: string | null;
authorUsername: string | null;
};

// Where to send a moderator to actually read the thing they're judging.
// Discussions and questions live under /d/; a shared link IS its destination,
// so it points off-site; everything else renders at /{username}/{slug}, where
// the reader grants admins the same bypass the author has — so an in_review
// post previews exactly as readers would eventually see it.
function postPreviewHref(post: PreviewablePost): string | null {
if (post.type === "link") return post.externalUrl;
if (!post.slug) return null;
if (post.type === "discussion" || post.type === "question") {
return `/d/${post.slug}`;
}
if (!post.authorUsername) return null;
return `/${post.authorUsername}/${post.slug}`;
}

const PreviewLink = ({ post }: { post: PreviewablePost }) => {
const href = postPreviewHref(post);
if (!href) return null;

return (
<Link href={href} target="_blank" className="secondary-button">
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
Preview
</Link>
);
};
// Read the submission before deciding on it. The preview is an admin-side,
// read-only render (see app/(admin)/admin/moderation/preview/[postId]) rather
// than the public URL: an unapproved post has no public URL yet, and the public
// reader would put vote/bookmark/comment controls on a post that may be about
// to be rejected. Keyed by id, so it is available for every queued post.
const PreviewLink = ({ postId }: { postId: string }) => (
<Link
href={`/admin/moderation/preview/${postId}`}
target="_blank"
className="secondary-button"
>
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
Preview
</Link>
);

// datetime-local is in the moderator's LOCAL time, so shift the `min` boundary
// by the tz offset before slicing to "YYYY-MM-DDTHH:mm".
Expand Down Expand Up @@ -310,7 +292,7 @@ const ModerationQueue = () => {
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<PreviewLink post={post} />
<PreviewLink postId={post.id} />
<button
className="primary-button"
disabled={isModerating}
Expand Down
178 changes: 178 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { and, eq } from "drizzle-orm";
import z from "zod";
import { db } from "@/server/db";
import { posts, user, post_tags, tag } from "@/server/db/schema";
import { PostBody, renderPostBody } from "@/components/ContentDetail/PostBody";
import { getCamelCaseFromLower } from "@/utils/utils";
import { safeExternalHref } from "@/utils/url";
import { moderationPreviewFilter } from "@/server/lib/postVisibility";

export const metadata = {
title: "Preview - Codú Admin",
description: "Read a submission before approving or declining it",
robots: { index: false, follow: false },
};

type Props = { params: Promise<{ postId: string }> };

// Read-only preview of a submission, for deciding whether it belongs on the
// site. It deliberately lives inside `(admin)` rather than exposing unpublished
// posts on the public reader routes: moderators need to READ a post, not vote,
// bookmark or comment on one that may be about to be rejected — and admins
// should still see the public site exactly as readers do.
//
// The body renders through the same `PostBody` the reader uses, so what a
// moderator approves is what readers will get.
//
// Admin-role gate is enforced in app/(admin)/layout.tsx.
export default async function Page({ params }: Props) {
const { postId } = await params;

// posts.id is a uuid column, so a mistyped or truncated id would make
// Postgres throw a cast error (a 500) before the not-found check below.
if (!z.string().uuid().safeParse(postId).success) notFound();

const [rows, tags] = await Promise.all([
db
.select({
id: posts.id,
title: posts.title,
body: posts.body,
excerpt: posts.excerpt,
type: posts.type,
status: posts.status,
externalUrl: posts.externalUrl,
coverImage: posts.coverImage,
readingTime: posts.readingTime,
moderationNote: posts.moderationNote,
authorUsername: user.username,
})
.from(posts)
.leftJoin(user, eq(posts.authorId, user.id))
// Submitted work only. A moderator has business reading anything that
// entered the pipeline; a private draft is not that.
.where(and(eq(posts.id, postId), moderationPreviewFilter()))
.limit(1),
db
.select({ title: tag.title, slug: tag.slug })
.from(post_tags)
.innerJoin(tag, eq(post_tags.tagId, tag.id))
.where(eq(post_tags.postId, postId)),
]);

const record = rows[0];
if (!record) notFound();

const renderedBody = renderPostBody(record.body);
const externalHref = safeExternalHref(record.externalUrl);
// Member-supplied, like externalUrl — same scheme guard applies.
const coverHref = safeExternalHref(record.coverImage);

return (
<div className="mx-auto max-w-3xl px-0 py-4 sm:px-4 sm:py-8">
<div className="mb-6 flex items-center gap-4">
<Link
href="/admin/moderation"
className="rounded-lg p-2 text-muted transition-colors hover:bg-elevated hover:text-fg"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
<div className="min-w-0">
<p className="eyebrow">
<span className="slash">{"// "}</span>preview
</p>
<h1 className="mt-1 font-display text-2xl font-extrabold tracking-tight text-fg">
{record.title || "Untitled"}
</h1>
<p className="mt-1 font-mono text-xs text-faint">
{record.type} · {record.status} · @
{record.authorUsername ?? "unknown"}
{record.readingTime ? ` · ${record.readingTime} min read` : ""}
</p>
</div>
</div>

{record.moderationNote && (
<p className="mb-6 rounded-lg border border-hairline bg-inset p-3 text-sm text-muted">
<span className="font-medium text-fg">Flagged:</span>{" "}
{record.moderationNote}
</p>
)}

{record.excerpt && (
<p className="mb-6 text-base text-muted">{record.excerpt}</p>
)}

{/* The cover image is the most visible part of a post on feed and profile
cards, so a moderator has to see it before approving — clean body copy
under an abusive image would otherwise sail through. */}
{coverHref && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverHref}
alt=""
className="mb-6 max-h-80 w-full rounded-lg border border-hairline object-cover"
/>
)}
Comment on lines +109 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Provide alternative text for the cover image.

alt="" marks the image as decorative, but the surrounding comment states that moderators must review the cover image before approval. Screen-reader users will not receive any information about this required content. Use stored image alternative text, or at least expose a non-empty label until that metadata is available.

Proposed fix
         <img
           src={coverHref}
-          alt=""
+          alt="Cover image"
           className="mb-6 max-h-80 w-full rounded-lg border border-hairline object-cover"
         />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{/* The cover image is the most visible part of a post on feed and profile
cards, so a moderator has to see it before approving clean body copy
under an abusive image would otherwise sail through. */}
{coverHref && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverHref}
alt=""
className="mb-6 max-h-80 w-full rounded-lg border border-hairline object-cover"
/>
)}
{/* The cover image is the most visible part of a post on feed and profile
cards, so a moderator has to see it before approving clean body copy
under an abusive image would otherwise sail through. */}
{coverHref && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverHref}
alt="Cover image"
className="mb-6 max-h-80 w-full rounded-lg border border-hairline object-cover"
/>
)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(admin)/admin/moderation/preview/[postId]/page.tsx around lines 109 -
119, The cover image rendered in the preview must have meaningful alternative
text instead of the empty alt value. Update the image in the coverHref rendering
block to use the stored image alt-text field when available, with a non-empty
fallback label until metadata is available.


{/* A link submission is judged on both halves: the member's own framing
above, and the destination. rel/noreferrer keep the admin surface out
of the referrer of a page that is under review precisely because it
may be hostile. */}
{record.type === "link" &&
(externalHref ? (
<p className="mb-6 break-all font-mono text-sm">
<span className="text-faint">{"// destination "}</span>
<a
href={externalHref}
target="_blank"
rel="noopener noreferrer nofollow"
className="text-accent underline"
>
{externalHref}
</a>
</p>
) : (
<p className="mb-6 font-mono text-sm text-danger">
{"// destination missing or not a http(s) URL: "}
{record.externalUrl ?? "none"}
</p>
))}

{tags.length > 0 && (
<div className="mb-6 flex flex-wrap gap-2">
{tags.map((t) => (
<span
key={t.title}
className="rounded-sm border border-hairline px-2.5 py-0.5 font-mono text-xs text-muted"
>
{getCamelCaseFromLower(t.title)}
</span>
))}
</div>
)}

{record.body ? (
<article className="prose max-w-none dark:prose-invert">
<PostBody
{...renderedBody}
emptyFallback={
<p className="font-mono text-sm text-faint">
{"// body is empty"}
</p>
}
/>
</article>
) : (
<p className="font-mono text-sm text-faint">{"// no body submitted"}</p>
)}

<p className="mt-8 font-mono text-xs text-faint">
{"// read-only — approve or decline from the queue"}
</p>
</div>
);
}
17 changes: 3 additions & 14 deletions app/(app)/[username]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ async function getUserPostUncached(
username: string,
postSlug: string,
viewerId?: string | null,
viewerIsAdmin = false,
) {
// Case-insensitive handle resolution (GitHub-style), matching the profile page.
const userRecord = await db.query.user.findFirst({
Expand All @@ -42,7 +41,7 @@ async function getUserPostUncached(

if (!userRecord) return null;

const visibilityFilter = postVisibilityFilter({ viewerId, viewerIsAdmin });
const visibilityFilter = postVisibilityFilter({ viewerId });

const postResults = await db
.select({
Expand Down Expand Up @@ -363,12 +362,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {

// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
const userPost = await getUserPost(
username,
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const userPost = await getUserPost(username, slug, session?.user?.id);
if (userPost) {
// Discussions/questions canonicalize to /d/{slug}; redirect before metadata.
if (isDiscussionKind(userPost.type)) {
Expand Down Expand Up @@ -525,12 +519,7 @@ const UnifiedPostPage = async (props: Props) => {

const host = (await headers()).get("host") || "";

const userPost = await getUserPost(
username,
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const userPost = await getUserPost(username, slug, session?.user?.id);

if (userPost) {
// Discussions/questions live under /d/{slug} — redirect before rendering.
Expand Down
15 changes: 3 additions & 12 deletions app/(app)/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ type Props = { params: Promise<{ slug: string }> };
async function getDiscussionPostUncached(
slug: string,
viewerId?: string | null,
viewerIsAdmin = false,
): Promise<ReaderPost | null> {
const urlId = parseUrlId(slug);
if (!urlId) return null;
Expand Down Expand Up @@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand Down Expand Up @@ -147,11 +146,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
const { slug } = await props.params;
// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
const post = await getDiscussionPost(
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const post = await getDiscussionPost(slug, session?.user?.id);

if (!post) {
return { title: "Discussion Not Found" };
Expand Down Expand Up @@ -201,11 +196,7 @@ const DiscussionPage = async (props: Props) => {
const { slug } = await props.params;
const session = await getServerAuthSession();

const post = await getDiscussionPost(
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const post = await getDiscussionPost(slug, session?.user?.id);

if (!post) return notFound();

Expand Down
Loading
Loading