Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 16 additions & 34 deletions app/(admin)/admin/moderation/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,39 +40,21 @@ const reasonLabels: Record<ReportReason, string> = {
const chipBase =
"rounded-full px-2 py-0.5 font-mono text-xs uppercase tracking-label";

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

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

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

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

// datetime-local is in the moderator's LOCAL time, so shift the `min` boundary
// by the tz offset before slicing to "YYYY-MM-DDTHH:mm".
Expand Down Expand Up @@ -310,7 +292,7 @@ const ModerationQueue = () => {
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<PreviewLink post={post} />
<PreviewLink postId={post.id} />
<button
className="primary-button"
disabled={isModerating}
Expand Down
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { eq } from "drizzle-orm";
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";

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;

const [record] = await db
.select({
id: posts.id,
title: posts.title,
body: posts.body,
excerpt: posts.excerpt,
type: posts.type,
status: posts.status,
slug: posts.slug,
externalUrl: posts.externalUrl,
coverImage: posts.coverImage,
readingTime: posts.readingTime,
createdAt: posts.createdAt,
moderationNote: posts.moderationNote,
authorName: user.name,
authorUsername: user.username,
})
.from(posts)
.leftJoin(user, eq(posts.authorId, user.id))
.where(eq(posts.id, postId))
.limit(1);

if (!record) notFound();

const renderedBody = renderPostBody(record.body);
const externalHref = safeExternalHref(record.externalUrl);

const tags = await db
.select({ title: tag.title, slug: tag.slug })
.from(post_tags)
.innerJoin(tag, eq(post_tags.tagId, tag.id))
.where(eq(post_tags.postId, record.id));

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>
)}

{/* 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} />
</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>
);
}
35 changes: 19 additions & 16 deletions app/(app)/[username]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import { type Metadata } from "next";
import { SITE_ORIGIN } from "@/config/site";
import { db } from "@/server/db";
import { posts, user, feed_sources, post_tags, tag } from "@/server/db/schema";
import { eq, and, lte, inArray, sql } from "drizzle-orm";
import { eq, and, lte, inArray, or, sql } from "drizzle-orm";
import UserLinkDetail from "./_userLinkDetail";
import PostReader from "@/components/ContentDetail/PostReader";
import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
import { postVisibilityFilter } from "@/server/lib/postVisibility";
import { serverApi } from "@/server/trpc/caller";
import { JsonLd } from "@/components/JsonLd";
import { getArticleSchema, getBreadcrumbSchema } from "@/lib/structured-data";
Expand All @@ -32,7 +31,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 +40,22 @@ async function getUserPostUncached(

if (!userRecord) return null;

const visibilityFilter = postVisibilityFilter({ viewerId, viewerIsAdmin });
// Owner bypass: the author may view their own in_review/rejected post;
// everyone else only sees published posts whose publish time has passed.
const isAuthor = !!viewerId && viewerId === userRecord.id;

const visibilityFilter = isAuthor
? or(
and(
eq(posts.status, "published"),
lte(posts.publishedAt, new Date().toISOString()),
),
inArray(posts.status, ["in_review", "rejected"]),
)
: and(
eq(posts.status, "published"),
lte(posts.publishedAt, new Date().toISOString()),
);

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

// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
const userPost = await getUserPost(
username,
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const userPost = await getUserPost(username, slug, session?.user?.id);
if (userPost) {
// Discussions/questions canonicalize to /d/{slug}; redirect before metadata.
if (isDiscussionKind(userPost.type)) {
Expand Down Expand Up @@ -525,12 +533,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
33 changes: 19 additions & 14 deletions app/(app)/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ import { ogPostImage } from "@/lib/og/url";
import { getServerAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import { posts, user, post_tags, tag, comments } from "@/server/db/schema";
import { eq, and, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
import { eq, and, lte, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
import PostReader, {
type ReaderPost,
} from "@/components/ContentDetail/PostReader";
import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
import { postVisibilityFilter } from "@/server/lib/postVisibility";
import { JsonLd } from "@/components/JsonLd";
import {
getDiscussionForumPostingSchema,
Expand All @@ -27,7 +26,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 All @@ -39,6 +37,13 @@ async function getDiscussionPostUncached(
? eq(posts.urlId, urlId)
: or(eq(posts.urlId, urlId), eq(posts.slug, slug))!;

const publicFilter = and(
eq(posts.status, "published"),
lte(posts.publishedAt, new Date().toISOString()),
);

// Owner bypass: the author may view their own in_review/rejected discussion;
// everyone else only sees published.
const [row] = await db
.select({
id: posts.id,
Expand Down Expand Up @@ -68,7 +73,15 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
viewerId
? or(
publicFilter,
and(
eq(posts.authorId, viewerId),
inArray(posts.status, ["in_review", "rejected"]),
),
)
: publicFilter,
),
)
.limit(1);
Expand Down Expand Up @@ -147,11 +160,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
const { slug } = await props.params;
// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
const post = await getDiscussionPost(
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const post = await getDiscussionPost(slug, session?.user?.id);

if (!post) {
return { title: "Discussion Not Found" };
Expand Down Expand Up @@ -201,11 +210,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