diff --git a/.gitignore b/.gitignore index 2951c3a5..57ab497a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ ssmSetup.zsh # Snyk Security Extension - AI Rules (auto-generated) .github/instructions/snyk_rules.instructions.md + +# Local-only deploy notes (never commit) +local.md diff --git a/app/(app)/(tsandcs)/layout.tsx b/app/(app)/(tsandcs)/layout.tsx index 07c4b076..44eb3a7c 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 index dbe9e19a..62238ed8 100644 --- a/app/(app)/[username]/[slug]/_feedArticleContent.tsx +++ b/app/(app)/[username]/[slug]/_feedArticleContent.tsx @@ -141,14 +141,14 @@ const FeedArticleContent = ({ sourceSlug, articleSlug }: Props) => { if (status === "pending") { return ( -
+
-
-
-
-
-
-
+
+
+
+
+
+
); @@ -156,18 +156,18 @@ const FeedArticleContent = ({ sourceSlug, articleSlug }: Props) => { if (status === "error" || !article) { return ( -
+
- Back to Feed + ‹ Back to feed -
-

+
+

Post Not Found

-

+

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

@@ -195,244 +195,226 @@ const FeedArticleContent = ({ sourceSlug, articleSlug }: Props) => { const score = article.upvotes - article.downvotes; return ( -
- {/* Breadcrumb */} - - - {/* Article card */} -
- {/* Source info */} -
+
- {article.source?.logoUrl ? ( + {article.source?.name || "Unknown Source"} + +
+ @{sourceSlug} + {article.sourceAuthor && + article.sourceAuthor.trim() && + !["by", "by,", "by ,"].includes( + article.sourceAuthor.trim().toLowerCase(), + ) + ? ` · ${article.sourceAuthor.replace(/^by\s+/i, "").trim()}` + : ""} +
+
+
+ + {ensureHttps(article.imageUrl) && article.externalUrl ? ( + + +
+ + {hostname} +
+
+ ) : ( +
+ )} + + {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?.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} +
+
+ + {article.source.name} + + + @{sourceSlug} +
-
- )} - - {/* 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} - - + {article.source.description && ( +

+ {article.source.description} +

+ )}
+
+ )} - {/* Comments count */} - - - {discussionCount ?? 0} comments - - - {/* Save button */} +
+
- - {/* Share button */} + 0 + ? "text-success" + : score < 0 + ? "text-danger" + : "text-faint" + }`} + > + {score} +
- {/* Discussion section - inside the card */} -
- -
-
-
+ + + {discussionCount ?? 0} comments + + + + + + + +
+

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

+ +
+ ); }; diff --git a/app/(app)/[username]/[slug]/_linkContentDetail.tsx b/app/(app)/[username]/[slug]/_linkContentDetail.tsx index f24a3bba..19a000f8 100644 --- a/app/(app)/[username]/[slug]/_linkContentDetail.tsx +++ b/app/(app)/[username]/[slug]/_linkContentDetail.tsx @@ -144,14 +144,14 @@ const LinkContentDetail = ({ sourceSlug, contentSlug }: Props) => { if (status === "pending") { return ( -
+
-
-
-
-
-
-
+
+
+
+
+
+
); @@ -159,18 +159,18 @@ const LinkContentDetail = ({ sourceSlug, 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.

@@ -197,216 +197,203 @@ const LinkContentDetail = ({ sourceSlug, contentSlug }: Props) => { const score = votes.upvotes - votes.downvotes; return ( -
- {/* Breadcrumb */} - +
+ + ‹ Back to feed + + +

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

- {/* Content card */} -
- {/* Source info */} -
+

+ {linkContent.title} +

+ + {linkContent.excerpt && ( +

+ {linkContent.excerpt} +

+ )} + +
+ + {linkContent.source?.logoUrl ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {linkContent.source?.name?.charAt(0).toUpperCase() || "?"} +
+ )} + +
- {linkContent.source?.logoUrl ? ( + {linkContent.source?.name || "Unknown Source"} + +
+ @{sourceSlug} + {linkContent.sourceAuthor && linkContent.sourceAuthor.trim() + ? ` · ${linkContent.sourceAuthor}` + : ""} +
+
+
+ + {ensureHttps(linkContent.imageUrl) && externalUrl ? ( + + + {hostname && ( +
+ + {hostname} +
+ )} +
+ ) : ( +
+ )} + + {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?.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} -

- )} +
+
+ + {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 */} - +
+ + 0 + ? "text-success" + : score < 0 + ? "text-danger" + : "text-faint" + }`} + > + {score} +
- {/* Discussion section */} -
- -
-
-
+ + + {discussionCount ?? 0} comments + + + + + +
+

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

+ +
+ ); }; diff --git a/app/(app)/[username]/[slug]/_userLinkDetail.tsx b/app/(app)/[username]/[slug]/_userLinkDetail.tsx index 7d1ecc1e..8c2c90e1 100644 --- a/app/(app)/[username]/[slug]/_userLinkDetail.tsx +++ b/app/(app)/[username]/[slug]/_userLinkDetail.tsx @@ -15,6 +15,7 @@ import { Temporal } from "@js-temporal/polyfill"; import DiscussionArea from "@/components/Discussion/DiscussionArea"; import { useSession, signIn } from "next-auth/react"; import { InlineAuthorBio } from "@/components/ContentDetail"; +import { FollowButton } from "@/components/ds"; type Props = { username: string; @@ -130,14 +131,14 @@ const UserLinkDetail = ({ username, contentSlug }: Props) => { if (status === "pending") { return ( -
+
-
-
-
-
-
-
+
+
+
+
+
+
); @@ -145,18 +146,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.

@@ -179,196 +180,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) && externalUrl ? ( + + + {hostname && ( +
+ + {hostname} +
+ )} +
+ ) : ( +
+ )} - {/* Comments count */} - - - {discussionCount ?? 0} comments - + {externalUrl && hostname && ( + + + Visit Link at {hostname} + + )} + + {linkContent.author && ( +
+ +
+ )} - {/* Share button */} +
+
+ 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 931a3f1c..36295314 100644 --- a/app/(app)/[username]/[slug]/page.tsx +++ b/app/(app)/[username]/[slug]/page.tsx @@ -19,8 +19,15 @@ import sanitizeHtml from "sanitize-html"; import type { JSONContent } from "@tiptap/core"; import NotFound from "@/components/NotFound/NotFound"; 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 { + posts, + user, + feed_sources, + post_tags, + tag, + comments, +} from "@/server/db/schema"; +import { eq, and, lte, inArray, count, isNull } from "drizzle-orm"; import FeedArticleContent from "./_feedArticleContent"; import LinkContentDetail from "./_linkContentDetail"; import UserLinkDetail from "./_userLinkDetail"; @@ -73,7 +80,15 @@ async function getUserPost(username: string, postSlug: string) { eq(posts.slug, postSlug), eq(posts.authorId, userRecord.id), eq(posts.status, "published"), - eq(posts.type, "article"), + // Text-content kinds all render via the article reader (title + body + + // discussion). Links have their own resolver below. + inArray(posts.type, [ + "article", + "discussion", + "question", + "til", + "resource", + ]), lte(posts.publishedAt, new Date().toISOString()), ), ) @@ -262,6 +277,17 @@ async function getUserArticleContent(username: string, contentSlug: string) { return getUserPost(username, contentSlug); } +// Server-side comment count for a piece of content. Mirrors +// discussion.getContentDiscussionCount so the user-post reader can render the +// same "Discussion {N}" heading as the source-content reader. +async function getDiscussionCount(contentId: string) { + const [result] = await db + .select({ count: count() }) + .from(comments) + .where(and(eq(comments.postId, contentId), isNull(comments.deletedAt))); + return result?.count ?? 0; +} + export async function generateMetadata(props: Props): Promise { const params = await props.params; const { username, slug } = params; @@ -491,17 +517,17 @@ const UnifiedPostPage = async (props: Props) => { { name: userPost.title }, ]); + const discussionCount = await getDiscussionCount(userPost.id); + return ( <> - {/* JSON-LD Structured Data for SEO */}
- {/* Breadcrumb navigation */} - {/* Article card - contains everything in one cohesive unit */} -
- {/* Author info */} -
+
+
{userPost.user.image ? ( { className="h-5 w-5 rounded-full object-cover" /> ) : ( -
+
{userPost.user.name?.charAt(0).toUpperCase() || "?"}
)} @@ -556,7 +580,6 @@ const UnifiedPostPage = async (props: Props) => { )}
- {/* Article content */}
{!isTiptapContent &&

{userPost.title}

} @@ -576,14 +599,13 @@ const UnifiedPostPage = async (props: Props) => { )}
- {/* Tags */} {userPost.tags.length > 0 && (
{userPost.tags.map(({ tag }) => ( {getCamelCaseFromLower(tag.title)} @@ -591,7 +613,6 @@ const UnifiedPostPage = async (props: Props) => {
)} - {/* Compact inline author bio */}
{ />
- {/* Action bar - just above discussion */}
{ />
- {/* Discussion section - inside the card */} -
+
+

+ Discussion{" "} + + {discussionCount} + +

{userPost.showComments ? ( ) : ( @@ -684,17 +712,17 @@ const UnifiedPostPage = async (props: Props) => { { name: userArticle.title }, ]); + const discussionCount = await getDiscussionCount(userArticle.id); + return ( <> - {/* JSON-LD Structured Data for SEO */}
- {/* Breadcrumb navigation */} - {/* Article card - contains everything in one cohesive unit */} -
- {/* Author info */} -
+
+
{userArticle.user.image ? ( { className="h-5 w-5 rounded-full object-cover" /> ) : ( -
+
{userArticle.user.name?.charAt(0).toUpperCase() || "?"}
)} @@ -752,7 +778,6 @@ const UnifiedPostPage = async (props: Props) => { )}
- {/* Article content */}
{!isTiptapContent &&

{userArticle.title}

} @@ -772,14 +797,13 @@ const UnifiedPostPage = async (props: Props) => { )}
- {/* Tags */} {userArticle.tags && userArticle.tags.length > 0 && (
{userArticle.tags.map(({ tag }) => ( {getCamelCaseFromLower(tag.title)} @@ -787,7 +811,6 @@ const UnifiedPostPage = async (props: Props) => {
)} - {/* Compact inline author bio */}
{ />
- {/* Action bar - just above discussion */}
{ />
- {/* Discussion section - inside the card */} -
+
+

+ Discussion{" "} + + {discussionCount} + +

{userArticle.showComments ? ( ) : ( diff --git a/app/(app)/[username]/_sourceProfileClient.tsx b/app/(app)/[username]/_sourceProfileClient.tsx index f695dddc..419c30e8 100644 --- a/app/(app)/[username]/_sourceProfileClient.tsx +++ b/app/(app)/[username]/_sourceProfileClient.tsx @@ -1,92 +1,89 @@ "use client"; import Link from "next/link"; -import { LinkIcon } from "@heroicons/react/20/solid"; +import { useState } from "react"; +import { signIn, useSession } from "next-auth/react"; +import { toast } from "sonner"; 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; - } +// Deterministic hue from the slug (sum of char codes mod 360). Math.random is +// unavailable here, and the publication tile colour must be stable per source. +const hueFromSlug = (slug: string): number => { + let sum = 0; + for (let i = 0; i < slug.length; i++) sum += slug.charCodeAt(i); + return sum % 360; }; -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; -} +// Two-letter initials for the square logo tile. +const initialsFromName = (name: string): string => { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "??"; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return (words[0][0] + words[1][0]).toUpperCase(); +}; 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") { + const { data: session } = useSession(); + const utils = api.useUtils(); + + const { data: pub, status } = api.publication.getBySlug.useQuery({ + slug: sourceSlug, + }); + + // Optimistic follow state, seeded from the query once it resolves. + const [optimisticFollowing, setOptimisticFollowing] = useState< + boolean | null + >(null); + const [shared, setShared] = useState(false); + + const onError = () => { + setOptimisticFollowing(null); + toast.error("Something went wrong. Please try again."); + void utils.publication.getBySlug.invalidate({ slug: sourceSlug }); + }; + const onSettled = () => { + void utils.publication.getBySlug.invalidate({ slug: sourceSlug }); + }; + + const followMut = api.publication.follow.useMutation({ onError, onSettled }); + const unfollowMut = api.publication.unfollow.useMutation({ + onError, + onSettled, + }); + const pending = followMut.isPending || unfollowMut.isPending; + + if (status === "pending") { return ( -
-
-
-
-
-
-
-
+
+
+
+
+
+
); } - if (sourceStatus === "error" || !source) { + if (status === "error" || !pub) { return ( -
-
-

- Source Not Found +
+
+

+ Publication Not Found

-

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

+ This publication may have been removed or the link is invalid.

Back to Feed @@ -95,128 +92,154 @@ const SourceProfileContent = ({ sourceSlug }: Props) => { ); } - const faviconUrl = getFaviconUrl(source.websiteUrl); - const articles = articlesData?.pages.flatMap((page) => page.articles) ?? []; + const hue = hueFromSlug(pub.slug ?? sourceSlug); + const initials = initialsFromName(pub.name); + const isFollowing = optimisticFollowing ?? pub.isFollowing; + + const handleFollowToggle = () => { + if (!session) { + signIn(); + return; + } + if (pending) return; + if (isFollowing) { + setOptimisticFollowing(false); + unfollowMut.mutate({ sourceId: pub.id }); + } else { + setOptimisticFollowing(true); + followMut.mutate({ sourceId: pub.id }); + } + }; + + const handleShare = () => { + const url = + typeof window !== "undefined" + ? window.location.href + : `/${pub.slug ?? sourceSlug}`; + void navigator.clipboard?.writeText(url).then(() => { + setShared(true); + setTimeout(() => setShared(false), 1200); + }); + }; 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)} -

- - )} +
+
+
+ {/* Square logo tile — the "publication, not person" signal. */} + {pub.logoUrl ? ( + {`${pub.name} + ) : ( + + )} + +
+

+ {"// "}publication +

+

+ {pub.name} +

+

+ @{pub.handle ?? sourceSlug} +

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

+ {pub.tagline} +

+ )} + + {/* Stats — Followers + Articles only (no Following). */} +
+
+
+ {pub.followerCount}
- ) : 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 -
- )} -
- - )} +
+ Followers +
+
+
+
+ {pub.articleCount} +
+
+ Articles +
+
-
- +
+ +
+

+ {"// "}latest articles +

+ + {pub.articles.length === 0 ? ( +

Nothing published yet.

+ ) : ( +
+ {pub.articles.map((article) => ( + + ))} +
+ )} +
+
); }; diff --git a/app/(app)/[username]/_usernameClient.tsx b/app/(app)/[username]/_usernameClient.tsx index 0ddaa0c6..dd5b3505 100644 --- a/app/(app)/[username]/_usernameClient.tsx +++ b/app/(app)/[username]/_usernameClient.tsx @@ -6,9 +6,10 @@ 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 { toast } from "sonner"; type Props = { @@ -30,15 +31,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 +54,48 @@ 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 handleBanSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); @@ -71,112 +114,350 @@ const Profile = ({ profile, isOwner, session }: Props) => { } }; - const ARTICLES = "articles"; - const selectedTab = tabFromParams === ARTICLES ? ARTICLES : ARTICLES; + const TABS = ["Posts", "Achievements"] as const; + type Tab = (typeof TABS)[number]; + + // URL is the source of truth: ?tab=posts|achievements (lower-case). + const tabParam = searchParams?.get("tab")?.toLowerCase(); + const tab: Tab = tabParam === "achievements" ? "Achievements" : "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 && ( +
+ +
+ )} +
+ + {bio && ( +

+ {bio} +

+ )} + + {(joinedLabel || location || websiteUrl) && ( +
+ {joinedLabel && ( + + ◷ {joinedLabel} + + )} + {location && ( + + ◉ {location} + + )} + {websiteUrl && ( - -

- {getDomainFromUrl(websiteUrl)} -

+ + {getDomainFromUrl(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 === "Achievements" && ( +
+ {engagement ? ( + <> +
+
+ + ✦ + +
+
+ {engagement.points} +
+
+ points +
+
+
+
+ + 🔥 + +
+
+ {engagement.currentStreak} +
+
+ day streak +
+
+
+
+ +
+

+ {"// "}badges +

+ + {earnedBadges.length} of {engagement.badges.length}{" "} + earned + +
+
+ {engagement.badges.map((b) => ( +
+
+ {b.earned ? b.emoji : "🔒"} +
+
+ {b.name}
- ); - }, - ) - ) : ( -

- Nothing published yet... 🥲 +

+ {b.description} +
+
+ ))} +
+

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

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

+ No achievements yet. +

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

Admin Control

+
+

+ Admin Control +

{accountLocked ? (
diff --git a/app/(app)/[username]/page.tsx b/app/(app)/[username]/page.tsx index e70439be..4fd0a055 100644 --- a/app/(app)/[username]/page.tsx +++ b/app/(app)/[username]/page.tsx @@ -27,7 +27,7 @@ export async function generateMetadata(props: Props): Promise { if (profile) { const { bio, name } = profile; - const title = `${name || username} - Codú Profile | Codú - The Web Developer Community`; + const title = `${name || username} - Codú Profile | Codú - The community for AI builders & indie hackers`; const description = `${name || username}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`; return { @@ -98,6 +98,9 @@ export default async function Page(props: { image: true, id: true, websiteUrl: true, + location: true, + topics: true, + createdAt: true, }, with: { posts: { @@ -149,7 +152,8 @@ export default async function Page(props: { {/* 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. */} ); diff --git a/app/(app)/admin/_client.tsx b/app/(app)/admin/_client.tsx index b3534a3a..114b5d68 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 be584cd7..d0fe0334 100644 --- a/app/(app)/admin/moderation/_client.tsx +++ b/app/(app)/admin/moderation/_client.tsx @@ -33,25 +33,25 @@ const reasonLabels: Record = { OTHER: "Other", }; +const chipBase = + "rounded-full px-2 py-0.5 font-mono text-xs uppercase tracking-label"; + 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 = () => { @@ -79,6 +79,22 @@ const ModerationQueue = () => { }, }); + // Auto-moderation queue (posts awaiting review). + const inReview = api.admin.listInReview.useQuery(); + + const { mutate: moderatePost, isPending: isModerating } = + api.admin.moderatePost.useMutation({ + onSuccess: (_data, variables) => { + toast.success( + variables.decision === "approve" ? "Post approved" : "Post rejected", + ); + utils.admin.listInReview.invalidate(); + }, + onError: () => { + toast.error("Failed to update post"); + }, + }); + const handleDismiss = (reportId: number) => { reviewReport({ reportId, @@ -115,88 +131,128 @@ const ModerationQueue = () => {
-

+

+ {"// "}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!)} +

+
+
+ + +
+
+ ))} +
+
+
{isLoading && (
{[1, 2, 3].map((i) => (
-
-
-
+
+
+
))}
)} {!isLoading && data?.reports.length === 0 && ( -
- -

+
+ +

No reports found

-

+

{statusFilter ? `No ${statusFilter.toLowerCase()} reports` : "All caught up!"} @@ -207,70 +263,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

-
-
-
-