diff --git a/app/(app)/[username]/[slug]/_feedArticleContent.tsx b/app/(app)/[username]/[slug]/_feedArticleContent.tsx new file mode 100644 index 00000000..dbe9e19a --- /dev/null +++ b/app/(app)/[username]/[slug]/_feedArticleContent.tsx @@ -0,0 +1,439 @@ +"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 new file mode 100644 index 00000000..f24a3bba --- /dev/null +++ b/app/(app)/[username]/[slug]/_linkContentDetail.tsx @@ -0,0 +1,413 @@ +"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]/page.tsx b/app/(app)/[username]/[slug]/page.tsx new file mode 100644 index 00000000..5ac3dcb9 --- /dev/null +++ b/app/(app)/[username]/[slug]/page.tsx @@ -0,0 +1,685 @@ +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 { headers } from "next/headers"; +import { notFound } 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 { 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"; + +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) { + const userRecord = await db.query.user.findFirst({ + columns: { id: true }, + where: eq(user.username, username), + }); + + if (!userRecord) return null; + + // Then find published article by slug that belongs to this user - using explicit JOIN + const postResults = await db + .select({ + id: posts.id, + title: posts.title, + body: posts.body, + status: posts.status, + publishedAt: posts.publishedAt, + updatedAt: posts.updatedAt, + readingTime: posts.readingTime, + slug: posts.slug, + excerpt: posts.excerpt, + canonicalUrl: posts.canonicalUrl, + showComments: posts.showComments, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + type: posts.type, + // Author info via JOIN + authorId: user.id, + authorName: user.name, + authorImage: user.image, + authorUsername: user.username, + authorBio: user.bio, + }) + .from(posts) + .leftJoin(user, eq(posts.authorId, user.id)) + .where( + and( + eq(posts.slug, postSlug), + eq(posts.authorId, userRecord.id), + eq(posts.status, "published"), + eq(posts.type, "article"), + lte(posts.publishedAt, new Date().toISOString()), + ), + ) + .limit(1); + + if (postResults.length === 0) return null; + + const postRecord = postResults[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, 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 } })), + user: { + id: postRecord.authorId, + name: postRecord.authorName, + image: postRecord.authorImage, + username: postRecord.authorUsername, + bio: postRecord.authorBio, + }, + }; +} + +// Helper to fetch link post by source slug and article slug (uses new posts table) +async function getFeedArticle( + sourceSlug: string, + articleSlugOrShortId: string, +) { + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), + }); + + 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, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + slug: posts.slug, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + publishedAt: posts.publishedAt, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + showComments: posts.showComments, + // Source info + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + }) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .where( + and( + eq(posts.slug, articleSlugOrShortId), + eq(posts.sourceId, source.id), + eq(posts.type, "link"), + eq(posts.status, "published"), + ), + ) + .limit(1); + + if (linkPostResults.length === 0) return null; + + const linkPost = linkPostResults[0]; + + // Map to expected shape for backwards compatibility + return { + ...linkPost, + shortId: linkPost.slug.split("-").pop() || "", + imageUrl: linkPost.coverImage, + ogImageUrl: linkPost.coverImage, + upvotes: linkPost.upvotesCount, + downvotes: linkPost.downvotesCount, + source: { + name: linkPost.sourceName, + slug: linkPost.sourceSlug, + logoUrl: linkPost.sourceLogo, + websiteUrl: linkPost.sourceWebsite, + }, + }; +} + +// 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); +} + +// 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); +} + +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); + if (userPost) { + 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}`, + }, + keywords: tags, + description: userPost.excerpt ?? undefined, + openGraph: { + description: userPost.excerpt ?? undefined, + type: "article", + images: [ + `/og?title=${encodeURIComponent( + userPost.title, + )}&readTime=${userPost.readTimeMins}&author=${encodeURIComponent( + authorName, + )}&date=${userPost.updatedAt}`, + ], + siteName: "Codú", + }, + twitter: { + description: userPost.excerpt ?? undefined, + images: [`/og?title=${encodeURIComponent(userPost.title)}`], + }, + alternates: { + canonical: userPost.canonicalUrl, + }, + }; + } + + // 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}`, + }, + keywords: tags, + description: userArticle.excerpt, + openGraph: { + description: userArticle.excerpt || "", + type: "article", + images: [ + `/og?title=${encodeURIComponent( + userArticle.title, + )}&readTime=${userArticle.readTimeMins || 5}&author=${encodeURIComponent( + userArticle.user.name || "", + )}&date=${userArticle.updatedAt}`, + ], + siteName: "Codú", + }, + twitter: { + description: userArticle.excerpt || "", + images: [`/og?title=${encodeURIComponent(userArticle.title)}`], + }, + alternates: { + canonical: userArticle.canonicalUrl, + }, + }; + } + + // Then try feed article (legacy aggregated_article table) + 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, + }, + }; + } + + 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; + + const host = (await headers()).get("host") || ""; + + // First try user post + const userPost = await getUserPost(username, slug); + + 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; + } + + return ( + <> +
+ {/* 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; + } + + return ( + <> +
+ {/* 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" && ( + + )} + + ); + } + + // Then try feed article (legacy aggregated_article table) + const feedArticle = await getFeedArticle(username, slug); + + if (feedArticle) { + // Render feed article + return ; + } + + // Try unified content table (new LINK type items) + const linkContent = await getLinkContent(username, slug); + + if (linkContent) { + // Render link content + return ; + } + + // Nothing found + return notFound(); +}; + +export default UnifiedPostPage; diff --git a/app/(app)/[username]/_sourceProfileClient.tsx b/app/(app)/[username]/_sourceProfileClient.tsx new file mode 100644 index 00000000..30af43d8 --- /dev/null +++ b/app/(app)/[username]/_sourceProfileClient.tsx @@ -0,0 +1,223 @@ +"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 4aa03b51..1b417c76 100644 --- a/app/(app)/[username]/_usernameClient.tsx +++ b/app/(app)/[username]/_usernameClient.tsx @@ -3,7 +3,7 @@ import * as Sentry from "@sentry/nextjs"; import React from "react"; import Link from "next/link"; -import ArticlePreview from "@/components/ArticlePreview/ArticlePreview"; +import { UnifiedContentCard } from "@/components/UnifiedContentCard"; import { LinkIcon } from "@heroicons/react/20/solid"; import { api } from "@/server/trpc/react"; import { useRouter, useSearchParams } from "next/navigation"; @@ -16,11 +16,11 @@ type Props = { isOwner: boolean; profile: { posts: { - published: string | null; + publishedAt: string | null; title: string; - excerpt: string; + excerpt: string | null; slug: string; - readTimeMins: number; + readingTime: number | null; id: string; }[]; accountLocked: boolean; @@ -127,36 +127,38 @@ const Profile = ({ profile, isOwner, session }: Props) => { slug, title, excerpt, - readTimeMins, - published, + readingTime, + publishedAt, id, }) => { - if (!published) return; + if (!publishedAt) return null; return ( - +
+ + {isOwner && ( + + Edit + + )} +
); }, ) diff --git a/app/(app)/[username]/page.tsx b/app/(app)/[username]/page.tsx index c744b1e8..e8aeb4ac 100644 --- a/app/(app)/[username]/page.tsx +++ b/app/(app)/[username]/page.tsx @@ -1,9 +1,12 @@ import React from "react"; import { notFound } 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"; type Props = { params: Promise<{ username: string }> }; @@ -11,6 +14,7 @@ export async function generateMetadata(props: Props): Promise { const params = await props.params; const username = params.username; + // First check if it's a user const profile = await db.query.user.findFirst({ columns: { bio: true, @@ -19,38 +23,58 @@ export async function generateMetadata(props: Props): Promise { where: (users, { eq }) => eq(users.username, username), }); - if (!profile) { - notFound(); - } - - 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."}`; + 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."}`; - return { - title, - description, - openGraph: { - title, - description, - type: "profile", - images: [ - { - url: "/images/og/home-og.png", - width: 1200, - height: 630, - alt: `${name || username}'s profile on Codú`, - }, - ], - siteName: "Codú", - }, - twitter: { - card: "summary_large_image", + return { title, description, - images: ["/images/og/home-og.png"], - }, - }; + openGraph: { + title, + description, + type: "profile", + images: [ + { + url: "/images/og/home-og.png", + width: 1200, + height: 630, + alt: `${name || username}'s profile on Codú`, + }, + ], + siteName: "Codú", + }, + twitter: { + card: "summary_large_image", + title, + description, + images: ["/images/og/home-og.png"], + }, + }; + } + + // 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 + return { title: "Profile Not Found" }; } export default async function Page(props: { @@ -63,6 +87,7 @@ export default async function Page(props: { notFound(); } + // First check if it's a user const profile = await db.query.user.findFirst({ columns: { bio: true, @@ -78,48 +103,53 @@ export default async function Page(props: { title: true, excerpt: true, slug: true, - readTimeMins: true, - published: true, + readingTime: true, + publishedAt: true, id: true, }, - where: (posts, { isNotNull, and, lte }) => + where: (posts, { eq, and, lte }) => and( - isNotNull(posts.published), - lte(posts.published, new Date().toISOString()), + eq(posts.status, "published"), + lte(posts.publishedAt, new Date().toISOString()), ), - orderBy: (posts, { desc }) => [desc(posts.published)], + orderBy: (posts, { desc }) => [desc(posts.publishedAt)], }, }, where: (users, { eq }) => eq(users.username, username), }); - if (!profile) { - notFound(); + if (profile) { + const bannedUser = await db.query.banned_users.findFirst({ + where: (bannedUsers, { eq }) => eq(bannedUsers.userId, profile.id), + }); + + const accountLocked = !!bannedUser; + const session = await getServerAuthSession(); + const isOwner = session?.user?.id === profile.id; + + const shapedProfile = { + ...profile, + posts: accountLocked ? [] : profile.posts, + accountLocked, + }; + + return ( + <> +

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

+ + + ); } - const bannedUser = await db.query.banned_users.findFirst({ - where: (bannedUsers, { eq }) => eq(bannedUsers.userId, profile.id), + // Check if it's a feed source + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, username), }); - const accountLocked = !!bannedUser; - const session = await getServerAuthSession(); - const isOwner = session?.user?.id === profile.id; - - const shapedProfile = { - ...profile, - posts: accountLocked - ? [] - : profile.posts.map((post) => ({ - ...post, - published: post.published, - })), - accountLocked, - }; - - return ( - <> -

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

- - - ); + if (source) { + return ; + } + + // Neither user nor source found + notFound(); } diff --git a/app/(app)/admin/_client.tsx b/app/(app)/admin/_client.tsx new file mode 100644 index 00000000..b1824840 --- /dev/null +++ b/app/(app)/admin/_client.tsx @@ -0,0 +1,214 @@ +"use client"; + +import Link from "next/link"; +import { + UsersIcon, + DocumentTextIcon, + FlagIcon, + RssIcon, + ShieldExclamationIcon, + NewspaperIcon, +} 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", +}; + +const StatCard = ({ + title, + value, + icon: Icon, + href, + color = "blue", + isLoading, +}: { + title: string; + value: number | undefined; + icon: React.ComponentType<{ className?: string }>; + href?: string; + color?: "blue" | "green" | "yellow" | "red" | "purple" | "orange"; + isLoading?: boolean; +}) => { + const content = ( +
+
+
+ +
+
+

+ {title} +

+

+ {isLoading ? ( + + ) : ( + (value ?? 0) + )} +

+
+
+
+ ); + + if (href) { + return {content}; + } + + return content; +}; + +const AdminDashboard = () => { + const { data: stats, isLoading } = api.admin.getStats.useQuery(); + const { data: reportCounts } = api.report.getCounts.useQuery(); + + return ( +
+
+

+ Admin Dashboard +

+

+ Manage and monitor the Codú platform +

+
+ + {/* Stats Grid */} +
+ + + + +
+ + {/* Moderation Stats */} +
+

+ Moderation +

+
+ + + + +
+
+ + {/* Quick Links */} +
+

+ Quick Actions +

+
+ + +
+

+ Moderation Queue +

+

+ Review reported content +

+
+ + + + +
+

+ User Management +

+

+ Search and manage users +

+
+ + + + +
+

+ Feed Sources +

+

+ Manage RSS feed sources +

+
+ +
+
+
+ ); +}; + +export default AdminDashboard; diff --git a/app/(app)/admin/moderation/_client.tsx b/app/(app)/admin/moderation/_client.tsx new file mode 100644 index 00000000..0352831c --- /dev/null +++ b/app/(app)/admin/moderation/_client.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + FlagIcon, + CheckCircleIcon, + XCircleIcon, + ExclamationTriangleIcon, + ArrowLeftIcon, +} from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; + +type ReportStatus = "PENDING" | "REVIEWED" | "DISMISSED" | "ACTIONED"; +type ReportReason = + | "SPAM" + | "HARASSMENT" + | "HATE_SPEECH" + | "MISINFORMATION" + | "COPYRIGHT" + | "NSFW" + | "OFF_TOPIC" + | "OTHER"; + +const reasonLabels: Record = { + SPAM: "Spam", + HARASSMENT: "Harassment", + HATE_SPEECH: "Hate Speech", + MISINFORMATION: "Misinformation", + COPYRIGHT: "Copyright", + NSFW: "NSFW", + OFF_TOPIC: "Off Topic", + OTHER: "Other", +}; + +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", +}; + +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", +}; + +const ModerationQueue = () => { + const [statusFilter, setStatusFilter] = useState( + "PENDING", + ); + const utils = api.useUtils(); + + const { data, isLoading } = api.report.getAll.useQuery({ + status: statusFilter, + limit: 20, + }); + + const { data: counts } = api.report.getCounts.useQuery(); + + const { mutate: reviewReport, isPending: isReviewing } = + api.report.review.useMutation({ + onSuccess: () => { + toast.success("Report updated"); + utils.report.getAll.invalidate(); + utils.report.getCounts.invalidate(); + }, + onError: () => { + toast.error("Failed to update report"); + }, + }); + + const handleDismiss = (reportId: number) => { + reviewReport({ + reportId, + status: "DISMISSED", + actionTaken: "Report dismissed by admin", + }); + }; + + const handleAction = (reportId: number) => { + reviewReport({ + reportId, + status: "ACTIONED", + actionTaken: "Content removed or user warned", + }); + }; + + 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" }); + }; + + return ( +
+
+ + + +
+

+ Moderation Queue +

+

+ Review and manage reported content +

+
+
+ + {/* Status Tabs */} +
+ + + + +
+ + {/* Reports List */} +
+ {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ )} + + {!isLoading && data?.reports.length === 0 && ( +
+ +

+ No reports found +

+

+ {statusFilter + ? `No ${statusFilter.toLowerCase()} reports` + : "All caught up!"} +

+
+ )} + + {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.discussion && ( +
+

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

+

+ {report.discussion.body} +

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

+ Details: {report.details} +

+
+ )} + +
+

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

+ + {/* Actions */} + {report.status === "PENDING" && ( +
+ + +
+ )} + + {report.status !== "PENDING" && report.reviewedBy && ( +

+ Reviewed by @{report.reviewedBy.username} +

+ )} +
+
+ ))} +
+
+ ); +}; + +export default ModerationQueue; diff --git a/app/(app)/admin/moderation/page.tsx b/app/(app)/admin/moderation/page.tsx new file mode 100644 index 00000000..e2dbe2af --- /dev/null +++ b/app/(app)/admin/moderation/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import ModerationQueue from "./_client"; + +export const metadata = { + title: "Moderation Queue - Codú Admin", + description: "Review and manage reported content", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx new file mode 100644 index 00000000..4e26b90f --- /dev/null +++ b/app/(app)/admin/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import AdminDashboard from "./_client"; + +export const metadata = { + title: "Admin Dashboard - Codú", + description: "Admin dashboard for managing Codú platform", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/sources/_client.tsx b/app/(app)/admin/sources/_client.tsx new file mode 100644 index 00000000..afa25e73 --- /dev/null +++ b/app/(app)/admin/sources/_client.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { + PlusIcon, + CheckCircleIcon, + XCircleIcon, + PauseCircleIcon, + TrashIcon, + ArrowPathIcon, + CloudArrowDownIcon, +} from "@heroicons/react/20/solid"; + +const statusColors = { + ACTIVE: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + PAUSED: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + ERROR: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", +}; + +const statusIcons = { + ACTIVE: CheckCircleIcon, + PAUSED: PauseCircleIcon, + ERROR: XCircleIcon, +}; + +const AdminSourcesPage = () => { + const [showAddForm, setShowAddForm] = useState(false); + const [syncingAll, setSyncingAll] = useState(false); + const [syncingSourceId, setSyncingSourceId] = useState(null); + const [formData, setFormData] = useState({ + name: "", + url: "", + websiteUrl: "", + logoUrl: "", + category: "", + }); + + const utils = api.useUtils(); + + // Sync all sources + const handleSyncAll = async () => { + setSyncingAll(true); + try { + const response = await fetch("/api/admin/sync-feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await response.json(); + + if (data.success) { + toast.success( + `Synced ${data.stats.sourcesProcessed} sources. Added ${data.stats.articlesAdded} articles.`, + ); + if (data.stats.errors.length > 0) { + toast.error(`${data.stats.errors.length} sources had errors`); + } + refetch(); + } else { + toast.error(data.error || "Sync failed"); + } + } catch { + toast.error("Failed to sync feeds"); + } finally { + setSyncingAll(false); + } + }; + + // Sync single source + const handleSyncSource = async (sourceId: number, sourceName: string) => { + setSyncingSourceId(sourceId); + try { + const response = await fetch("/api/admin/sync-feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId }), + }); + const data = await response.json(); + + if (data.success) { + toast.success( + `${sourceName}: Added ${data.stats.articlesAdded} articles`, + ); + refetch(); + } else { + toast.error(data.error || "Sync failed"); + } + } catch { + toast.error(`Failed to sync ${sourceName}`); + } finally { + setSyncingSourceId(null); + } + }; + + // Fetch sources with stats + const { data: sources, status, refetch } = api.feed.getSourceStats.useQuery(); + + // Mutations + const createSource = api.feed.createSource.useMutation({ + onSuccess: () => { + toast.success("Feed source added successfully"); + setShowAddForm(false); + setFormData({ + name: "", + url: "", + websiteUrl: "", + logoUrl: "", + category: "", + }); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to add feed source"); + }, + }); + + const updateSource = api.feed.updateSource.useMutation({ + onSuccess: () => { + toast.success("Feed source updated"); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to update feed source"); + }, + }); + + const deleteSource = api.feed.deleteSource.useMutation({ + onSuccess: () => { + toast.success("Feed source deleted"); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete feed source"); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createSource.mutate({ + name: formData.name, + url: formData.url, + websiteUrl: formData.websiteUrl || undefined, + logoUrl: formData.logoUrl || undefined, + category: formData.category || undefined, + }); + }; + + const handleStatusToggle = (id: number, currentStatus: string) => { + // Status is now lowercase in the new schema, but UpdateFeedSourceSchema still expects uppercase + const newStatus = currentStatus === "active" ? "PAUSED" : "ACTIVE"; + updateSource.mutate({ + id, + status: newStatus as "ACTIVE" | "PAUSED" | "ERROR", + }); + }; + + const handleDelete = (id: number, name: string) => { + if ( + confirm( + `Are you sure you want to delete "${name}"? This will also delete all associated articles.`, + ) + ) { + deleteSource.mutate({ id }); + } + }; + + return ( +
+
+
+

+ Feed Sources +

+

+ Manage RSS feed sources for the content aggregator +

+
+
+ + +
+
+ + {/* Add Source Form */} + {showAddForm && ( +
+

+ Add New Feed Source +

+
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + required + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="e.g., Josh Comeau's Blog" + /> +
+
+ + + setFormData({ ...formData, url: e.target.value }) + } + required + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com/rss.xml" + /> +
+
+ + + setFormData({ ...formData, websiteUrl: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com" + /> +
+
+ + + setFormData({ ...formData, logoUrl: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com/logo.png" + /> +
+
+ + + setFormData({ ...formData, category: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="e.g., frontend, react, career" + /> +
+
+ + +
+
+
+ )} + + {/* Sources Table */} + {status === "pending" && ( +
+ +
+ )} + + {status === "error" && ( +
+ Failed to load feed sources. Please refresh the page. +
+ )} + + {status === "success" && ( +
+ + + + + + + + + + + + + + {sources?.map((source) => { + const StatusIcon = + statusIcons[source.status as keyof typeof statusIcons]; + return ( + + + + + + + + + + ); + })} + +
+ Source + + Category + + Status + + Articles + + Last Fetched + + Errors + + Actions +
+
+ {source.sourceName} +
+
+ {/* Category would need to be fetched separately or added to stats */} + - + + + + {source.status} + + + {source.articleCount} + + {source.lastFetchedAt + ? new Date(source.lastFetchedAt).toLocaleDateString() + : "Never"} + + {source.errorCount} + +
+ + + +
+
+ {sources?.length === 0 && ( +
+ No feed sources yet. Add your first source above. +
+ )} +
+ )} +
+ ); +}; + +export default AdminSourcesPage; diff --git a/app/(app)/admin/sources/page.tsx b/app/(app)/admin/sources/page.tsx new file mode 100644 index 00000000..c16ee4dd --- /dev/null +++ b/app/(app)/admin/sources/page.tsx @@ -0,0 +1,19 @@ +import Content from "./_client"; +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; + +export const metadata = { + title: "Feed Sources - Admin", + description: "Manage RSS feed sources for the content aggregator", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + // Redirect non-admin users + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/users/_client.tsx b/app/(app)/admin/users/_client.tsx new file mode 100644 index 00000000..457ef548 --- /dev/null +++ b/app/(app)/admin/users/_client.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + MagnifyingGlassIcon, + ArrowLeftIcon, + ShieldExclamationIcon, + ShieldCheckIcon, +} from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { useSearchParams } from "next/navigation"; + +const UserManagement = () => { + const searchParams = useSearchParams(); + const initialFilter = searchParams?.get("filter"); + const [search, setSearch] = useState(""); + const [showBannedOnly, setShowBannedOnly] = useState( + initialFilter === "banned", + ); + const [banNote, setBanNote] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(null); + const utils = api.useUtils(); + + const { data: usersData, isLoading } = api.admin.getUsers.useQuery({ + search: search || undefined, + limit: 20, + }); + + const { data: bannedUsers } = api.admin.getBannedUsers.useQuery(undefined, { + enabled: showBannedOnly, + }); + + const { mutate: banUser, isPending: isBanning } = api.admin.ban.useMutation({ + onSuccess: () => { + toast.success("User banned successfully"); + utils.admin.getUsers.invalidate(); + utils.admin.getBannedUsers.invalidate(); + setSelectedUserId(null); + setBanNote(""); + }, + onError: () => { + toast.error("Failed to ban user"); + }, + }); + + const { mutate: unbanUser, isPending: isUnbanning } = + api.admin.unban.useMutation({ + onSuccess: () => { + toast.success("User unbanned successfully"); + utils.admin.getUsers.invalidate(); + utils.admin.getBannedUsers.invalidate(); + }, + onError: () => { + toast.error("Failed to unban user"); + }, + }); + + const handleBan = (userId: string) => { + if (!banNote.trim()) { + toast.error("Please provide a reason for the ban"); + return; + } + banUser({ userId, note: banNote }); + }; + + const handleUnban = (userId: string) => { + unbanUser({ userId }); + }; + + const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffDays < 1) return "today"; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; + return date.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }); + }; + + const displayUsers = showBannedOnly + ? bannedUsers?.map((b) => ({ + ...b.user, + isBanned: true, + bannedAt: b.createdAt, + banNote: b.note, + bannedBy: b.bannedBy, + })) + : usersData?.users; + + return ( +
+
+ + + +
+

+ User Management +

+

+ Search and manage platform users +

+
+
+ + {/* Search and Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-neutral-300 bg-white py-2 pl-10 pr-4 text-neutral-900 placeholder-neutral-400 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500" + /> +
+ + +
+ + {/* Users List */} +
+ {isLoading && ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {!isLoading && displayUsers?.length === 0 && ( +
+

+ No users found +

+

+ {search + ? "Try a different search term" + : showBannedOnly + ? "No banned users" + : "No users yet"} +

+
+ )} + + {displayUsers?.map((user) => ( +
+
+
+ +
+
+ + {user.name || user.username} + + {"role" in user && user.role === "ADMIN" && ( + + Admin + + )} + {"isBanned" in user && user.isBanned && ( + + Banned + + )} +
+

+ @{user.username} · {user.email} + {"createdAt" in user && user.createdAt && ( + <> · Joined {getRelativeTime(user.createdAt)} + )} +

+
+
+ +
+ {"isBanned" in user && user.isBanned ? ( + + ) : !("role" in user) || user.role !== "ADMIN" ? ( + selectedUserId === user.id ? ( +
+ setBanNote(e.target.value)} + className="w-48 rounded-lg border border-neutral-300 px-2 py-1 text-sm dark:border-neutral-600 dark:bg-neutral-700" + /> + + +
+ ) : ( + + ) + ) : null} +
+
+ + {/* Ban details if banned */} + {"banNote" in user && user.banNote && ( +
+

+ Ban reason:{" "} + {user.banNote} +

+ {"bannedBy" in user && user.bannedBy && ( +

+ Banned by @{user.bannedBy.username} +

+ )} +
+ )} +
+ ))} +
+
+ ); +}; + +export default UserManagement; diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx new file mode 100644 index 00000000..37d62e58 --- /dev/null +++ b/app/(app)/admin/users/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import UserManagement from "./_client"; + +export const metadata = { + title: "User Management - Codú Admin", + description: "Search and manage users", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/advertise/_client.tsx b/app/(app)/advertise/_client.tsx new file mode 100644 index 00000000..e9b83b28 --- /dev/null +++ b/app/(app)/advertise/_client.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { + HeroSection, + MetricsSection, + OfferingsSection, + SocialProofSection, + ContactSection, +} from "@/components/Sponsorship"; + +export function AdvertiseClient() { + return ( +
+ + + + + +
+ ); +} diff --git a/app/(app)/advertise/page.tsx b/app/(app)/advertise/page.tsx new file mode 100644 index 00000000..6e3fd36a --- /dev/null +++ b/app/(app)/advertise/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { AdvertiseClient } from "./_client"; + +export const metadata: Metadata = { + title: "Advertise with Codú - Reach Ireland's Developer Community", + description: + "Partner with Codú to reach 100,000+ monthly developer visits. Job postings, newsletter ads, event branding, and more. Connect with Ireland's largest web developer community.", + openGraph: { + title: "Advertise with Codú", + description: + "Connect your brand with Ireland's most engaged developer community. Sponsorship packages for job postings, newsletter advertising, and event branding.", + }, +}; + +export default function AdvertisePage() { + return ; +} diff --git a/app/(app)/alpha/sponsorship/page.tsx b/app/(app)/alpha/sponsorship/page.tsx deleted file mode 100644 index 82e4d0f3..00000000 --- a/app/(app)/alpha/sponsorship/page.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"use client"; - -import type { StaticImageData } from "next/image"; -import Image from "next/image"; -import Link from "next/link"; -import { useEffect } from "react"; - -import pic1 from "@/public/images/sponsors/pic1.png"; -import pic2 from "@/public/images/sponsors/pic2.png"; -import pic3 from "@/public/images/sponsors/pic3.png"; -import pic4 from "@/public/images/sponsors/pic4.png"; -import pic5 from "@/public/images/sponsors/pic5.png"; - -interface Image { - rotate: number; - src: StaticImageData; - alt: string; -} - -const images: Image[] = [ - { - src: pic1, - alt: "Audience watching a presentation", - rotate: 6.56, - }, - { - src: pic2, - alt: "Audience watching a presentation with the name 'Codú' on the screen", - rotate: -3.57, - }, - { - src: pic3, - alt: "Six people from Codú smiling at the camera", - rotate: 4.58, - }, - { - src: pic4, - alt: "Audience watching a presentation", - rotate: -4.35, - }, - { - src: pic5, - alt: "Audience smiling at the camera", - rotate: 6.56, - }, -]; - -const Sponsorship = () => { - useEffect(() => { - function handleScroll() { - document.body.style.setProperty("--scroll", String(window.scrollY)); - } - window.addEventListener("scroll", handleScroll); - - return () => { - window.removeEventListener("scroll", handleScroll); - document.body.style.removeProperty("--scroll"); - }; - }, []); - - return ( - <> -
-
-

- Become a{" "} - - Sponsor - -

-

- Reach thousands of developers every month! -

-
-
- {images.map((image) => ( -
- {image.alt} -
- ))} -
-
-
-

- Trusted by brands both large and small -

-
-

- Codú aims to create one of the largest coding communities - globally. Your funds go directly towards building the community - and a flourishing ecosystem. -

-

- We offer opportunities to sponsor hackathons, monthly{" "} - events, giveaways and online ad space. -

-

- - Contact us - {" "} - today to find out more. -

-
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-

- Let us help you amplify your brand. -

- - Find out more - -
-
-
- - ); -}; - -export default Sponsorship; diff --git a/app/(app)/articles/[slug]/page.tsx b/app/(app)/articles/[slug]/page.tsx index 0fc774c2..f0e3c28f 100644 --- a/app/(app)/articles/[slug]/page.tsx +++ b/app/(app)/articles/[slug]/page.tsx @@ -1,188 +1,31 @@ -import React from "react"; -import type { RenderableTreeNode } from "@markdoc/markdoc"; -import Markdoc from "@markdoc/markdoc"; -import Link from "next/link"; -import BioBar from "@/components/BioBar/BioBar"; -import { markdocComponents } from "@/markdoc/components"; -import { config } from "@/markdoc/config"; -import CommentsArea from "@/components/Comments/CommentsArea"; -import ArticleMenu from "@/components/ArticleMenu/ArticleMenu"; -import { headers } from "next/headers"; -import { notFound } from "next/navigation"; -import { getServerAuthSession } from "@/server/auth"; -import ArticleAdminPanel from "@/components/ArticleAdminPanel/ArticleAdminPanel"; -import { type Metadata } from "next"; -import { getPost } from "@/server/lib/posts"; -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 { permanentRedirect, notFound } from "next/navigation"; +import { db } from "@/server/db"; +import { post, user } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; type Props = { params: Promise<{ slug: string }> }; -export async function generateMetadata(props: Props): Promise { +// This page exists only to redirect from legacy /articles/[slug] URLs to the new /[username]/[slug] pattern +export default async function ArticlePage(props: Props) { const params = await props.params; - const slug = params.slug; - - const post = await getPost({ slug }); - - // @TODO revisit to give more defaults - // @TODO can we parse article and give recommended tags? - const tags = post?.tags.map((tag) => tag.tag.title); - - if (!post) return {}; - const host = (await headers()).get("host") || ""; - return { - title: `${post.title} | by ${post.user.name} | Codú`, - authors: { - name: post.user.name, - url: `https://www.${host}/${post.user.username}`, - }, - keywords: tags, - description: post.excerpt, - openGraph: { - description: post.excerpt, - type: "article", - images: [ - `/og?title=${encodeURIComponent( - post.title, - )}&readTime=${post.readTimeMins}&author=${encodeURIComponent( - post.user.name, - )}&date=${post.updatedAt}`, - ], - siteName: "Codú", - }, - twitter: { - description: post.excerpt, - images: [`/og?title=${encodeURIComponent(post.title)}`], - }, - alternates: { - canonical: post.canonicalUrl, - }, - }; -} - -const parseJSON = (str: string): JSONContent | null => { - try { - return JSON.parse(str); - } catch (e) { - return null; - } -}; - -const renderSanitizedTiptapContent = (jsonContent: JSONContent) => { - const rawHtml = generateHTML(jsonContent, [...RenderExtensions]); - // Sanitize the HTML using sanitize-html (server-safe, no jsdom dependency) - 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 ArticlePage = async (props: Props) => { - const params = await props.params; - const session = await getServerAuthSession(); const { slug } = params; - const host = (await headers()).get("host") || ""; - - const post = await getPost({ slug }); - - if (!post) { + const postRecord = await db + .select({ + slug: post.slug, + username: user.username, + }) + .from(post) + .leftJoin(user, eq(post.userId, user.id)) + .where(eq(post.slug, slug)) + .limit(1); + + if (!postRecord.length || !postRecord[0].username) { return notFound(); } - const parsedBody = parseJSON(post.body); - const isTiptapContent = parsedBody?.type === "doc"; - - let renderedContent: string | RenderableTreeNode; - - if (isTiptapContent && parsedBody) { - const jsonContent = parsedBody; - renderedContent = renderSanitizedTiptapContent(jsonContent); - } else { - const ast = Markdoc.parse(post.body); - const transformedContent = Markdoc.transform(ast, config); - renderedContent = Markdoc.renderers.react(transformedContent, React, { - components: markdocComponents, - }) as unknown as string; - } - - return ( - <> - -
-
- {!isTiptapContent &&

{post.title}

} + const { username } = postRecord[0]; - {isTiptapContent ? ( -
, - }} - className="tiptap-content" - /> - ) : ( -
- {Markdoc.renderers.react(renderedContent, React, { - components: markdocComponents, - })} -
- )} -
- {post.tags.length > 0 && ( -
- {post.tags.map(({ tag }) => ( - - {getCamelCaseFromLower(tag.title)} - - ))} -
- )} -
-
- - {post.showComments ? ( - - ) : ( -

- Comments are disabled for this post -

- )} -
- {session && session?.user?.role === "ADMIN" && ( - - )} - - ); -}; - -export default ArticlePage; + // Permanent redirect (308) to the new URL pattern + permanentRedirect(`/${username}/${slug}`); +} diff --git a/app/(app)/articles/_client.tsx b/app/(app)/articles/_client.tsx index 1eea1c83..faddbad7 100644 --- a/app/(app)/articles/_client.tsx +++ b/app/(app)/articles/_client.tsx @@ -1,45 +1,397 @@ "use client"; -import { Fragment, useEffect } from "react"; +import { Fragment, useEffect, useState } from "react"; import { TagIcon } from "@heroicons/react/20/solid"; -import ArticlePreview from "@/components/ArticlePreview/ArticlePreview"; -import ArticleLoading from "@/components/ArticlePreview/ArticleLoading"; +import { + ChevronUpIcon, + ChevronDownIcon, + BookmarkIcon, + ChatBubbleLeftIcon, + ShareIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; import { useInView } from "react-intersection-observer"; import { useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; import { api } from "@/server/trpc/react"; import SideBarSavedPosts from "@/components/SideBar/SideBarSavedPosts"; -import { useSession } from "next-auth/react"; +import { useSession, signIn } from "next-auth/react"; import { getCamelCaseFromLower } from "@/utils/utils"; import PopularTagsLoading from "@/components/PopularTags/PopularTagsLoading"; import CoduChallenge from "@/components/CoduChallenge/CoduChallenge"; +import { toast } from "sonner"; +import * as Sentry from "@sentry/nextjs"; +import { FeedFilters } from "@/components/Feed"; +import { useReportModal } from "@/components/ReportModal/ReportModal"; + +// Get relative time string +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" }); +}; + +// Article card component with voting +type ArticleCardProps = { + id: string; + slug: string; + title: string; + excerpt: string | null; + name: string; + username: string; + image: string; + date: string; + readTime: number; + upvotes: number; + downvotes: number; + userVote: "up" | "down" | null; + isBookmarked: boolean; + discussionCount?: number; +}; + +const ArticleCard = ({ + id, + slug, + title, + excerpt, + name, + username, + image, + date, + readTime, + upvotes, + downvotes, + userVote: initialUserVote, + isBookmarked: initialBookmarked, + discussionCount = 0, +}: ArticleCardProps) => { + const { data: session } = useSession(); + const utils = api.useUtils(); + const [userVote, setUserVote] = useState(initialUserVote); + const [votes, setVotes] = useState({ upvotes, downvotes }); + const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); + const { openReport } = useReportModal(); + + const { mutate: vote, status: voteStatus } = api.post.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: (error) => { + setUserVote(initialUserVote); + setVotes({ upvotes, downvotes }); + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.published.invalidate(); + }, + }); + + const { mutate: bookmark, status: bookmarkStatus } = + api.post.bookmark.useMutation({ + onMutate: async ({ setBookmarked }) => { + setIsBookmarked(setBookmarked); + }, + onError: (error) => { + setIsBookmarked(initialBookmarked); + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.myBookmarks.invalidate(); + }, + }); + + const handleVote = (voteType: "up" | "down" | null) => { + if (!session) { + signIn(); + return; + } + vote({ postId: id, voteType }); + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + bookmark({ postId: id, setBookmarked: !isBookmarked }); + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}/${username}/${slug}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const handleReport = () => { + if (!session) { + signIn(); + return; + } + openReport("post", id); + }; + + const relativeTime = getRelativeTime(date); + const score = votes.upvotes - votes.downvotes; + + return ( +
+ {/* Header row - author and metadata */} +
+ + + {name} + + + + {readTime > 0 && ( + <> + + {readTime} min read + + )} +
+ + {/* Title */} +

+ + {title} + +

+ + {/* Excerpt */} + {excerpt && ( +

+ {excerpt} +

+ )} + + {/* Action bar */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {/* Comments button */} + + + {discussionCount} + + + {/* Save button */} + + + {/* Share button */} + + + {/* Triple-dot menu */} + + + More options + + + + + + + + + + + + + +
+
+ ); +}; + +// Loading skeleton +const ArticleCardLoading = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +// Sort option types - unified between feed and articles +type UISortOption = "recent" | "trending" | "popular"; +type APISortOption = "newest" | "oldest" | "top" | "trending"; + +// Map UI sort to API sort +const sortUIToAPI: Record = { + recent: "newest", + trending: "trending", + popular: "top", +}; + +// Map API sort to UI sort (for URL params) +const sortAPIToUI: Record = { + newest: "recent", + oldest: "recent", // fallback + top: "popular", + trending: "trending", +}; + +const validUISorts: UISortOption[] = ["recent", "trending", "popular"]; const ArticlesPage = () => { const searchParams = useSearchParams(); const router = useRouter(); const { data: session } = useSession(); - const filter = searchParams?.get("filter"); + const sortParam = searchParams?.get("sort"); const dirtyTag = searchParams?.get("tag"); const tag = typeof dirtyTag === "string" ? dirtyTag : null; - type Filter = "newest" | "oldest" | "top"; - const filters: Filter[] = ["newest", "oldest", "top"]; - const getSortBy = () => { - if (typeof filter === "string") { - const hasFilter = filters.some((f) => f === filter); - if (hasFilter) return filter as Filter; - } - return "newest"; - }; + // Get UI sort from URL param + const uiSort: UISortOption = validUISorts.includes(sortParam as UISortOption) + ? (sortParam as UISortOption) + : "recent"; - const selectedSortFilter = getSortBy(); + // Convert to API sort for the query + const apiSort = sortUIToAPI[uiSort]; const { status, data, isFetchingNextPage, fetchNextPage, hasNextPage } = api.post.published.useInfiniteQuery( { limit: 15, - sort: selectedSortFilter, + sort: apiSort, tag, }, { @@ -55,7 +407,27 @@ const ArticlesPage = () => { if (inView && hasNextPage) { fetchNextPage(); } - }, [inView]); + }, [inView, hasNextPage, fetchNextPage]); + + // Handle filter changes + const handleSortChange = (newSort: UISortOption) => { + const params = new URLSearchParams(); + if (newSort !== "recent") params.set("sort", newSort); + if (tag) params.set("tag", tag); + const queryString = params.toString(); + router.push(`/articles${queryString ? `?${queryString}` : ""}`); + }; + + const handleTagChange = (newTag: string | null) => { + const params = new URLSearchParams(); + if (uiSort !== "recent") params.set("sort", uiSort); + if (newTag) params.set("tag", newTag); + const queryString = params.toString(); + router.push(`/articles${queryString ? `?${queryString}` : ""}`); + }; + + // Get tags list for the filter dropdown + const tagsList = tagsData?.data.map((t) => t.title.toLowerCase()) || []; return ( <> @@ -71,41 +443,27 @@ const ArticlesPage = () => { "Articles" )} -
- - -
+
{status === "error" && ( -
+
Something went wrong... Please refresh your page.
)} {status === "pending" && - Array.from({ length: 7 }, (_, i) => )} + Array.from({ length: 7 }, (_, i) => ( + + ))} {status === "success" && data.pages.map((page) => { return ( @@ -120,12 +478,13 @@ const ArticlesPage = () => { readTimeMins, id, currentUserBookmarkedPost, - likes, + upvotes, + downvotes, + userVote, }) => { - // TODO: Bump posts that were recently updated to the top and show that they were updated recently if (!published) return null; return ( - { username={user?.username || ""} image={user?.image || ""} date={published} - readTime={readTimeMins} - bookmarkedInitialState={currentUserBookmarkedPost} - likes={likes} + readTime={readTimeMins ?? 0} + upvotes={upvotes ?? 0} + downvotes={downvotes ?? 0} + userVote={userVote ?? null} + isBookmarked={currentUserBookmarkedPost} /> ); }, @@ -146,9 +507,16 @@ const ArticlesPage = () => { ); })} {status === "success" && !data.pages[0].posts.length && ( -

No results founds

+
+

+ No articles found +

+

+ Check back later for new content. +

+
)} - {isFetchingNextPage ? : null} + {isFetchingNextPage && } intersection observer marker diff --git a/app/(app)/articles/page.tsx b/app/(app)/articles/page.tsx index d106775c..d26d6e34 100644 --- a/app/(app)/articles/page.tsx +++ b/app/(app)/articles/page.tsx @@ -1,29 +1,7 @@ -import Content from "./_client"; +import { redirect } from "next/navigation"; -// @TODO - Add custom image for this page -export const metadata = { - title: "Codú - Read Our Web Developer Articles", - description: - "Codú is an open-source web developer community and blogging platform where readers can learn, and where writers can teach.", - keywords: [ - "programming", - "frontend", - "community", - "learn", - "programmer", - "article", - "Python", - "JavaScript", - "AWS", - "HTML", - "CSS", - "Tailwind", - "React", - "blog", - "backend", - ], -}; - -export default async function Page() { - return ; +// Redirect /articles to /feed?type=article +// The unified feed now handles all content types with filtering +export default function Page() { + redirect("/feed?type=article"); } diff --git a/app/(app)/company/[slug]/page.tsx b/app/(app)/company/[slug]/page.tsx index ec38a34f..6d6a9e73 100644 --- a/app/(app)/company/[slug]/page.tsx +++ b/app/(app)/company/[slug]/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { companies } from "./config"; export const metadata = { - title: "Ninedots Recruitment | Codu", + title: "Ninedots Recruitment | Codú", description: "Explore our community sponsors. Ninedots Recruitment connects top talent with leading companies in the tech industry.", }; diff --git a/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx b/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx new file mode 100644 index 00000000..bdcd647a --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx @@ -0,0 +1,211 @@ +"use client"; + +import Link from "next/link"; +import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; +import { api } from "@/server/trpc/react"; +import DiscussionArea from "@/components/Discussion/DiscussionArea"; +import { + ContentDetailLayout, + ContentTypeBadge, + ContentMetaHeader, + UnifiedActionBar, + SourceInfoCard, +} from "@/components/ContentDetail"; + +type Props = { + sourceSlug: string; + shortId: string; +}; + +// 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 (many RSS feeds provide http which won't load due to mixed content) +const ensureHttps = (url: string | null | undefined): string | null => { + if (!url) return null; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + return url; +}; + +const FeedArticlePage = ({ sourceSlug, shortId }: Props) => { + const { data: article, status } = api.feed.getBySlugAndShortId.useQuery({ + sourceSlug, + shortId, + }); + + const { data: discussionCount } = + api.discussion.getContentDiscussionCount.useQuery( + { contentId: article?.id ?? "" }, + { enabled: !!article?.id }, + ); + + const { mutate: trackClick } = api.feed.trackClick.useMutation(); + + 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 hostname = article.externalUrl + ? getHostname(article.externalUrl) + : null; + const shareUrl = + typeof window !== "undefined" + ? `${window.location.origin}/feed/${sourceSlug}/${shortId}` + : `/feed/${sourceSlug}/${shortId}`; + + return ( + + } + sideInfo={ + article.source && ( + + ) + } + discussion={} + > + {/* Content type badge */} +
+ +
+ + {/* Source/author info */} + + + {/* Title */} +

+ {article.title} +

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

+ {article.excerpt} +

+ )} + + {/* Thumbnail image */} + {ensureHttps(article.imageUrl) && ( + + +
+ + {hostname} +
+
+ )} + + {/* Read article CTA */} + {article.externalUrl && ( + + + Read Full Article at {hostname} + + )} +
+ ); +}; + +export default FeedArticlePage; diff --git a/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx b/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx new file mode 100644 index 00000000..2714b0aa --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx @@ -0,0 +1,77 @@ +import { notFound } from "next/navigation"; +import { db } from "@/server/db"; +import { posts, feed_sources } from "@/server/db/schema"; +import { eq, and } from "drizzle-orm"; +import type { Metadata } from "next"; +import FeedArticlePage from "./_client"; + +type Props = { + params: Promise<{ sourceSlug: string; shortId: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { sourceSlug, shortId } = await params; + + // Find the source by slug + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), + }); + + if (!source) { + return { title: "Post Not Found" }; + } + + // Find the post by slug (shortId is used as slug in new schema) and sourceId + const post = await db.query.posts.findFirst({ + where: and( + eq(posts.sourceId, source.id), + eq(posts.type, "link"), + eq(posts.slug, shortId), + ), + with: { + source: true, + }, + }); + + if (!post) { + return { title: "Post Not Found" }; + } + + return { + title: `${post.title} | Codú Feed`, + description: post.excerpt || `Discussion about ${post.title}`, + openGraph: { + title: post.title, + description: post.excerpt || `Discussion about ${post.title}`, + images: post.coverImage ? [post.coverImage] : undefined, + }, + }; +} + +export default async function Page({ params }: Props) { + const { sourceSlug, shortId } = await params; + + // Verify source exists + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), + }); + + if (!source) { + notFound(); + } + + // Verify post exists by slug (shortId is used as slug in new schema) + const post = await db.query.posts.findFirst({ + where: and( + eq(posts.sourceId, source.id), + eq(posts.type, "link"), + eq(posts.slug, shortId), + ), + }); + + if (!post) { + notFound(); + } + + return ; +} diff --git a/app/(app)/feed/[sourceSlug]/_client.tsx b/app/(app)/feed/[sourceSlug]/_client.tsx new file mode 100644 index 00000000..2006304d --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/_client.tsx @@ -0,0 +1,223 @@ +"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 SourceProfilePage = ({ 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 SourceProfilePage; diff --git a/app/(app)/feed/[sourceSlug]/page.tsx b/app/(app)/feed/[sourceSlug]/page.tsx new file mode 100644 index 00000000..194b6053 --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/page.tsx @@ -0,0 +1,49 @@ +import { notFound } from "next/navigation"; +import { db } from "@/server/db"; +import { feed_source } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; +import type { Metadata } from "next"; +import SourceProfilePage from "./_client"; + +type Props = { + params: Promise<{ sourceSlug: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { sourceSlug } = await params; + + const source = await db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + return { title: "Source Not Found" }; + } + + 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, + }, + }; +} + +export default async function Page({ params }: Props) { + const { sourceSlug } = await params; + + // Verify source exists + const source = await db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + notFound(); + } + + return ; +} diff --git a/app/(app)/feed/_client.tsx b/app/(app)/feed/_client.tsx new file mode 100644 index 00000000..5a427d5b --- /dev/null +++ b/app/(app)/feed/_client.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { Fragment, useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import { useSearchParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { api } from "@/server/trpc/react"; +import { useSession } from "next-auth/react"; +import { FeedItemLoading, FeedFilters } from "@/components/Feed"; +import { UnifiedContentCard } from "@/components/UnifiedContentCard"; +import { SavedItemCard } from "@/components/SavedItemCard"; + +type SortOption = "recent" | "trending" | "popular"; +type ContentType = + | "ARTICLE" + | "LINK" + | "QUESTION" + | "VIDEO" + | "DISCUSSION" + | null; + +const validSorts: SortOption[] = ["recent", "trending", "popular"]; +// Lowercase type values for URL params (converted to uppercase for API) +const validTypesLower: string[] = [ + "article", + "link", + "question", + "video", + "discussion", +]; + +const FeedPage = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const { data: session } = useSession(); + + // Get filter params from URL + const sortParam = searchParams?.get("sort"); + const categoryParam = searchParams?.get("category"); + const typeParam = searchParams?.get("type")?.toLowerCase(); + + // Validate sort param + const sort: SortOption = validSorts.includes(sortParam as SortOption) + ? (sortParam as SortOption) + : "recent"; + + const category = typeof categoryParam === "string" ? categoryParam : null; + + // Validate type param (URL uses lowercase, API uses uppercase) + const type: ContentType = validTypesLower.includes(typeParam || "") + ? (typeParam?.toUpperCase() as ContentType) + : null; + + // Fetch feed data with infinite scroll using the unified content API + const { status, data, isFetchingNextPage, fetchNextPage, hasNextPage } = + api.content.getFeed.useInfiniteQuery( + { + limit: 25, + sort, + type, + category, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + // Fetch categories for filter dropdown + const { data: categoriesData } = api.content.getCategories.useQuery(); + + // Intersection observer for infinite scroll + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage]); + + // Handle filter changes + const handleSortChange = (newSort: SortOption) => { + const params = new URLSearchParams(); + if (newSort !== "recent") params.set("sort", newSort); + if (category) params.set("category", category); + const queryString = params.toString(); + router.push(`/feed${queryString ? `?${queryString}` : ""}`); + }; + + const handleCategoryChange = (newCategory: string | null) => { + const params = new URLSearchParams(); + if (sort !== "recent") params.set("sort", sort); + if (newCategory) params.set("category", newCategory); + if (type) params.set("type", type); + const queryString = params.toString(); + router.push(`/feed${queryString ? `?${queryString}` : ""}`); + }; + + const handleTypeChange = (newType: ContentType) => { + const params = new URLSearchParams(); + if (sort !== "recent") params.set("sort", sort); + if (category) params.set("category", category); + // Use lowercase in URL params for cleaner URLs + if (newType) params.set("type", newType.toLowerCase()); + const queryString = params.toString(); + router.push(`/feed${queryString ? `?${queryString}` : ""}`); + }; + + return ( +
+ {/* Header */} +
+

+ Feed +

+ +
+ + {/* Main content grid */} +
+ {/* Feed items */} +
+
+ {status === "error" && ( +
+ Something went wrong loading the feed. Please refresh the page. +
+ )} + + {status === "pending" && + Array.from({ length: 7 }, (_, i) => )} + + {status === "success" && + data.pages.map((page, pageIndex) => ( + + {page.items.map((item) => ( + + ))} + + ))} + + {status === "success" && !data.pages[0].items.length && ( +
+

+ No content yet +

+

+ Check back soon for curated developer content. +

+
+ )} + + {isFetchingNextPage && } + + + intersection observer marker + +
+
+ + {/* Sidebar */} +
+ {/* About section - moved above topics */} +
+

+ About the Feed +

+

+ Curated developer content from across the web. Upvote articles you + find helpful, save them for later, and discover trending topics in + the developer community. +

+
+ + {/* Categories section */} +
+

+ Topics +

+
+ {categoriesData?.map((cat) => ( + + ))} +
+
+ + {/* Saved articles for logged in users */} + {session && ( +
+

+ Your Saved Articles +

+ +
+ )} +
+
+
+ ); +}; + +// Component to show saved articles preview in sidebar +const SavedArticlesPreview = () => { + const { data, status } = api.post.myBookmarks.useQuery({ limit: 5 }); + + if (status === "pending") { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + if (status === "error" || !data?.items?.length) { + return ( +

+ No saved articles yet. Save articles to read them later! +

+ ); + } + + // Map DB type to frontend type + const toFrontendType = (dbType: string | null): "POST" | "LINK" => { + if (dbType === "article") return "POST"; + return "LINK"; + }; + + return ( +
+ {data.items.slice(0, 3).map((item) => ( + + ))} + {data.items.length > 3 && ( + + View all saved + + )} +
+ ); +}; + +export default FeedPage; diff --git a/app/(app)/feed/page.tsx b/app/(app)/feed/page.tsx new file mode 100644 index 00000000..4d956207 --- /dev/null +++ b/app/(app)/feed/page.tsx @@ -0,0 +1,24 @@ +import Content from "./_client"; + +export const metadata = { + title: "Codú Feed - Curated Developer Content", + description: + "Discover the best developer articles from across the web, curated by the Codú community. Upvote, save, and find content that matters to you.", + keywords: [ + "developer feed", + "programming articles", + "tech news", + "web development", + "JavaScript", + "React", + "TypeScript", + "Python", + "DevOps", + "career", + "curated content", + ], +}; + +export default async function Page() { + return ; +} diff --git a/app/(app)/my-posts/_client.tsx b/app/(app)/my-posts/_client.tsx index f70e979c..95f7b0e3 100644 --- a/app/(app)/my-posts/_client.tsx +++ b/app/(app)/my-posts/_client.tsx @@ -42,15 +42,15 @@ const MyPosts = () => { const [selectedArticleToDelete, setSelectedArticleToDelete] = useState(); - const drafts = api.post.myDrafts.useQuery(); - const scheduled = api.post.myScheduled.useQuery(); - const published = api.post.myPublished.useQuery(); + const drafts = api.content.myDrafts.useQuery({}); + const scheduled = api.content.myScheduled.useQuery({}); + const publishedContent = api.content.myPublished.useQuery({}); - const { mutate, status: deleteStatus } = api.post.delete.useMutation({ + const { mutate, status: deleteStatus } = api.content.delete.useMutation({ onSuccess() { setSelectedArticleToDelete(undefined); drafts.refetch(); - published.refetch(); + publishedContent.refetch(); }, }); @@ -83,8 +83,8 @@ const MyPosts = () => { name: "Published", href: `?tab=${PUBLISHED}`, value: PUBLISHED, - data: published.data, - status: published.status, + data: publishedContent.data, + status: publishedContent.status, current: selectedTab === PUBLISHED, }, ]; @@ -124,17 +124,9 @@ const MyPosts = () => { {selectedTabData.status === "success" && selectedTabData.data?.map( - ({ - id, - title, - excerpt, - readTimeMins, - slug, - published, - updatedAt, - }) => { - const postStatus = published - ? getPostStatus(new Date(published)) + ({ id, title, excerpt, slug, publishedAt, updatedAt }) => { + const postStatus = publishedAt + ? getPostStatus(new Date(publishedAt)) : status.DRAFT; return (
{

{excerpt || "No excerpt yet... Write more to see one."}

-

- Read time so far: {readTimeMins} mins -

- {published && postStatus === status.SCHEDULED ? ( + {publishedAt && postStatus === status.SCHEDULED ? ( <> - {renderDate("Scheduled to publish on ", published)} + {renderDate( + "Scheduled to publish on ", + publishedAt, + )} - ) : published && postStatus === status.PUBLISHED ? ( + ) : publishedAt && postStatus === status.PUBLISHED ? ( <> - {/*If updatedAt is greater than published by more than on minutes show updated at else show published - as on updating published updatedAt is automatically updated and is greater than published*/} - {new Date(updatedAt).getTime() - - new Date(published).getTime() >= - 60000 ? ( + {/*If updatedAt is greater than publishedAt by more than one minute show updated at else show publishedAt + as on updating publishedAt updatedAt is automatically updated and is greater than publishedAt*/} + {updatedAt && + new Date(updatedAt).getTime() - + new Date(publishedAt).getTime() >= + 60000 ? ( <>{renderDate("Last updated on ", updatedAt)} ) : ( - <>{renderDate("Published on ", published)} + <>{renderDate("Published on ", publishedAt)} )} ) : postStatus === status.DRAFT ? ( - <>{renderDate("Last updated on ", updatedAt)} + <> + {updatedAt && + renderDate("Last updated on ", updatedAt)} + ) : null}
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index c539a68e..848dfca6 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -13,7 +13,7 @@ const Home = async () => { const session = await getServerAuthSession(); return ( - <> +
{session == null && ( @@ -29,10 +29,10 @@ const Home = async () => { Get started - Learn more + Browse feed
@@ -68,7 +68,7 @@ const Home = async () => {
- + ); }; diff --git a/app/(app)/saved/_client.tsx b/app/(app)/saved/_client.tsx index 7a5a68c0..0d63cea4 100644 --- a/app/(app)/saved/_client.tsx +++ b/app/(app)/saved/_client.tsx @@ -1,17 +1,22 @@ "use client"; -import ArticlePreview from "@/components/ArticlePreview/ArticlePreview"; import { api } from "@/server/trpc/react"; import PageHeading from "@/components/PageHeading/PageHeading"; -import ArticleLoading from "@/components/ArticlePreview/ArticleLoading"; +import { SavedItemCard } from "@/components/SavedItemCard"; + +// Map DB type to frontend type +const toFrontendType = (dbType: string | null): "POST" | "LINK" => { + if (dbType === "article") return "POST"; + return "LINK"; +}; const SavedPosts = () => { const { data: bookmarksData, refetch, status: bookmarkStatus, - } = api.post.myBookmarks.useQuery({}); - const bookmarks = bookmarksData?.bookmarks || []; + } = api.post.myBookmarks.useQuery({ limit: 100 }); + const bookmarks = bookmarksData?.items || []; const { mutate: bookmark } = api.post.bookmark.useMutation({ onSettled() { @@ -30,9 +35,14 @@ const SavedPosts = () => { return (
Saved items -
+
{bookmarkStatus === "pending" && - Array.from({ length: 7 }, (_, i) => )} + Array.from({ length: 7 }, (_, i) => ( +
+ ))} {bookmarkStatus === "error" && (

Something went wrong fetching your saved posts... Refresh the page. @@ -40,40 +50,22 @@ const SavedPosts = () => { )} {bookmarkStatus === "success" && - bookmarks.map( - ({ - id, - slug, - title, - excerpt, - user: { name, image, username }, - updatedAt, - readTimeMins, - }) => { - return ( - removeSavedItem(id), - }, - ]} - /> - ); - }, - )} + bookmarks.map((item) => ( + removeSavedItem(item.id)} + /> + ))} {bookmarkStatus === "success" && bookmarks?.length === 0 && (

diff --git a/app/(app)/sponsorship/page.tsx b/app/(app)/sponsorship/page.tsx index ecdc46b2..4ed5eaee 100644 --- a/app/(app)/sponsorship/page.tsx +++ b/app/(app)/sponsorship/page.tsx @@ -1,133 +1,5 @@ -import Link from "next/link"; +import { permanentRedirect } from "next/navigation"; -export const metadata = { - title: "Sponsor Codú - And Reach More Developers!", - description: - "The largest JavaScript and web developer community in Ireland! Reach thousands of developers in Ireland and beyond.", -}; - -const Sponsorship = () => { - return ( - <> -

-
-
-
-

- Support us -

-

- Sponsor Codú -

-

- Our work and events would not be possible without the support of - our partners. -

-

- {`Codú is the perfect place to show your company's support of open source software, find new developers and fund the next generation of avid learners.`} -

-

- {`Sponsors can post jobs to our network of thousands of developers (and growing), brand at our events, and advertise in our newsletter.`} -

-

-
-

- For more info contact us: -

- - partnerships@codu.co - -
-
-
-
-
-
- Developers working on their laptops at a table -
-
-
-
-
-

- Previous sponsors include -

-
-
-
- - StaticKit - -
-
- - Version 1 - -
-
- - Mirage - -
-
- - Transistor - -
-
- - Workcation - -
-
-
-
-
- - ); -}; - -export default Sponsorship; +export default function SponsorshipRedirect() { + permanentRedirect("/advertise"); +} diff --git a/app/(editor)/create/[[...paramsArr]]/_client.tsx b/app/(editor)/create/[[...paramsArr]]/_client.tsx index 24adf097..84abdf82 100644 --- a/app/(editor)/create/[[...paramsArr]]/_client.tsx +++ b/app/(editor)/create/[[...paramsArr]]/_client.tsx @@ -12,8 +12,8 @@ import { Transition, } from "@headlessui/react"; import { ChevronUpIcon } from "@heroicons/react/20/solid"; -import type { SavePostInput } from "@/schema/post"; -import { ConfirmPostSchema } from "@/schema/post"; +import type { UpdateContentInput } from "@/schema/content"; +import { ConfirmContentSchema } from "@/schema/content"; import { api } from "@/server/trpc/react"; import { removeMarkdown } from "@/utils/removeMarkdown"; import { useDebounce } from "@/hooks/useDebounce"; @@ -122,6 +122,17 @@ const Create = ({ session }: { session: Session | null }) => { useMarkdownHotkeys(textareaRef); useMarkdownShortcuts(textareaRef); + // Form input type for the editor + type EditorFormInput = { + id?: string; + title: string; + body: string; + excerpt?: string; + canonicalUrl?: string; + published?: string; + tags?: string[]; + }; + const { handleSubmit, register, @@ -131,7 +142,7 @@ const Create = ({ session }: { session: Session | null }) => { formState: { isDirty, errors }, setError, clearErrors, - } = useForm({ + } = useForm({ mode: "onSubmit", defaultValues: { title: "", @@ -147,14 +158,14 @@ const Create = ({ session }: { session: Session | null }) => { mutate: publish, status: publishStatus, data: publishData, - } = api.post.publish.useMutation({ + } = api.content.publish.useMutation({ onError(error) { toast.error("Error saving settings."); Sentry.captureException(error); }, }); - const { mutate: save, status: saveStatus } = api.post.update.useMutation({ + const { mutate: save, status: saveStatus } = api.content.update.useMutation({ onError(error) { // TODO: Add error messages from field validations toast.error("Error auto-saving"); @@ -166,15 +177,14 @@ const Create = ({ session }: { session: Session | null }) => { data: createData, isError, isSuccess, - } = api.post.create.useMutation(); + } = api.content.create.useMutation(); - // TODO get rid of this for standard get post - // Should be allowed get draft post through regular mechanism if you own it + // Fetch user's own content for editing const { data, status: dataStatus, isError: draftFetchError, - } = api.post.editDraft.useQuery( + } = api.content.editDraft.useQuery( { id: postId }, { enabled: !!postId && shouldRefetch, @@ -223,12 +233,28 @@ const Create = ({ session }: { session: Session | null }) => { const savePost = async () => { const formData = getFormData(); - // Don't include published time when saving post, handle separately in onSubmit - delete formData.published; + // Don't include published time when saving content, handle separately in onSubmit + const { published: _published, ...saveData } = formData; if (!formData.id) { - await create({ ...formData }); + // Create new content as ARTICLE type + await create({ + type: "POST", + title: saveData.title, + body: saveData.body, + excerpt: saveData.excerpt, + canonicalUrl: saveData.canonicalUrl, + tags: saveData.tags, + published: false, + }); } else { - await save({ ...formData, id: postId }); + await save({ + id: postId, + title: saveData.title, + body: saveData.body, + excerpt: saveData.excerpt, + canonicalUrl: saveData.canonicalUrl, + tags: saveData.tags, + }); setSavedTime( new Date().toLocaleString(undefined, { dateStyle: "medium", @@ -244,11 +270,11 @@ const Create = ({ session }: { session: Session | null }) => { saveStatus === "pending" || dataStatus === "pending"; - const currentPostStatus = data?.published - ? getPostStatus(new Date(data.published)) + const currentPostStatus = data?.publishedAt + ? getPostStatus(new Date(data.publishedAt)) : status.DRAFT; - const onSubmit = async (inputData: SavePostInput) => { + const onSubmit = async (inputData: EditorFormInput) => { // validate markdoc syntax const ast = Markdoc.parse(inputData.body); const errors = Markdoc.validate(ast, config).filter( @@ -266,15 +292,15 @@ const Create = ({ session }: { session: Session | null }) => { await savePost(); if (currentPostStatus === status.PUBLISHED) { - if (data) { - router.push(`/articles/${data.slug}`); + if (data && session?.user?.username) { + router.push(`/${session.user.username}/${data.slug}`); } return; } try { const formData = getFormData(); - ConfirmPostSchema.parse(formData); + ConfirmContentSchema.parse(formData); await publish({ id: postId, published: true, @@ -332,20 +358,22 @@ const Create = ({ session }: { session: Session | null }) => { useEffect(() => { if (!data) return; - const { body, excerpt, title, id, tags, published } = data; - setTags(tags.map(({ tag }) => tag.title)); + const { body, excerpt, title, id, tags, publishedAt } = data; + setTags(tags.map(({ tag }) => tag.title.toUpperCase())); reset({ - body, - excerpt, - title, + body: body || "", + excerpt: excerpt || "", + title: title || "", id, - published: published ? published : undefined, + published: publishedAt ? publishedAt : undefined, }); - setIsPostScheduled(published ? new Date(published) > new Date() : false); + setIsPostScheduled( + publishedAt ? new Date(publishedAt) > new Date() : false, + ); setPostStatus( - published ? getPostStatus(new Date(published)) : status.DRAFT, + publishedAt ? getPostStatus(new Date(publishedAt)) : status.DRAFT, ); - }, [data]); + }, [data, reset]); useEffect(() => { if ((title + body).length < 5) { @@ -377,14 +405,24 @@ const Create = ({ session }: { session: Session | null }) => { }, [title, body]); useEffect(() => { - if (publishStatus === "success" && publishData?.slug) { + if ( + publishStatus === "success" && + publishData?.slug && + session?.user?.username + ) { if (isPostScheduled) { router.push("/my-posts?tab=scheduled"); } else { - router.push(`/articles/${publishData.slug}`); + router.push(`/${session.user.username}/${publishData.slug}`); } } - }, [publishStatus, publishData, isPostScheduled, router]); + }, [ + publishStatus, + publishData, + isPostScheduled, + router, + session?.user?.username, + ]); const handlePublish = () => { if (isDisabled) return; @@ -490,8 +528,8 @@ const Create = ({ session }: { session: Session | null }) => {
{data && - (data.published === null || - new Date(data.published) > new Date()) && ( + (data.publishedAt === null || + new Date(data.publishedAt) > new Date()) && (