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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, '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
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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, '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
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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, '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
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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, '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
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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, '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
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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, '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
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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, '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
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 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
150 changes: 150 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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