Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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"
/>
)}
{/*Thecoverimageisthemostvisiblepartofapostonfeedandprofile
cards,soamoderatorhastoseeitbeforeapprovingcleanbodycopy
underanabusiveimagewouldotherwisesailthrough.*/}
{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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand Up@@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand DownExpand 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 DownExpand 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