diff --git a/.gitignore b/.gitignore index 2951c3a52..104a24b50 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ yarn-error.log* # local env files .env* !.env.example +env.aws # vercel .vercel @@ -71,3 +72,7 @@ ssmSetup.zsh # Snyk Security Extension - AI Rules (auto-generated) .github/instructions/snyk_rules.instructions.md + +# Local-only deploy notes (never commit) +local.md +logs/ diff --git a/README.md b/README.md index a366c0884..c97175cc4 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ ![Codu Logo](https://raw.githubusercontent.com/codu-code/codu/develop/public/images/codu-gradient.png) -> A space for coders +> A community for AI builders and indie hackers -Codú is the ultimate community of web developers to learn, share, and get support for your projects, either big or small. It is the perfect place to sharpen your skills and build your portfolio. In Codú, we're all here to help each other to grow as web developers. Plus, Codú makes it easier to find collaborators for your next big project. +Codú is a community for AI builders and indie hackers. Share what you're building, learn from people shipping real projects, and get support — whether it's a weekend experiment or a product you're taking to market. Write articles, post TILs, start discussions, ask questions, and find people to build with. --- @@ -53,6 +53,14 @@ npm run db:migrate The full command can be seen in our [package.json](/package.json#16) file. +> Deploy note: the Vercel Build Command runs `npm run db:migrate && npm run ci-build` +> on **every** target, so **both preview and production builds migrate** — preview +> against the shared dev database, production against prod. A migration that fails +> aborts the build, so a branch carrying a broken or environment-incompatible +> migration turns the Vercel check red. Connections require SSL +> (`sslmode=require` in `DATABASE_URL`) because the RDS instances run with +> `rds.force_ssl` on; local dev and e2e use Docker over localhost and need none. + 7. Seed the database with some mock data by running: ```bash @@ -69,17 +77,15 @@ npm run dev After completion of the above commands, navigate to [http://localhost:3000](http://localhost:3000) in your browser to see the result. -You can start your journey by modifying `pages/index.tsx`. With the auto-update feature, pages update as you edit the file. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. +The app uses the Next.js App Router — routes live in the `app/` directory and hot-reload as you edit. The home feed is served from `app/(app)/page.tsx`. -Learn more about API routes [here](https://nextjs.org/docs/api-routes/introduction). +API endpoints are [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) under `app/api/*`, alongside the tRPC routers in `server/`. ## Environment Variables ### DATABASE_URL -The `DATABASE_URL` is a connection string to a PostgreSQL database (version 15.0). +The `DATABASE_URL` is a connection string to a PostgreSQL database (version 15). By default, we point to a database running locally with Docker from our `docker-compose.yml` file. @@ -89,6 +95,12 @@ Run the command `docker compose up`. Alternatively, if you have PostgreSQL running locally, you can use your local connection string or grab one from a free service like [Supabase](https://supabase.com/docs/guides/database/connecting-to-postgres#finding-your-connection-string). +### Local email (Mailpit) + +`docker compose up` also starts [Mailpit](https://mailpit.axllent.org/), a local email catcher. Set `EMAIL_PROVIDER=local` in your `.env` and every outgoing email (magic-link sign-in, moderation/report notifications) is captured at [http://localhost:8027](http://localhost:8027) instead of being sent through SES — no AWS credentials or real inboxes needed in development. + +The email E2E spec (`e2e/email.spec.ts`) asserts delivery through Mailpit's API; it skips automatically when Mailpit isn't running. To include it: `EMAIL_PROVIDER=local npm run dev:e2e` (server) and `EMAIL_PROVIDER=local npm test` (tests). + ### GITHUB_ID and GITHUB_SECRET Currently, we only allow authentication via GitHub. To enable this, you need to have a `GITHUB_ID` and `GITHUB_SECRET` value. diff --git a/app/(app)/(tsandcs)/layout.tsx b/app/(app)/(tsandcs)/layout.tsx index 07c4b0769..44eb3a7c9 100644 --- a/app/(app)/(tsandcs)/layout.tsx +++ b/app/(app)/(tsandcs)/layout.tsx @@ -4,8 +4,10 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( -
- {children} +
+
+ {children} +
); } diff --git a/app/(app)/[username]/[slug]/_feedArticleContent.tsx b/app/(app)/[username]/[slug]/_feedArticleContent.tsx deleted file mode 100644 index dbe9e19ac..000000000 --- a/app/(app)/[username]/[slug]/_feedArticleContent.tsx +++ /dev/null @@ -1,439 +0,0 @@ -"use client"; - -import Link from "next/link"; -import * as Sentry from "@sentry/nextjs"; -import { - ArrowTopRightOnSquareIcon, - BookmarkIcon, - ChatBubbleLeftIcon, - ChevronUpIcon, - ChevronDownIcon, - ShareIcon, -} from "@heroicons/react/20/solid"; -import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; -import { api } from "@/server/trpc/react"; -import { signIn, useSession } from "next-auth/react"; -import { toast } from "sonner"; -import { Temporal } from "@js-temporal/polyfill"; -import DiscussionArea from "@/components/Discussion/DiscussionArea"; - -type Props = { - sourceSlug: string; - articleSlug: string; -}; - -// Get favicon URL from a website -const getFaviconUrl = ( - websiteUrl: string | null | undefined, -): string | null => { - if (!websiteUrl) return null; - try { - const url = new URL(websiteUrl); - return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; - } catch { - return null; - } -}; - -// Get hostname from URL -const getHostname = (urlString: string): string => { - try { - const url = new URL(urlString); - return url.hostname; - } catch { - return urlString; - } -}; - -// Ensure image URL uses https -const ensureHttps = (url: string | null | undefined): string | null => { - if (!url) return null; - if (url.startsWith("http://")) { - return url.replace("http://", "https://"); - } - return url; -}; - -const FeedArticleContent = ({ sourceSlug, articleSlug }: Props) => { - const { data: session } = useSession(); - const utils = api.useUtils(); - - const { data: article, status } = api.feed.getBySourceAndArticleSlug.useQuery( - { - sourceSlug, - articleSlug, - }, - ); - - const { data: discussionCount } = - api.discussion.getContentDiscussionCount.useQuery( - { contentId: article?.id ?? "" }, - { enabled: !!article?.id }, - ); - - const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({ - onSuccess: () => { - utils.feed.getBySourceAndArticleSlug.invalidate({ - sourceSlug, - articleSlug, - }); - utils.content.getFeed.invalidate(); - }, - onError: (error) => { - toast.error("Failed to update vote"); - Sentry.captureException(error); - }, - }); - - const { mutate: bookmark, status: bookmarkStatus } = - api.feed.bookmark.useMutation({ - onSuccess: () => { - utils.feed.getBySourceAndArticleSlug.invalidate({ - sourceSlug, - articleSlug, - }); - utils.feed.getFeed.invalidate(); - utils.feed.mySavedArticles.invalidate(); - }, - onError: (error) => { - toast.error("Failed to update bookmark"); - Sentry.captureException(error); - }, - }); - - const { mutate: trackClick } = api.feed.trackClick.useMutation(); - - const handleVote = (voteType: "up" | "down" | null) => { - if (!session) { - signIn(); - return; - } - if (article) { - vote({ contentId: article.id, voteType }); - } - }; - - const handleBookmark = () => { - if (!session) { - signIn(); - return; - } - if (article) { - bookmark({ articleId: article.id, setBookmarked: !article.isBookmarked }); - } - }; - - const handleShare = async () => { - const shareUrl = `${window.location.origin}/${sourceSlug}/${articleSlug}`; - try { - await navigator.clipboard.writeText(shareUrl); - toast.success("Link copied to clipboard"); - } catch { - toast.error("Failed to copy link"); - } - }; - - const handleExternalClick = () => { - if (article) { - trackClick({ articleId: article.id }); - } - }; - - if (status === "pending") { - return ( -
-
-
-
-
-
-
-
-
-
- ); - } - - if (status === "error" || !article) { - return ( -
- - Back to Feed - -
-

- Post Not Found -

-

- This post may have been removed or the link is invalid. -

-
-
- ); - } - - const dateTime = article.publishedAt - ? Temporal.Instant.from(new Date(article.publishedAt).toISOString()) - : null; - const readableDate = dateTime - ? dateTime.toLocaleString(["en-IE"], { - year: "numeric", - month: "long", - day: "numeric", - }) - : null; - - const faviconUrl = getFaviconUrl( - article.source?.websiteUrl || article.externalUrl, - ); - const hostname = article.externalUrl - ? getHostname(article.externalUrl) - : null; - const score = article.upvotes - article.downvotes; - - return ( -
- {/* Breadcrumb */} - - - {/* Article card */} -
- {/* Source info */} -
- - {article.source?.logoUrl ? ( - - ) : faviconUrl ? ( - - ) : ( -
- {article.source?.name?.charAt(0).toUpperCase() || "?"} -
- )} - - {article.source?.name || "Unknown Source"} - - - {article.sourceAuthor && - article.sourceAuthor.trim() && - !["by", "by,", "by ,"].includes( - article.sourceAuthor.trim().toLowerCase(), - ) && ( - <> - - - {article.sourceAuthor.replace(/^by\s+/i, "").trim()} - - - )} - {readableDate && ( - <> - - - - )} -
- - {/* Title */} -

- {article.title} -

- - {/* Excerpt */} - {article.excerpt && ( -

- {article.excerpt} -

- )} - - {/* Thumbnail image */} - {ensureHttps(article.imageUrl) && article.externalUrl && ( - - -
- - {hostname} -
-
- )} - - {/* Read article CTA */} - {article.externalUrl && ( - - - Read Full Article at {hostname} - - )} - - {/* Inline source info - styled like author bio */} - {article.source && ( -
- - {article.source.logoUrl ? ( - - ) : faviconUrl ? ( - - ) : ( -
- {article.source.name?.charAt(0).toUpperCase() || "?"} -
- )} - -
-
- - {article.source.name} - - - @{sourceSlug} - -
- {article.source.description && ( -

- {article.source.description} -

- )} -
-
- )} - - {/* Action bar - just above discussion */} -
- {/* Vote buttons */} -
- - 0 - ? "text-green-500" - : score < 0 - ? "text-red-500" - : "text-neutral-500 dark:text-neutral-400" - }`} - > - {score} - - -
- - {/* Comments count */} - - - {discussionCount ?? 0} comments - - - {/* Save button */} - - - {/* Share button */} - -
- - {/* Discussion section - inside the card */} -
- -
-
-
- ); -}; - -export default FeedArticleContent; diff --git a/app/(app)/[username]/[slug]/_linkContentDetail.tsx b/app/(app)/[username]/[slug]/_linkContentDetail.tsx deleted file mode 100644 index f24a3bba5..000000000 --- a/app/(app)/[username]/[slug]/_linkContentDetail.tsx +++ /dev/null @@ -1,413 +0,0 @@ -"use client"; - -import { useState, useMemo } from "react"; -import Link from "next/link"; -import { - ArrowTopRightOnSquareIcon, - ChatBubbleLeftIcon, - ChevronUpIcon, - ChevronDownIcon, - ShareIcon, -} from "@heroicons/react/20/solid"; -import { api } from "@/server/trpc/react"; -import { toast } from "sonner"; -import { Temporal } from "@js-temporal/polyfill"; -import DiscussionArea from "@/components/Discussion/DiscussionArea"; -import { useSession, signIn } from "next-auth/react"; - -type Props = { - sourceSlug: string; - contentSlug: string; -}; - -// Get favicon URL from a website -const getFaviconUrl = ( - websiteUrl: string | null | undefined, -): string | null => { - if (!websiteUrl) return null; - try { - const url = new URL(websiteUrl); - return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; - } catch { - return null; - } -}; - -// Get hostname from URL -const getHostname = (urlString: string): string => { - try { - const url = new URL(urlString); - return url.hostname; - } catch { - return urlString; - } -}; - -// Ensure image URL uses https -const ensureHttps = (url: string | null | undefined): string | null => { - if (!url) return null; - if (url.startsWith("http://")) { - return url.replace("http://", "https://"); - } - return url; -}; - -const LinkContentDetail = ({ sourceSlug, contentSlug }: Props) => { - const { data: session } = useSession(); - const { data: linkContent, status } = - api.feed.getLinkContentBySourceAndSlug.useQuery({ - sourceSlug, - contentSlug, - }); - - const { data: discussionCount } = - api.discussion.getContentDiscussionCount.useQuery( - { contentId: linkContent?.id ?? "" }, - { enabled: !!linkContent?.id }, - ); - - // Vote state management - derive initial values from query data - const initialVoteState = useMemo( - () => ({ - userVote: linkContent?.userVote ?? null, - upvotes: linkContent?.upvotes ?? 0, - downvotes: linkContent?.downvotes ?? 0, - }), - [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes], - ); - - const [userVote, setUserVote] = useState<"up" | "down" | null>( - initialVoteState.userVote, - ); - const [votes, setVotes] = useState({ - upvotes: initialVoteState.upvotes, - downvotes: initialVoteState.downvotes, - }); - - // Sync state when server data changes (e.g., after mutation invalidation) - const currentUserVote = linkContent?.userVote ?? null; - const currentUpvotes = linkContent?.upvotes ?? 0; - const currentDownvotes = linkContent?.downvotes ?? 0; - - // Use refs to track if we need to sync - const serverVoteKey = `${currentUserVote}-${currentUpvotes}-${currentDownvotes}`; - const [lastSyncedKey, setLastSyncedKey] = useState(serverVoteKey); - - if (serverVoteKey !== lastSyncedKey && linkContent) { - setUserVote(currentUserVote); - setVotes({ upvotes: currentUpvotes, downvotes: currentDownvotes }); - setLastSyncedKey(serverVoteKey); - } - - const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({ - onMutate: async ({ voteType }) => { - const oldVote = userVote; - setUserVote(voteType); - setVotes((prev) => { - let newUpvotes = prev.upvotes; - let newDownvotes = prev.downvotes; - if (oldVote === "up") newUpvotes--; - if (oldVote === "down") newDownvotes--; - if (voteType === "up") newUpvotes++; - if (voteType === "down") newDownvotes++; - return { upvotes: newUpvotes, downvotes: newDownvotes }; - }); - }, - onError: () => { - setUserVote(linkContent?.userVote ?? null); - setVotes({ - upvotes: linkContent?.upvotes ?? 0, - downvotes: linkContent?.downvotes ?? 0, - }); - toast.error("Failed to update vote"); - }, - }); - - const handleVote = (voteType: "up" | "down" | null) => { - if (!session) { - signIn(); - return; - } - if (!linkContent) return; - vote({ contentId: linkContent.id, voteType }); - }; - - const handleShare = async () => { - const shareUrl = `${window.location.origin}/${sourceSlug}/${contentSlug}`; - try { - await navigator.clipboard.writeText(shareUrl); - toast.success("Link copied to clipboard"); - } catch { - toast.error("Failed to copy link"); - } - }; - - if (status === "pending") { - return ( -
-
-
-
-
-
-
-
-
-
- ); - } - - if (status === "error" || !linkContent) { - return ( -
- - Back to Feed - -
-

- Content Not Found -

-

- This link may have been removed or the URL is invalid. -

-
-
- ); - } - - const externalUrl = linkContent.externalUrl || ""; - const dateTime = linkContent.publishedAt - ? Temporal.Instant.from(new Date(linkContent.publishedAt).toISOString()) - : null; - const readableDate = dateTime - ? dateTime.toLocaleString(["en-IE"], { - year: "numeric", - month: "long", - day: "numeric", - }) - : null; - - const faviconUrl = getFaviconUrl( - linkContent.source?.websiteUrl || externalUrl, - ); - const hostname = externalUrl ? getHostname(externalUrl) : null; - const score = votes.upvotes - votes.downvotes; - - return ( -
- {/* Breadcrumb */} - - - {/* Content card */} -
- {/* Source info */} -
- - {linkContent.source?.logoUrl ? ( - - ) : faviconUrl ? ( - - ) : ( -
- {linkContent.source?.name?.charAt(0).toUpperCase() || "?"} -
- )} - - {linkContent.source?.name || "Unknown Source"} - - - {linkContent.sourceAuthor && linkContent.sourceAuthor.trim() && ( - <> - - {linkContent.sourceAuthor} - - )} - {readableDate && ( - <> - - - - )} -
- - {/* Title */} -

- {linkContent.title} -

- - {/* Excerpt */} - {linkContent.excerpt && ( -

- {linkContent.excerpt} -

- )} - - {/* Thumbnail image */} - {ensureHttps(linkContent.imageUrl) && externalUrl && ( - - - {hostname && ( -
- - {hostname} -
- )} -
- )} - - {/* Visit link CTA */} - {externalUrl && hostname && ( - - - Visit Link at {hostname} - - )} - - {/* Inline source info - styled like author bio */} - {linkContent.source && ( -
- - {linkContent.source.logoUrl ? ( - - ) : faviconUrl ? ( - - ) : ( -
- {linkContent.source.name?.charAt(0).toUpperCase() || "?"} -
- )} - -
-
- - {linkContent.source.name} - - - @{sourceSlug} - -
- {linkContent.source.description && ( -

- {linkContent.source.description} -

- )} -
-
- )} - - {/* Action bar - just above discussion */} -
- {/* Vote buttons */} -
- - 0 - ? "text-green-500" - : score < 0 - ? "text-red-500" - : "text-neutral-400 dark:text-neutral-500" - }`} - > - {score} - - -
- - {/* Comments count */} - - - {discussionCount ?? 0} comments - - - {/* Share button */} - -
- - {/* Discussion section */} -
- -
-
-
- ); -}; - -export default LinkContentDetail; diff --git a/app/(app)/[username]/[slug]/_userLinkDetail.tsx b/app/(app)/[username]/[slug]/_userLinkDetail.tsx index 7d1ecc1e7..3aa9ad3f0 100644 --- a/app/(app)/[username]/[slug]/_userLinkDetail.tsx +++ b/app/(app)/[username]/[slug]/_userLinkDetail.tsx @@ -10,42 +10,31 @@ import { ShareIcon, } from "@heroicons/react/20/solid"; import { api } from "@/server/trpc/react"; +import { type RouterOutputs } from "@/server/trpc/shared"; import { toast } from "sonner"; import { Temporal } from "@js-temporal/polyfill"; import DiscussionArea from "@/components/Discussion/DiscussionArea"; +import { ensureHttps, getHostname, safeExternalHref } from "@/utils/url"; import { useSession, signIn } from "next-auth/react"; import { InlineAuthorBio } from "@/components/ContentDetail"; +import { FollowButton } from "@/components/ds"; type Props = { username: string; contentSlug: string; + /** Server-fetched content so the body renders in the crawlable HTML. */ + initialContent?: RouterOutputs["content"]["getUserLinkBySlug"] | null; }; -// Get hostname from URL -const getHostname = (urlString: string): string => { - try { - const url = new URL(urlString); - return url.hostname; - } catch { - return urlString; - } -}; - -// Ensure image URL uses https -const ensureHttps = (url: string | null | undefined): string | null => { - if (!url) return null; - if (url.startsWith("http://")) { - return url.replace("http://", "https://"); - } - return url; -}; - -const UserLinkDetail = ({ username, contentSlug }: Props) => { +const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => { const { data: session } = useSession(); - const { data: linkContent, status } = api.content.getUserLinkBySlug.useQuery({ - username, - slug: contentSlug, - }); + const { data: linkContent, status } = api.content.getUserLinkBySlug.useQuery( + { + username, + slug: contentSlug, + }, + initialContent ? { initialData: initialContent } : undefined, + ); const { data: discussionCount } = api.discussion.getContentDiscussionCount.useQuery( @@ -130,14 +119,14 @@ const UserLinkDetail = ({ username, contentSlug }: Props) => { if (status === "pending") { return ( -
+
-
-
-
-
-
-
+
+
+
+
+
+
); @@ -145,18 +134,18 @@ const UserLinkDetail = ({ username, contentSlug }: Props) => { if (status === "error" || !linkContent) { return ( -
+
- Back to Feed + ‹ Back to feed -
-

+
+

Content Not Found

-

+

This link may have been removed or the URL is invalid.

@@ -165,6 +154,8 @@ const UserLinkDetail = ({ username, contentSlug }: Props) => { } const externalUrl = linkContent.externalUrl || ""; + // Guard against javascript:/data: schemes — z.string().url() accepts them. + const safeExternalUrl = safeExternalHref(linkContent.externalUrl); const dateTime = linkContent.publishedAt ? Temporal.Instant.from(new Date(linkContent.publishedAt).toISOString()) : null; @@ -179,196 +170,181 @@ const UserLinkDetail = ({ username, contentSlug }: Props) => { const hostname = externalUrl ? getHostname(externalUrl) : null; const score = votes.upvotes - votes.downvotes; + const isOwner = session?.user?.id === linkContent.author?.id; + return ( -
- {/* Breadcrumb */} - +
+ + ‹ Back to feed + - {/* Content card */} -
- {/* Author info */} -
- - {linkContent.author?.image ? ( - - ) : ( -
- {linkContent.author?.name?.charAt(0).toUpperCase() || "?"} -
- )} - - {linkContent.author?.name || "Unknown"} - - - {readableDate && ( - <> - - - - )} - {hostname && ( - <> - - {hostname} - - )} -
+

+ {"// "} + Link + {readableDate ? ` · ${readableDate}` : ""} +

- {/* Title */} -

- {linkContent.title} -

+

+ {linkContent.title} +

- {/* Excerpt */} - {linkContent.excerpt && ( -

- {linkContent.excerpt} -

- )} + {linkContent.excerpt && ( +

+ {linkContent.excerpt} +

+ )} - {/* Thumbnail image */} - {ensureHttps(linkContent.coverImage) && externalUrl && ( - + + )} + +
+ - - Visit Link at {hostname} - - )} - - {/* Inline author bio */} - {linkContent.author && ( -
- + {linkContent.author?.name || "Unknown"} + +
+ @{linkContent.author?.username || username} + {hostname ? ` · ${hostname}` : ""}
+
+ {session && !isOwner && linkContent.author?.id && ( + )} +
- {/* Action bar - just above discussion */} -
- {/* Vote buttons */} -
- - 0 - ? "text-green-500" - : score < 0 - ? "text-red-500" - : "text-neutral-400 dark:text-neutral-500" - }`} - > - {score} - - -
+ {ensureHttps(linkContent.coverImage) && safeExternalUrl ? ( + + + {hostname && ( +
+ + {hostname} +
+ )} +
+ ) : ( +
+ )} - {/* Comments count */} - - - {discussionCount ?? 0} comments - + {safeExternalUrl && hostname && ( + + + Visit Link at {hostname} + + )} - {/* Share button */} + {linkContent.author && ( +
+ +
+ )} + +
+
+ + 0 + ? "text-success" + : score < 0 + ? "text-danger" + : "text-faint" + }`} + > + {score} +
- {/* Discussion section */} -
- {linkContent.showComments ? ( + + + {discussionCount ?? 0} comments + + + +
+ +
+ {linkContent.showComments ? ( + <> +

+ Discussion{" "} + + {discussionCount ?? 0} + +

- ) : ( -
-

- Comments are disabled for this link -

-
- )} -
-
-
+ + ) : ( +

+ Comments are disabled for this link +

+ )} + + ); }; diff --git a/app/(app)/[username]/[slug]/page.tsx b/app/(app)/[username]/[slug]/page.tsx index 931a3f1c7..f7122466b 100644 --- a/app/(app)/[username]/[slug]/page.tsx +++ b/app/(app)/[username]/[slug]/page.tsx @@ -1,48 +1,51 @@ -import React from "react"; -import type { RenderableTreeNode } from "@markdoc/markdoc"; -import Markdoc from "@markdoc/markdoc"; -import Link from "next/link"; -import { markdocComponents } from "@/markdoc/components"; -import { config } from "@/markdoc/config"; -import DiscussionArea from "@/components/Discussion/DiscussionArea"; -import { ArticleActionBarWrapper } from "@/components/ArticleActionBar"; -import { InlineAuthorBio } from "@/components/ContentDetail"; +import { cache } from "react"; import { headers } from "next/headers"; -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect } from "next/navigation"; import { getServerAuthSession } from "@/server/auth"; -import ArticleAdminPanel from "@/components/ArticleAdminPanel/ArticleAdminPanel"; import { type Metadata } from "next"; -import { getCamelCaseFromLower } from "@/utils/utils"; -import { generateHTML } from "@tiptap/core"; -import { RenderExtensions } from "@/components/editor/editor/extensions/render-extensions"; -import sanitizeHtml from "sanitize-html"; -import type { JSONContent } from "@tiptap/core"; -import NotFound from "@/components/NotFound/NotFound"; +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 } from "drizzle-orm"; -import FeedArticleContent from "./_feedArticleContent"; -import LinkContentDetail from "./_linkContentDetail"; +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 { serverApi } from "@/server/trpc/caller"; import { JsonLd } from "@/components/JsonLd"; -import { - getArticleSchema, - getBreadcrumbSchema, - getNewsArticleSchema, -} from "@/lib/structured-data"; +import { getArticleSchema, getBreadcrumbSchema } from "@/lib/structured-data"; type Props = { params: Promise<{ username: string; slug: string }> }; -// Helper to fetch user article by username and slug (uses new posts table) -async function getUserPost(username: string, postSlug: string) { +async function getUserPostUncached( + username: string, + postSlug: string, + viewerId?: string | null, +) { + // Case-insensitive handle resolution (GitHub-style), matching the profile page. const userRecord = await db.query.user.findFirst({ columns: { id: true }, - where: eq(user.username, username), + where: sql`lower(${user.username}) = ${username.toLowerCase()}`, }); if (!userRecord) return null; - // Then find published article by slug that belongs to this user - using explicit JOIN + // 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({ id: posts.id, @@ -59,7 +62,7 @@ async function getUserPost(username: string, postSlug: string) { upvotesCount: posts.upvotesCount, downvotesCount: posts.downvotesCount, type: posts.type, - // Author info via JOIN + moderationNote: posts.moderationNote, authorId: user.id, authorName: user.name, authorImage: user.image, @@ -72,9 +75,15 @@ async function getUserPost(username: string, postSlug: string) { and( eq(posts.slug, postSlug), eq(posts.authorId, userRecord.id), - eq(posts.status, "published"), - eq(posts.type, "article"), - lte(posts.publishedAt, new Date().toISOString()), + // Text-content kinds render via the article reader; links resolve below. + inArray(posts.type, [ + "article", + "discussion", + "question", + "til", + "resource", + ]), + visibilityFilter, ), ) .limit(1); @@ -83,21 +92,19 @@ async function getUserPost(username: string, postSlug: string) { const postRecord = postResults[0]; - // Fetch tags separately using explicit JOIN const tagsResult = await db - .select({ title: tag.title }) + .select({ title: tag.title, slug: tag.slug }) .from(post_tags) .innerJoin(tag, eq(post_tags.tagId, tag.id)) .where(eq(post_tags.postId, postRecord.id)); - // Map to expected shape for backwards compatibility return { ...postRecord, published: postRecord.publishedAt, readTimeMins: postRecord.readingTime, upvotes: postRecord.upvotesCount, downvotes: postRecord.downvotesCount, - tags: tagsResult.map((t) => ({ tag: { title: t.title } })), + tags: tagsResult.map((t) => ({ tag: { title: t.title, slug: t.slug } })), user: { id: postRecord.authorId, name: postRecord.authorName, @@ -108,16 +115,14 @@ async function getUserPost(username: string, postSlug: string) { }; } -// Helper to fetch user-created link post by username and slug (user shared a link) -async function getUserLinkPost(username: string, postSlug: string) { +async function getUserLinkPostUncached(username: string, postSlug: string) { const userRecord = await db.query.user.findFirst({ columns: { id: true }, - where: eq(user.username, username), + where: sql`lower(${user.username}) = ${username.toLowerCase()}`, }); if (!userRecord) return null; - // Find published link post by slug that belongs to this user (no sourceId) const linkPostResults = await db .select({ id: posts.id, @@ -135,7 +140,6 @@ async function getUserLinkPost(username: string, postSlug: string) { upvotesCount: posts.upvotesCount, downvotesCount: posts.downvotesCount, type: posts.type, - // Author info via JOIN authorId: user.id, authorName: user.name, authorImage: user.image, @@ -159,14 +163,12 @@ async function getUserLinkPost(username: string, postSlug: string) { const linkPost = linkPostResults[0]; - // Fetch tags separately using explicit JOIN const tagsResult = await db .select({ title: tag.title }) .from(post_tags) .innerJoin(tag, eq(post_tags.tagId, tag.id)) .where(eq(post_tags.postId, linkPost.id)); - // Map to expected shape return { ...linkPost, published: linkPost.publishedAt, @@ -184,8 +186,7 @@ async function getUserLinkPost(username: string, postSlug: string) { }; } -// Helper to fetch link post by source slug and article slug (uses new posts table) -async function getFeedArticle( +async function getFeedArticleUncached( sourceSlug: string, articleSlugOrShortId: string, ) { @@ -195,7 +196,6 @@ async function getFeedArticle( if (!source) return null; - // Find link post by slug that belongs to this source - using explicit JOIN const linkPostResults = await db .select({ id: posts.id, @@ -211,7 +211,6 @@ async function getFeedArticle( createdAt: posts.createdAt, updatedAt: posts.updatedAt, showComments: posts.showComments, - // Source info sourceName: feed_sources.name, sourceSlug: feed_sources.slug, sourceLogo: feed_sources.logoUrl, @@ -233,7 +232,6 @@ async function getFeedArticle( const linkPost = linkPostResults[0]; - // Map to expected shape for backwards compatibility return { ...linkPost, shortId: linkPost.slug.split("-").pop() || "", @@ -250,40 +248,144 @@ async function getFeedArticle( }; } -// Helper to fetch link content (uses new posts table - same as getFeedArticle) -async function getLinkContent(sourceSlug: string, contentSlug: string) { - // Delegate to getFeedArticle since they query the same table now - return getFeedArticle(sourceSlug, contentSlug); -} +// Per-request dedupe: generateMetadata and the page body run the same +// resolution cascade; cache() makes each (resolver, args) pair hit the DB once. +const getUserPost = cache(getUserPostUncached); +const getUserLinkPost = cache(getUserLinkPostUncached); +const getFeedArticle = cache(getFeedArticleUncached); +const resolveMemberCanonicalByUrlId = cache( + resolveMemberCanonicalByUrlIdUncached, +); +const exactPublishedPostExists = cache(exactPublishedPostExistsUncached); -// Helper to fetch user article content (uses new posts table - same as getUserPost) async function getUserArticleContent(username: string, contentSlug: string) { - // Delegate to getUserPost since they query the same table now return getUserPost(username, contentSlug); } +// Resolve a member post by its urlId to its canonical username + slug. +// Aggregated/source content (no author) is excluded; a miss returns null so +// callers fall back to username+slug resolution. +async function resolveMemberCanonicalByUrlIdUncached(urlId: string) { + if (!urlId) return null; + + const [match] = await db + .select({ + slug: posts.slug, + username: user.username, + type: posts.type, + }) + .from(posts) + .innerJoin(user, eq(posts.authorId, user.id)) + .where( + and( + eq(posts.urlId, urlId), + eq(posts.status, "published"), + lte(posts.publishedAt, new Date().toISOString()), + inArray(posts.type, [ + "article", + "discussion", + "question", + "til", + "resource", + "link", + ]), + ), + ) + .limit(1); + + if (!match || !match.username || !match.slug) return null; + return { username: match.username, slug: match.slug, type: match.type }; +} + +// Discussions and questions live under the /d/ namespace. Everything else +// (articles, TIL, resource, link) stays at /{username}/{slug}. +function isDiscussionKind(type: string | null | undefined): boolean { + return type === "discussion" || type === "question"; +} + +// Does a published post live at the EXACT (username, slug) requested? Suppresses +// urlId-based redirects so a slug whose trailing token collides with another +// post's urlId isn't hijacked (301'd) to that other post. +async function exactPublishedPostExistsUncached( + username: string, + slug: string, +): Promise { + const [match] = await db + .select({ id: posts.id }) + .from(posts) + .innerJoin(user, eq(posts.authorId, user.id)) + .where( + and( + sql`lower(${user.username}) = ${username.toLowerCase()}`, + eq(posts.slug, slug), + eq(posts.status, "published"), + lte(posts.publishedAt, new Date().toISOString()), + ), + ) + .limit(1); + + return !!match; +} + +// 301 to canonical when the request's urlId resolves to a member post whose +// canonical path differs (title edit / username rename). Discussion/question +// kinds always 301 to /d/{slug}. +async function redirectMemberToCanonical(username: string, slug: string) { + const canonical = await resolveMemberCanonicalByUrlId(parseUrlId(slug)); + if (!canonical) return; + + // Discussions/questions always move to /d/, even if an exact post exists here. + if (isDiscussionKind(canonical.type)) { + permanentRedirect(`/d/${canonical.slug}`); + } + + // Hijack guard: if a real post already lives at the EXACT requested URL, the + // token collided with another post's urlId — let the normal render path serve + // the correct post (the discussion redirect above stays unconditional). + if (await exactPublishedPostExists(username, slug)) { + return; + } + + const canonicalPath = `/${canonical.username}/${canonical.slug}`; + if (canonicalMismatch(`/${username}/${slug}`, canonicalPath)) { + permanentRedirect(canonicalPath); + } +} + export async function generateMetadata(props: Props): Promise { const params = await props.params; const { username, slug } = params; - // First try user post (legacy Post table) - const userPost = await getUserPost(username, slug); + // 301 stale member URLs (title edits / username renames) before metadata work. + await redirectMemberToCanonical(username, slug); + + // 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); if (userPost) { + // Discussions/questions canonicalize to /d/{slug}; redirect before metadata. + if (isDiscussionKind(userPost.type)) { + permanentRedirect(`/d/${userPost.slug}`); + } const tags = userPost.tags.map((tag) => tag.tag.title); - const host = (await headers()).get("host") || ""; const authorName = userPost.user.name || "Unknown"; return { title: `${userPost.title} | by ${authorName} | Codú`, authors: { name: authorName, - url: `https://www.${host}/${userPost.user.username}`, + // Author URLs always point at the canonical production host — + // host-header values vary on previews. + url: `${SITE_ORIGIN}/${userPost.user.username}`, }, keywords: tags, description: userPost.excerpt ?? undefined, openGraph: { description: userPost.excerpt ?? undefined, type: "article", + url: `/${userPost.user.username ?? username}/${userPost.slug}`, + publishedTime: userPost.published ?? undefined, + modifiedTime: userPost.updatedAt ?? undefined, images: [ `/og?title=${encodeURIComponent( userPost.title, @@ -298,29 +400,34 @@ export async function generateMetadata(props: Props): Promise { images: [`/og?title=${encodeURIComponent(userPost.title)}`], }, alternates: { - canonical: userPost.canonicalUrl, + // Cross-posted content points at the original; native posts + // self-canonical at the stored handle casing. + canonical: + userPost.canonicalUrl ?? + `/${userPost.user.username ?? username}/${userPost.slug}`, }, }; } - // Then try user ARTICLE content (new unified Content table) const userArticle = await getUserArticleContent(username, slug); if (userArticle && userArticle.user) { const tags = userArticle.tags?.map((t) => t.tag.title) || []; - const host = (await headers()).get("host") || ""; const articleAuthorName = userArticle.user.name || "Unknown"; return { title: `${userArticle.title} | by ${articleAuthorName} | Codú`, authors: { name: articleAuthorName, - url: `https://www.${host}/${userArticle.user.username}`, + url: `${SITE_ORIGIN}/${userArticle.user.username}`, }, keywords: tags, description: userArticle.excerpt, openGraph: { description: userArticle.excerpt || "", type: "article", + url: `/${userArticle.user.username ?? username}/${userArticle.slug}`, + publishedTime: userArticle.published ?? undefined, + modifiedTime: userArticle.updatedAt ?? undefined, images: [ `/og?title=${encodeURIComponent( userArticle.title, @@ -335,22 +442,22 @@ export async function generateMetadata(props: Props): Promise { images: [`/og?title=${encodeURIComponent(userArticle.title)}`], }, alternates: { - canonical: userArticle.canonicalUrl, + canonical: + userArticle.canonicalUrl ?? + `/${userArticle.user.username ?? username}/${userArticle.slug}`, }, }; } - // Try user-created link post (user shared a link) const userLinkPost = await getUserLinkPost(username, slug); if (userLinkPost && userLinkPost.user) { - const host = (await headers()).get("host") || ""; const linkAuthorName = userLinkPost.user.name || "Unknown"; return { title: `${userLinkPost.title} | shared by ${linkAuthorName} | Codú`, authors: { name: linkAuthorName, - url: `https://www.${host}/${userLinkPost.user.username}`, + url: `${SITE_ORIGIN}/${userLinkPost.user.username}`, }, description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`, openGraph: { @@ -359,565 +466,141 @@ export async function generateMetadata(props: Props): Promise { images: userLinkPost.coverImage ? [userLinkPost.coverImage] : undefined, siteName: "Codú", }, + // Member-shared links keep Codú as canonical (aggregated links point to source). + alternates: { + canonical: `/${userLinkPost.user.username ?? username}/${userLinkPost.slug}`, + }, }; } - // Then try feed article (legacy aggregated_article table) + // Aggregated/source content moved to /s/{sourceSlug}/{slug} — 301 rather than + // emit feed metadata at the legacy URL. const feedArticle = await getFeedArticle(username, slug); if (feedArticle) { - return { - title: `${feedArticle.title} | Codú Feed`, - description: - feedArticle.excerpt || `Discussion about ${feedArticle.title}`, - openGraph: { - title: feedArticle.title, - description: - feedArticle.excerpt || `Discussion about ${feedArticle.title}`, - images: - feedArticle.ogImageUrl || feedArticle.imageUrl - ? [feedArticle.ogImageUrl || feedArticle.imageUrl!] - : undefined, - }, - }; - } - - // Try unified content table (new LINK type items) - const linkContent = await getLinkContent(username, slug); - if (linkContent) { - return { - title: `${linkContent.title} | Codú Feed`, - description: - linkContent.excerpt || `Discussion about ${linkContent.title}`, - openGraph: { - title: linkContent.title, - description: - linkContent.excerpt || `Discussion about ${linkContent.title}`, - images: - linkContent.ogImageUrl || linkContent.imageUrl - ? [linkContent.ogImageUrl || linkContent.imageUrl!] - : undefined, - }, - }; + permanentRedirect(`/s/${username}/${feedArticle.slug}`); } return { title: "Content Not Found" }; } -const parseJSON = (str: string): JSONContent | null => { - try { - return JSON.parse(str); - } catch { - return null; - } -}; - -const renderSanitizedTiptapContent = (jsonContent: JSONContent) => { - const rawHtml = generateHTML(jsonContent, [...RenderExtensions]); - return sanitizeHtml(rawHtml, { - allowedTags: sanitizeHtml.defaults.allowedTags.concat([ - "img", - "iframe", - "h1", - "h2", - ]), - allowedAttributes: { - ...sanitizeHtml.defaults.allowedAttributes, - img: ["src", "alt", "title", "width", "height", "class"], - iframe: ["src", "width", "height", "frameborder", "allowfullscreen"], - "*": ["class", "id", "style"], - }, - allowedIframeHostnames: [ - "www.youtube.com", - "youtube.com", - "www.youtube-nocookie.com", - ], - }); -}; - const UnifiedPostPage = async (props: Props) => { const params = await props.params; const session = await getServerAuthSession(); const { username, slug } = params; + // 301 stale member URLs (title edits / username renames) to canonical. + await redirectMemberToCanonical(username, slug); + const host = (await headers()).get("host") || ""; - // First try user post - const userPost = await getUserPost(username, slug); + const userPost = await getUserPost(username, slug, session?.user?.id); if (userPost) { - // Render user article - const bodyContent = userPost.body ?? ""; - const parsedBody = parseJSON(bodyContent); - const isTiptapContent = parsedBody?.type === "doc"; - - let renderedContent: string | RenderableTreeNode; - - if (isTiptapContent && parsedBody) { - const jsonContent = parsedBody; - renderedContent = renderSanitizedTiptapContent(jsonContent); - } else { - const ast = Markdoc.parse(bodyContent); - const transformedContent = Markdoc.transform(ast, config); - renderedContent = Markdoc.renderers.react(transformedContent, React, { - components: markdocComponents, - }) as unknown as string; + // Discussions/questions live under /d/{slug} — redirect before rendering. + if (isDiscussionKind(userPost.type)) { + permanentRedirect(`/d/${userPost.slug}`); } - // Prepare JSON-LD structured data - const articleSchema = getArticleSchema({ - title: userPost.title, - excerpt: userPost.excerpt, - slug: userPost.slug, - publishedAt: userPost.published, - updatedAt: userPost.updatedAt, - readingTime: userPost.readTimeMins, - canonicalUrl: userPost.canonicalUrl, - tags: userPost.tags.map((t) => ({ title: t.tag.title })), - author: { - name: userPost.user.name, - username: userPost.user.username, - image: userPost.user.image, - bio: userPost.user.bio, - }, - }); - - const breadcrumbSchema = getBreadcrumbSchema([ - { name: "Home", url: "https://www.codu.co" }, - { name: "Feed", url: "https://www.codu.co/feed" }, - { - name: userPost.user.name || "Author", - url: `https://www.codu.co/${userPost.user.username}`, - }, - { name: userPost.title }, - ]); + // Handle resolution is case-insensitive; only the stored casing renders. + if (userPost.user.username && userPost.user.username !== username) { + permanentRedirect(`/${userPost.user.username}/${userPost.slug}`); + } return ( - <> - {/* JSON-LD Structured Data for SEO */} - - - -
- {/* Breadcrumb navigation */} - - - {/* Article card - contains everything in one cohesive unit */} -
- {/* Author info */} -
- - {userPost.user.image ? ( - - ) : ( -
- {userPost.user.name?.charAt(0).toUpperCase() || "?"} -
- )} - {userPost.user.name} - - {userPost.published && ( - <> - - - - )} - {userPost.readTimeMins && ( - <> - - {userPost.readTimeMins} min read - - )} -
- - {/* Article content */} -
- {!isTiptapContent &&

{userPost.title}

} - - {isTiptapContent ? ( -
, - }} - className="tiptap-content" - /> - ) : ( -
- {Markdoc.renderers.react(renderedContent, React, { - components: markdocComponents, - })} -
- )} -
- - {/* Tags */} - {userPost.tags.length > 0 && ( -
- {userPost.tags.map(({ tag }) => ( - - {getCamelCaseFromLower(tag.title)} - - ))} -
- )} - - {/* Compact inline author bio */} -
- -
- - {/* Action bar - just above discussion */} -
- -
- - {/* Discussion section - inside the card */} -
- {userPost.showComments ? ( - - ) : ( -
-

- Comments are disabled for this post -

-
- )} -
-
-
- - {session && session?.user?.role === "ADMIN" && ( - - )} - + ); } - // Then try user ARTICLE content (new unified Content table) const userArticle = await getUserArticleContent(username, slug); if (userArticle && userArticle.user && userArticle.body) { - // Render user article from Content table - const parsedBody = parseJSON(userArticle.body); - const isTiptapContent = parsedBody?.type === "doc"; - - let renderedContent: string | RenderableTreeNode; - - if (isTiptapContent && parsedBody) { - const jsonContent = parsedBody; - renderedContent = renderSanitizedTiptapContent(jsonContent); - } else { - const ast = Markdoc.parse(userArticle.body); - const transformedContent = Markdoc.transform(ast, config); - renderedContent = Markdoc.renderers.react(transformedContent, React, { - components: markdocComponents, - }) as unknown as string; + if (isDiscussionKind(userArticle.type)) { + permanentRedirect(`/d/${userArticle.slug}`); } - // Prepare JSON-LD structured data - const articleSchema = getArticleSchema({ - title: userArticle.title, - excerpt: userArticle.excerpt, - slug: userArticle.slug, - publishedAt: userArticle.publishedAt, - updatedAt: userArticle.updatedAt, - readingTime: userArticle.readTimeMins, - canonicalUrl: userArticle.canonicalUrl, - tags: userArticle.tags?.map((t) => ({ title: t.tag.title })), - author: { - name: userArticle.user.name, - username: userArticle.user.username, - image: userArticle.user.image, - bio: userArticle.user.bio, - }, - }); - - const breadcrumbSchema = getBreadcrumbSchema([ - { name: "Home", url: "https://www.codu.co" }, - { name: "Feed", url: "https://www.codu.co/feed" }, - { - name: userArticle.user.name || "Author", - url: `https://www.codu.co/${userArticle.user.username}`, - }, - { name: userArticle.title }, - ]); + if (userArticle.user.username && userArticle.user.username !== username) { + permanentRedirect(`/${userArticle.user.username}/${userArticle.slug}`); + } return ( - <> - {/* JSON-LD Structured Data for SEO */} - - - -
- {/* Breadcrumb navigation */} - - - {/* Article card - contains everything in one cohesive unit */} -
- {/* Author info */} -
- - {userArticle.user.image ? ( - - ) : ( -
- {userArticle.user.name?.charAt(0).toUpperCase() || "?"} -
- )} - {userArticle.user.name} - - {userArticle.publishedAt && ( - <> - - - - )} - {userArticle.readTimeMins && ( - <> - - {userArticle.readTimeMins} min read - - )} -
- - {/* Article content */} -
- {!isTiptapContent &&

{userArticle.title}

} - - {isTiptapContent ? ( -
, - }} - className="tiptap-content" - /> - ) : ( -
- {Markdoc.renderers.react(renderedContent, React, { - components: markdocComponents, - })} -
- )} -
- - {/* Tags */} - {userArticle.tags && userArticle.tags.length > 0 && ( -
- {userArticle.tags.map(({ tag }) => ( - - {getCamelCaseFromLower(tag.title)} - - ))} -
- )} - - {/* Compact inline author bio */} -
- -
- - {/* Action bar - just above discussion */} -
- -
- - {/* Discussion section - inside the card */} -
- {userArticle.showComments ? ( - - ) : ( -
-

- Comments are disabled for this article -

-
- )} -
-
-
- - {session && session?.user?.role === "ADMIN" && ( - - )} - + ); } - // Try user-created link post (user shared a link) const userLinkPost = await getUserLinkPost(username, slug); if (userLinkPost && userLinkPost.user) { - // Render user link post - return ; - } - - // Then try feed article (legacy aggregated_article table) - const feedArticle = await getFeedArticle(username, slug); + if (userLinkPost.user.username && userLinkPost.user.username !== username) { + permanentRedirect(`/${userLinkPost.user.username}/${userLinkPost.slug}`); + } - if (feedArticle) { - // Prepare JSON-LD structured data for feed article - const newsArticleSchema = getNewsArticleSchema({ - title: feedArticle.title, - excerpt: feedArticle.excerpt, - slug: feedArticle.slug, - externalUrl: feedArticle.externalUrl || "", - coverImage: feedArticle.imageUrl || feedArticle.ogImageUrl, - publishedAt: feedArticle.publishedAt, - source: { - name: feedArticle.source?.name || null, - slug: feedArticle.source?.slug || username, - logoUrl: feedArticle.source?.logoUrl, + // Member-shared links are self-canonical, so emit BlogPosting + BreadcrumbList + // JSON-LD like member articles. + const linkAuthorName = userLinkPost.user.name || "Unknown"; + const articleSchema = getArticleSchema({ + title: userLinkPost.title, + excerpt: userLinkPost.excerpt, + slug: userLinkPost.slug, + image: userLinkPost.coverImage, + publishedAt: userLinkPost.published, + updatedAt: userLinkPost.updatedAt, + readingTime: userLinkPost.readTimeMins, + // Self-canonical: omit canonicalUrl so the builder uses the Codú URL. + tags: userLinkPost.tags.map((t) => ({ title: t.tag.title })), + author: { + name: userLinkPost.user.name, + username: userLinkPost.user.username, + image: userLinkPost.user.image, + bio: userLinkPost.user.bio, }, }); - const breadcrumbSchema = getBreadcrumbSchema([ - { name: "Home", url: "https://www.codu.co" }, - { name: "Feed", url: "https://www.codu.co/feed" }, + { name: "Home", url: SITE_ORIGIN }, { - name: feedArticle.source?.name || username, - url: `https://www.codu.co/${feedArticle.source?.slug || username}`, + name: linkAuthorName, + url: `${SITE_ORIGIN}/${userLinkPost.user.username}`, }, - { name: feedArticle.title }, + { name: userLinkPost.title }, ]); - // Render feed article with JSON-LD + // Server-fetch the tRPC-shaped content (in-process, includes the viewer's + // vote) so the link body is in the crawlable HTML, not client-fetched. + const initialLinkContent = await serverApi() + .then((api) => api.content.getUserLinkBySlug({ username, slug })) + .catch(() => null); + return ( <> - + - + ); } - // Try unified content table (new LINK type items) - const linkContent = await getLinkContent(username, slug); - - if (linkContent) { - // Prepare JSON-LD structured data for link content - const newsArticleSchema = getNewsArticleSchema({ - title: linkContent.title, - excerpt: linkContent.excerpt, - slug: linkContent.slug, - externalUrl: linkContent.externalUrl || "", - coverImage: linkContent.imageUrl || linkContent.ogImageUrl, - publishedAt: linkContent.publishedAt, - source: { - name: linkContent.source?.name || null, - slug: linkContent.source?.slug || username, - logoUrl: linkContent.source?.logoUrl, - }, - }); - - const breadcrumbSchema = getBreadcrumbSchema([ - { name: "Home", url: "https://www.codu.co" }, - { name: "Feed", url: "https://www.codu.co/feed" }, - { - name: linkContent.source?.name || username, - url: `https://www.codu.co/${linkContent.source?.slug || username}`, - }, - { name: linkContent.title }, - ]); + // Aggregated content now lives at /s/{sourceSlug}/{slug} — 301 there. + const feedArticle = await getFeedArticle(username, slug); - // Render link content with JSON-LD - return ( - <> - - - - - ); + if (feedArticle) { + permanentRedirect(`/s/${username}/${feedArticle.slug}`); } - // Nothing found return notFound(); }; diff --git a/app/(app)/[username]/_sourceProfileClient.tsx b/app/(app)/[username]/_sourceProfileClient.tsx deleted file mode 100644 index f695dddc3..000000000 --- a/app/(app)/[username]/_sourceProfileClient.tsx +++ /dev/null @@ -1,223 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { LinkIcon } from "@heroicons/react/20/solid"; -import { api } from "@/server/trpc/react"; -import { useInView } from "react-intersection-observer"; -import { useEffect } from "react"; -import { Heading } from "@/components/ui-components/heading"; -import { UnifiedContentCard } from "@/components/UnifiedContentCard"; - -type Props = { - sourceSlug: string; -}; - -// Get favicon URL from a website -const getFaviconUrl = ( - websiteUrl: string | null | undefined, -): string | null => { - if (!websiteUrl) return null; - try { - const url = new URL(websiteUrl); - return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=128`; - } catch { - return null; - } -}; - -function getDomainFromUrl(url: string) { - const domain = url.replace(/(https?:\/\/)?(www.)?/i, ""); - if (domain[domain.length - 1] === "/") { - return domain.slice(0, domain.length - 1); - } - return domain; -} - -const SourceProfileContent = ({ sourceSlug }: Props) => { - const { ref: loadMoreRef, inView } = useInView({ threshold: 0 }); - - const { data: source, status: sourceStatus } = - api.feed.getSourceBySlug.useQuery({ slug: sourceSlug }); - - const { - data: articlesData, - status: articlesStatus, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = api.feed.getArticlesBySource.useInfiniteQuery( - { sourceSlug, sort: "recent", limit: 25 }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ); - - useEffect(() => { - if (inView && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); - - if (sourceStatus === "pending") { - return ( -
-
-
-
-
-
-
-
-
-
-
- ); - } - - if (sourceStatus === "error" || !source) { - return ( -
-
-

- Source Not Found -

-

- This source may have been removed or the link is invalid. -

- - Back to Feed - -
-
- ); - } - - const faviconUrl = getFaviconUrl(source.websiteUrl); - const articles = articlesData?.pages.flatMap((page) => page.articles) ?? []; - - return ( - <> -
- {/* Profile header - matching user profile pattern exactly */} -
-
- {source.logoUrl ? ( - {`Avatar - ) : faviconUrl ? ( - {`Avatar - ) : ( -
- {source.name?.charAt(0).toUpperCase() || "?"} -
- )} -
-
-

{source.name}

-

- @{sourceSlug} -

-

{source.description || ""}

- {source.websiteUrl && ( - - -

- {getDomainFromUrl(source.websiteUrl)} -

- - )} -
-
- - {/* Articles header - matching user profile */} -
- {`Articles (${source.articleCount})`} -
- - {/* Articles list using UnifiedContentCard */} -
- {articlesStatus === "pending" ? ( -
- {[...Array(5)].map((_, i) => ( -
-
-
-
-
- ))} -
- ) : articles.length === 0 ? ( -

Nothing published yet... 🥲

- ) : ( - <> - {articles.map((article) => { - // Use slug for SEO-friendly URLs, fallback to shortId for legacy articles - const articleSlug = article.slug || article.shortId; - - return ( - - ); - })} - - {/* Load more trigger */} -
- {isFetchingNextPage && ( -
- Loading more articles... -
- )} - {!hasNextPage && articles.length > 0 && ( -
- No more articles -
- )} -
- - )} -
-
- - ); -}; - -export default SourceProfileContent; diff --git a/app/(app)/[username]/_usernameClient.tsx b/app/(app)/[username]/_usernameClient.tsx index 0ddaa0c69..bdcdbc66c 100644 --- a/app/(app)/[username]/_usernameClient.tsx +++ b/app/(app)/[username]/_usernameClient.tsx @@ -6,9 +6,12 @@ import Link from "next/link"; import { UnifiedContentCard } from "@/components/UnifiedContentCard"; import { LinkIcon } from "@heroicons/react/20/solid"; import { api } from "@/server/trpc/react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Session } from "next-auth"; import { Heading } from "@/components/ui-components/heading"; +import { FollowButton, Tag } from "@/components/ds"; +import { getHostname, safeExternalHref } from "@/utils/url"; +import { getRelativeTime } from "@/utils/relativeTime"; import { toast } from "sonner"; type Props = { @@ -30,15 +33,17 @@ type Props = { image: string; bio: string; websiteUrl: string; + location: string; + topics: string[]; + createdAt: string; }; }; const Profile = ({ profile, isOwner, session }: Props) => { const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); - const tabFromParams = searchParams?.get("tab"); - const { mutate: banUser } = api.admin.ban.useMutation({ onSettled() { router.refresh(); @@ -51,8 +56,53 @@ const Profile = ({ profile, isOwner, session }: Props) => { }, }); - const { name, username, image, bio, posts, websiteUrl, id, accountLocked } = - profile; + const { data: followCounts } = api.follow.counts.useQuery({ + userId: profile.id, + }); + + const [listView, setListView] = React.useState< + null | "followers" | "following" + >(null); + const { data: followersList } = api.follow.getFollowers.useQuery( + { userId: profile.id }, + { enabled: listView === "followers" }, + ); + const { data: followingList } = api.follow.getFollowing.useQuery( + { userId: profile.id }, + { enabled: listView === "following" }, + ); + const listData = listView === "followers" ? followersList : followingList; + + const { + name, + username, + image, + bio, + posts, + websiteUrl, + location, + topics, + createdAt, + id, + accountLocked, + } = profile; + + const joinedLabel = createdAt + ? `Joined ${new Date(createdAt).toLocaleDateString(undefined, { + month: "long", + year: "numeric", + })}` + : null; + + const { data: engagement } = api.engagement.profileEngagement.useQuery( + { userId: id }, + { enabled: !accountLocked }, + ); + + const { data: replies } = api.profile.userReplies.useQuery( + { username: username ?? "" }, + { enabled: !accountLocked && !!username }, + ); const handleBanSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); @@ -71,112 +121,394 @@ const Profile = ({ profile, isOwner, session }: Props) => { } }; - const ARTICLES = "articles"; - const selectedTab = tabFromParams === ARTICLES ? ARTICLES : ARTICLES; + const TABS = ["Posts", "Replies", "Achievements"] as const; + type Tab = (typeof TABS)[number]; + + // URL is the source of truth: ?tab=posts|replies|achievements (lower-case). + const tabParam = searchParams?.get("tab")?.toLowerCase(); + const tab: Tab = + tabParam === "achievements" + ? "Achievements" + : tabParam === "replies" + ? "Replies" + : "Posts"; + + const setTab = (value: Tab) => { + const params = new URLSearchParams(searchParams?.toString()); + params.set("tab", value.toLowerCase()); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }; + + // Show a "Top helper" chip when the user is clearly engaged: a high point + // total or any earned badge. Uses existing profileEngagement data only. + const earnedBadges = engagement?.badges.filter((b) => b.earned) ?? []; + const isTopHelper = + !!engagement && (engagement.points >= 100 || earnedBadges.length > 0); return ( <> -
-
-
+
+
+
{image && ( {`Avatar )}
-
-

{name}

-

- @{username} -

-

{bio}

- {websiteUrl && !accountLocked && ( +
+
+

+ {name} +

+ {isTopHelper && ( + + ◆ Top helper + + )} +
+

@{username}

+
+ {session && !isOwner && !accountLocked && ( +
+ +
+ )} + {isOwner && !accountLocked && ( +
- -

- {getDomainFromUrl(websiteUrl)} -

+ Manage posts + +
+ )} +
+ + {bio && ( +

+ {bio} +

+ )} + + {(joinedLabel || location || websiteUrl) && ( +
+ {joinedLabel && ( + + ◷ {joinedLabel} + + )} + {location && ( + + ◉ {location} + + )} + {safeExternalHref(websiteUrl) && ( + + + {getHostname(safeExternalHref(websiteUrl))} )}
-
+ )} + + {topics && topics.length > 0 && ( +
+ {topics.map((t) => ( + {t} + ))} +
+ )} + + {!accountLocked && ( +
+
+
+ {posts.length} +
+
+ Posts +
+
+ + +
+ )} + + {/* Followers / Following list */} + {!accountLocked && listView && ( +
+
+

+ {listView} +

+ +
+
+ {!listData &&

Loading…

} + {listData && listData.length === 0 && ( +

No {listView} yet.

+ )} + {listData?.map((u) => ( +
+ +
+ + {u.name || u.username} + +

@{u.username}

+
+ {session && session.user?.id !== u.id && ( + + )} +
+ ))} +
+
+ )} + {accountLocked ? ( -
+
Account locked 🔒
) : ( -
- {`Articles (${posts.length})`} -
- )} - {(() => { - switch (selectedTab) { - case ARTICLES: - return ( -
- {posts.length ? ( - posts.map( - ({ - slug, - title, - excerpt, - readingTime, - publishedAt, - id, - }) => { - if (!publishedAt) return null; - return ( -
- - {isOwner && ( - - Edit - - )} + <> +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "Posts" && ( +
+ {posts.length ? ( + posts.map( + ({ + slug, + title, + excerpt, + readingTime, + publishedAt, + id, + }) => { + if (!publishedAt) return null; + return ( +
+ + {isOwner && ( + + Edit + + )} +
+ ); + }, + ) + ) : ( +

+ Nothing published yet... 🥲 +

+ )} +
+ )} + + {tab === "Replies" && ( +
+ {replies && replies.length > 0 ? ( + replies.map((reply) => ( + +

+ Replied on{" "} + + {reply.parent.title} + {" "} + · {getRelativeTime(reply.createdAt)} +

+

+ {reply.body} +

+ + )) + ) : ( +

No replies yet.

+ )} +
+ )} + + {tab === "Achievements" && ( +
+ {engagement ? ( + <> +
+
+ + ✦ + +
+
+ {engagement.points} +
+
+ points +
+
+
+
+ + 🔥 + +
+
+ {engagement.currentStreak}
- ); - }, - ) - ) : ( -

- Nothing published yet... 🥲 +

+ day streak +
+
+
+
+ +
+

+ {"// "}badges +

+ {/* Just the earned count — the catalogue grows over time, + so "of N" would keep moving the goalposts. */} + + {earnedBadges.length} earned + +
+
+ {engagement.badges.map((b) => ( +
+
+ {b.earned ? b.emoji : "🔒"} +
+
+ {b.name} +
+
+ {b.description} +
+
+ ))} +
+

+ {"// "}points come from posting and helpful contributions

- )} -
- ); - default: - return null; - } - })()} + + ) : ( +

+ No achievements yet. +

+ )} +
+ )} + + )}
{session?.user?.role === "ADMIN" && ( -
-

Admin Control

+
+

+ Admin Control +

{accountLocked ? (
@@ -213,11 +545,3 @@ const Profile = ({ profile, isOwner, session }: Props) => { }; export default Profile; - -function getDomainFromUrl(url: string) { - const domain = url.replace(/(https?:\/\/)?(www.)?/i, ""); - if (domain[domain.length - 1] === "/") { - return domain.slice(0, domain.length - 1); - } - return domain; -} diff --git a/app/(app)/[username]/page.tsx b/app/(app)/[username]/page.tsx index e70439be3..af17250cd 100644 --- a/app/(app)/[username]/page.tsx +++ b/app/(app)/[username]/page.tsx @@ -1,14 +1,13 @@ import React from "react"; -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect } from "next/navigation"; import Content from "./_usernameClient"; -import SourceProfileContent from "./_sourceProfileClient"; import { getServerAuthSession } from "@/server/auth"; import { type Metadata } from "next"; import { db } from "@/server/db"; import { feed_sources } from "@/server/db/schema"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { JsonLd } from "@/components/JsonLd"; -import { getPersonSchema } from "@/lib/structured-data"; +import { getProfilePageSchema } from "@/lib/structured-data"; type Props = { params: Promise<{ username: string }> }; @@ -16,23 +15,29 @@ export async function generateMetadata(props: Props): Promise { const params = await props.params; const username = params.username; - // First check if it's a user + // Case-insensitive handle resolution (GitHub-style) on lower(username). const profile = await db.query.user.findFirst({ columns: { bio: true, name: true, + username: true, }, - where: (users, { eq }) => eq(users.username, username), + where: (users) => sql`lower(${users.username}) = ${username.toLowerCase()}`, }); if (profile) { const { bio, name } = profile; - const title = `${name || username} - Codú Profile | Codú - The Web Developer Community`; - const description = `${name || username}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`; + const handle = profile.username ?? username; + // Short enough to survive SERP truncation (~60 chars). + const title = `${name || handle} (@${handle}) | Codú`; + const description = `${name || handle}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`; return { title, description, + // Canonical at the stored handle casing (mixed-case requests 301 anyway; + // this guards query-param duplicates). + alternates: { canonical: `/${handle}` }, openGraph: { title, description, @@ -56,26 +61,7 @@ export async function generateMetadata(props: Props): Promise { }; } - // Check if it's a feed source - const source = await db.query.feed_sources.findFirst({ - where: eq(feed_sources.slug, username), - }); - - if (source) { - return { - title: `${source.name} | Codú Feed`, - description: - source.description || `Articles from ${source.name} on Codú Feed`, - openGraph: { - title: source.name, - description: - source.description || `Articles from ${source.name} on Codú Feed`, - images: source.logoUrl ? [source.logoUrl] : undefined, - }, - }; - } - - // Neither user nor source found + // Feed sources live at /s/{sourceSlug} (the /s/ route owns their metadata). return { title: "Profile Not Found" }; } @@ -98,6 +84,9 @@ export default async function Page(props: { image: true, id: true, websiteUrl: true, + location: true, + topics: true, + createdAt: true, }, with: { posts: { @@ -117,10 +106,17 @@ export default async function Page(props: { orderBy: (posts, { desc }) => [desc(posts.publishedAt)], }, }, - where: (users, { eq }) => eq(users.username, username), + // Case-insensitive handle resolution (GitHub-style). + where: (users) => sql`lower(${users.username}) = ${username.toLowerCase()}`, }); if (profile) { + // Canonicalize casing: 301 to the handle's stored display casing so there's + // one indexable URL per profile. + if (profile.username && profile.username !== username) { + permanentRedirect(`/${profile.username}`); + } + const bannedUser = await db.query.banned_users.findFirst({ where: (bannedUsers, { eq }) => eq(bannedUsers.userId, profile.id), }); @@ -135,33 +131,35 @@ export default async function Page(props: { accountLocked, }; - // Prepare Person JSON-LD for SEO - const personSchema = getPersonSchema({ + // ProfilePage JSON-LD (wraps a Person mainEntity) for profile SEO. + const profilePageSchema = getProfilePageSchema({ name: shapedProfile.name, username: shapedProfile.username, image: shapedProfile.image, bio: shapedProfile.bio, websiteUrl: shapedProfile.websiteUrl, + createdAt: shapedProfile.createdAt, }); return ( <> - {/* Person JSON-LD for profile SEO */} - + -

{`${shapedProfile.name || shapedProfile.username}'s Coding Profile`}

+ {/* The visible profile name (rendered as

in _usernameClient) is the + single page h1 — no separate sr-only h1 to avoid duplicate headings. */} ); } - // Check if it's a feed source + // /{username} is users-only: a non-user segment that IS a feed source 301s to /s/. const source = await db.query.feed_sources.findFirst({ + columns: { slug: true }, where: eq(feed_sources.slug, username), }); if (source) { - return ; + permanentRedirect(`/s/${username}`); } // Neither user nor source found diff --git a/app/(app)/admin/_client.tsx b/app/(app)/admin/_client.tsx index b3534a3ab..114b5d684 100644 --- a/app/(app)/admin/_client.tsx +++ b/app/(app)/admin/_client.tsx @@ -11,16 +11,21 @@ import { } from "@heroicons/react/24/outline"; import { api } from "@/server/trpc/react"; -const colorClasses = { - blue: "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400", - green: "bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400", - yellow: - "bg-yellow-50 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400", - red: "bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400", - purple: - "bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400", - orange: - "bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400", +type StatTone = + | "accent" + | "success" + | "warning" + | "danger" + | "info" + | "neutral"; + +const toneClasses: Record = { + accent: "bg-accent/10 text-accent", + success: "bg-success/12 text-success", + warning: "bg-warning/12 text-warning", + danger: "bg-danger/12 text-danger", + info: "bg-info/12 text-info", + neutral: "border border-hairline text-muted", }; const StatCard = ({ @@ -28,29 +33,29 @@ const StatCard = ({ value, icon: Icon, href, - color = "blue", + tone = "info", isLoading, }: { title: string; value: number | undefined; icon: React.ComponentType<{ className?: string }>; href?: string; - color?: "blue" | "green" | "yellow" | "red" | "purple" | "orange"; + tone?: StatTone; isLoading?: boolean; }) => { const content = ( -
+
-
+
-

+

{title}

-

+

{isLoading ? ( - + ) : ( (value ?? 0) )} @@ -74,21 +79,21 @@ const AdminDashboard = () => { return (

-

+

+ {"// "}admin +

+

Admin Dashboard

-

- Manage and monitor the Codú platform -

+

Manage and monitor the Codú platform

- {/* Stats Grid */}
@@ -96,14 +101,14 @@ const AdminDashboard = () => { title="Published Posts" value={stats?.publishedPosts} icon={DocumentTextIcon} - color="green" + tone="success" isLoading={isLoading} /> @@ -111,15 +116,14 @@ const AdminDashboard = () => { title="Total Reports" value={reportCounts?.total} icon={FlagIcon} - color="purple" + tone="neutral" href="/admin/moderation" isLoading={isLoading} />
- {/* Moderation Stats */}
-

+

Moderation

@@ -127,7 +131,7 @@ const AdminDashboard = () => { title="Pending Reports" value={reportCounts?.pending} icon={FlagIcon} - color="yellow" + tone="warning" href="/admin/moderation" isLoading={isLoading} /> @@ -135,14 +139,14 @@ const AdminDashboard = () => { title="Actioned Reports" value={reportCounts?.actioned} icon={ShieldExclamationIcon} - color="red" + tone="danger" isLoading={isLoading} /> @@ -150,73 +154,64 @@ const AdminDashboard = () => { title="Dismissed Reports" value={reportCounts?.dismissed} icon={FlagIcon} - color="green" + tone="success" isLoading={isLoading} />
- {/* Quick Links */}
-

+

Quick Actions

- +
-

+

Moderation Queue

-

- Review reported content -

+

Review reported content

- +
-

+

User Management

-

- Search and manage users -

+

Search and manage users

- +
-

- Feed Sources -

-

- Manage RSS feed sources -

+

Feed Sources

+

Manage RSS feed sources

- +
-

+

Tag Management

-

+

Merge, curate, and manage tags

diff --git a/app/(app)/admin/moderation/_client.tsx b/app/(app)/admin/moderation/_client.tsx index be584cd73..f993bb9f3 100644 --- a/app/(app)/admin/moderation/_client.tsx +++ b/app/(app)/admin/moderation/_client.tsx @@ -1,15 +1,19 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { FlagIcon, XCircleIcon, ExclamationTriangleIcon, ArrowLeftIcon, + EyeSlashIcon, + ArrowTopRightOnSquareIcon, } from "@heroicons/react/24/outline"; import { api } from "@/server/trpc/react"; import { toast } from "sonner"; +import { getRelativeTime } from "@/utils/relativeTime"; type ReportStatus = "PENDING" | "REVIEWED" | "DISMISSED" | "ACTIONED"; type ReportReason = @@ -33,25 +37,34 @@ const reasonLabels: Record = { OTHER: "Other", }; +const chipBase = + "rounded-full px-2 py-0.5 font-mono text-xs uppercase tracking-label"; + +// 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". +function localDateTimeMin(): string { + const now = new Date(); + return new Date(now.getTime() - now.getTimezoneOffset() * 60_000) + .toISOString() + .slice(0, 16); +} + const reasonColors: Record = { - SPAM: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", - HARASSMENT: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", - HATE_SPEECH: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", - MISINFORMATION: - "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400", - COPYRIGHT: - "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400", - NSFW: "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400", - OFF_TOPIC: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400", - OTHER: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400", + SPAM: "bg-warning/12 text-warning", + HARASSMENT: "bg-danger/12 text-danger", + HATE_SPEECH: "bg-danger/12 text-danger", + MISINFORMATION: "bg-accent/10 text-accent", + COPYRIGHT: "bg-info/12 text-info", + NSFW: "bg-accent/10 text-accent", + OFF_TOPIC: "border border-hairline text-muted", + OTHER: "border border-hairline text-muted", }; const statusColors: Record = { - PENDING: - "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", - REVIEWED: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400", - DISMISSED: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400", - ACTIONED: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + PENDING: "bg-warning/12 text-warning", + REVIEWED: "bg-info/12 text-info", + DISMISSED: "border border-hairline text-muted", + ACTIONED: "bg-danger/12 text-danger", }; const ModerationQueue = () => { @@ -60,6 +73,20 @@ const ModerationQueue = () => { ); const utils = api.useUtils(); + // Deep-link from the moderation email: ?item= highlights/scrolls the + // matching queue item (in either the awaiting-review or reported-live list). + const searchParams = useSearchParams(); + const highlightedItem = searchParams.get("item"); + const highlightRef = useRef(null); + + // Optional per-item "Decline" note, keyed by postId. + const [declineNotes, setDeclineNotes] = useState>({}); + + // Per-postId future release time (datetime-local) for the "Schedule" affordance. + const [scheduleTimes, setScheduleTimes] = useState>( + {}, + ); + const { data, isLoading } = api.report.getAll.useQuery({ status: statusFilter, limit: 20, @@ -79,6 +106,34 @@ const ModerationQueue = () => { }, }); + // Auto-moderation queue (posts awaiting review). + const inReview = api.admin.listInReview.useQuery(); + + // Live posts that have been flagged by users (open reports on published posts). + const reportedPosts = api.admin.listReportedPosts.useQuery(); + + const { mutate: moderatePost, isPending: isModerating } = + api.admin.moderatePost.useMutation({ + onSuccess: (_data, variables) => { + toast.success( + variables.decision === "approve" + ? variables.publishAt + ? "Post approved and scheduled" + : "Post approved" + : variables.decision === "hide" + ? "Post hidden and moved to review" + : "Post declined", + ); + utils.admin.listInReview.invalidate(); + utils.admin.listReportedPosts.invalidate(); + utils.report.getAll.invalidate(); + utils.report.getCounts.invalidate(); + }, + onError: () => { + toast.error("Failed to update post"); + }, + }); + const handleDismiss = (reportId: number) => { reviewReport({ reportId, @@ -95,108 +150,327 @@ const ModerationQueue = () => { }); }; - const getRelativeTime = (dateStr: string): string => { - const now = new Date(); - const date = new Date(dateStr); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + // Dismiss all open reports for a reported-live post (the post stays published). + const handleDismissReportedPost = (reportIds: number[]) => { + reportIds.forEach((reportId) => + reviewReport({ + reportId, + status: "DISMISSED", + actionTaken: "Reports dismissed by admin", + }), + ); + utils.admin.listReportedPosts.invalidate(); }; + // Scroll to + highlight the ?item= deep-linked card once data loads. + useEffect(() => { + if (!highlightedItem) return; + if (inReview.isLoading || reportedPosts.isLoading) return; + const node = highlightRef.current; + if (node) { + node.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, [ + highlightedItem, + inReview.isLoading, + reportedPosts.isLoading, + inReview.data, + reportedPosts.data, + ]); + + const highlightClass = (id: string) => + highlightedItem === id ? "ring-2 ring-accent rounded-lg" : ""; + return (
-

+

+ {"// "}admin +

+

Moderation Queue

-

- Review and manage reported content -

+

Review and manage reported content

- {/* Status Tabs */} -
- - - - +
+ {( + [ + ["PENDING", `Pending (${counts?.pending ?? 0})`], + ["ACTIONED", `Actioned (${counts?.actioned ?? 0})`], + ["DISMISSED", `Dismissed (${counts?.dismissed ?? 0})`], + [undefined, `All (${counts?.total ?? 0})`], + ] as const + ).map(([value, label]) => ( + + ))}
- {/* Reports List */} + {/* In review — auto-moderation queue */} +
+
+

+ {"// "}in review +

+ + {inReview.data?.length ?? 0} awaiting + +
+ + {inReview.isLoading && ( +

Loading…

+ )} + + {!inReview.isLoading && (inReview.data?.length ?? 0) === 0 && ( +

+ {"// nothing waiting for review"} +

+ )} + +
+ {inReview.data?.map((post) => ( +
+
+
+

+ {post.title || "Untitled"} +

+

+ @{post.authorUsername ?? "unknown"} ·{" "} + {getRelativeTime(post.createdAt!)} +

+ {post.moderationNote && ( +

+ Reason:{" "} + {post.moderationNote} +

+ )} +
+
+ + +
+
+ + setDeclineNotes((prev) => ({ + ...prev, + [post.id]: e.target.value, + })) + } + placeholder="Optional note shown to the author when declined…" + className="mt-2 w-full rounded border border-hairline bg-inset px-3 py-1.5 text-sm text-fg placeholder:text-faint focus:border-accent focus:outline-none" + /> + {/* Approve & schedule: pick a future release time, then Schedule. + "Approve" (above) still publishes immediately. */} +
+ + + setScheduleTimes((prev) => ({ + ...prev, + [post.id]: e.target.value, + })) + } + className="rounded border border-hairline bg-inset px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none" + /> + +
+
+ ))} +
+
+ + {/* Reported (live) — published posts users have flagged */} +
+
+

+ {"// "}reported (live) +

+ + {reportedPosts.data?.length ?? 0} flagged + +
+ + {reportedPosts.isLoading && ( +

Loading…

+ )} + + {!reportedPosts.isLoading && + (reportedPosts.data?.length ?? 0) === 0 && ( +

+ {"// no flagged live posts"} +

+ )} + +
+ {reportedPosts.data?.map((post) => ( +
+
+
+

+ {post.title || "Untitled"} +

+

+ @{post.authorUsername ?? "unknown"} · {post.reportCount}{" "} + report + {post.reportCount === 1 ? "" : "s"} + {post.latestReportAt + ? ` · ${getRelativeTime(post.latestReportAt)}` + : ""} +

+
+ + {reasonLabels[post.latestReason as ReportReason] ?? + post.latestReason} + + {post.latestDetails && ( + + {post.latestDetails} + + )} +
+
+
+ {post.authorUsername && post.slug && ( + + + View + + )} + + +
+
+
+ ))} +
+
+
{isLoading && (
{[1, 2, 3].map((i) => (
-
-
-
+
+
+
))}
)} {!isLoading && data?.reports.length === 0 && ( -
- -

+
+ +

No reports found

-

+

{statusFilter ? `No ${statusFilter.toLowerCase()} reports` : "All caught up!"} @@ -207,70 +481,65 @@ const ModerationQueue = () => { {data?.reports.map((report) => (

- {/* Header */}
{reasonLabels[report.reason as ReportReason]} {report.status} - + {getRelativeTime(report.createdAt!)}
- {/* Content Preview */}
{report.content && ( -
-

+

+

{report.content.type} by @{report.content.user?.username}

-

- {report.content.title} -

+

{report.content.title}

)} {report.discussion && ( -
-

+

+

Comment by @{report.discussion.user?.username}

-

+

{report.discussion.body}

)}
- {/* Reporter Details */} {report.details && (
-

- Details: {report.details} +

+ Details:{" "} + {report.details}

)}
-

+

Reported by @{report.reporter?.username || "unknown"}

- {/* Actions */} {report.status === "PENDING" && (
- {/* Add Source Form */} {showAddForm && ( -
-

+
+

Add New Feed Source

-
-
-
-
-
@@ -442,14 +443,14 @@ const AdminSourcesPage = () => { @@ -458,24 +459,23 @@ const AdminSourcesPage = () => {
)} - {/* Edit Modal */} {editingSource && (
-
+
-

+

Edit Feed Source

-
-
-
-