@@ -191,22 +190,22 @@ const UserManagement = () => {
{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)}>
@@ -220,7 +219,7 @@ const UserManagement = () => {
@@ -255,7 +254,7 @@ const UserManagement = () => {
) : (
setSelectedUserId(user.id)}
- className="flex items-center gap-1 rounded-lg border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
+ className="secondary-button px-3 py-1.5 text-sm text-danger"
>
Ban
@@ -267,13 +266,13 @@ const UserManagement = () => {
{/* Ban details if banned */}
{"banNote" in user && user.banNote && (
-
-
+
+
Ban reason:{" "}
{user.banNote}
{"bannedBy" in user && user.bannedBy && (
-
+
Banned by @{user.bannedBy.username}
)}
diff --git a/app/(app)/advertise/_client.tsx b/app/(app)/advertise/_client.tsx
deleted file mode 100644
index 4b69368c..00000000
--- a/app/(app)/advertise/_client.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-"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
deleted file mode 100644
index 6e3fd36a..00000000
--- a/app/(app)/advertise/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-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/new/[[...postIdArr]]/_client.tsx b/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx
index e262d709..41aa1579 100644
--- a/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx
+++ b/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx
@@ -133,7 +133,7 @@ const Create = () => {
{({ open }) => (
<>
-
+
View advanced settings
{
{
if (isDisabled) return;
await savePost();
@@ -178,11 +178,11 @@ const Create = () => {
{hasLoadingState ? (
<>
-
+
{"Saving"}
>
) : (
@@ -202,10 +202,10 @@ const Create = () => {
Fetching post data.
@@ -255,7 +255,7 @@ const Create = () => {
setOpen(true)}
aria-label={
data?.published
diff --git a/app/(app)/articles/_client.tsx b/app/(app)/articles/_client.tsx
deleted file mode 100644
index ed84021f..00000000
--- a/app/(app)/articles/_client.tsx
+++ /dev/null
@@ -1,547 +0,0 @@
-"use client";
-
-import { Fragment, useEffect, useState } from "react";
-import { TagIcon } from "@heroicons/react/20/solid";
-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, signIn } from "next-auth/react";
-import { getCamelCaseFromLower } from "@/utils/utils";
-import PopularTagsLoading from "@/components/PopularTags/PopularTagsLoading";
-import NewsletterCTA from "@/components/NewsletterCTA/NewsletterCTA";
-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 */}
-
- handleVote(userVote === "up" ? null : "up")}
- disabled={voteStatus === "pending"}
- className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "up"
- ? "text-green-500"
- : "text-neutral-400 dark:text-neutral-500"
- }`}
- aria-label="Upvote"
- >
-
-
- 0
- ? "text-green-500"
- : score < 0
- ? "text-red-500"
- : "text-neutral-400 dark:text-neutral-500"
- }`}
- >
- {score}
-
- handleVote(userVote === "down" ? null : "down")}
- disabled={voteStatus === "pending"}
- className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "down"
- ? "text-red-500"
- : "text-neutral-400 dark:text-neutral-500"
- }`}
- aria-label="Downvote"
- >
-
-
-
-
- {/* Comments button */}
-
-
-
{discussionCount}
-
-
- {/* Save button */}
-
- {isBookmarked ? (
-
- ) : (
-
- )}
-
- {isBookmarked ? "Saved" : "Save"}
-
-
-
- {/* Share button */}
-
-
- Share
-
-
- {/* Triple-dot menu */}
-
-
-
- );
-};
-
-// 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",
-};
-
-const validUISorts: UISortOption[] = ["recent", "trending", "popular"];
-
-const ArticlesPage = () => {
- const searchParams = useSearchParams();
- const router = useRouter();
- const { data: session } = useSession();
- const sortParam = searchParams?.get("sort");
- const dirtyTag = searchParams?.get("tag");
-
- const tag = typeof dirtyTag === "string" ? dirtyTag : null;
-
- // Get UI sort from URL param
- const uiSort: UISortOption = validUISorts.includes(sortParam as UISortOption)
- ? (sortParam as UISortOption)
- : "recent";
-
- // Convert to API sort for the query
- const apiSort = sortUIToAPI[uiSort];
-
- const { status, data, isFetchingNextPage, fetchNextPage, hasNextPage } =
- api.post.published.useInfiniteQuery(
- {
- limit: 15,
- sort: apiSort,
- tag,
- },
- {
- getNextPageParam: (lastPage) => lastPage.nextCursor,
- },
- );
-
- const { status: tagsStatus, data: tagsData } = api.tag.get.useQuery();
-
- const { ref, inView } = useInView();
-
- useEffect(() => {
- if (inView && hasNextPage) {
- fetchNextPage();
- }
- }, [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 (
- <>
-
-
-
- {typeof tag === "string" ? (
-
-
- {getCamelCaseFromLower(tag)}
-
- ) : (
- "Articles"
- )}
-
-
-
-
-
-
- {status === "error" && (
-
- Something went wrong... Please refresh your page.
-
- )}
- {status === "pending" &&
- Array.from({ length: 7 }, (_, i) => (
-
- ))}
- {status === "success" &&
- data.pages.map((page) => {
- return (
-
- {page.posts.map(
- ({
- slug,
- title,
- excerpt,
- user,
- published,
- readTimeMins,
- id,
- currentUserBookmarkedPost,
- upvotes,
- downvotes,
- userVote,
- }) => {
- if (!published) return null;
- return (
-
- );
- },
- )}
-
- );
- })}
- {status === "success" && !data.pages[0].posts.length && (
-
-
- No articles found
-
-
- Check back later for new content.
-
-
- )}
- {isFetchingNextPage && }
-
- intersection observer marker
-
-
-
-
-
-
- Popular topics
-
-
- {tagsStatus === "pending" &&
}
- {tagsStatus === "success" &&
- tagsData.data.map(({ title }) => (
-
- {getCamelCaseFromLower(title)}
-
- ))}
-
- {session && (
-
-
-
- )}
-
-
-
- >
- );
-};
-
-export default ArticlesPage;
diff --git a/app/(app)/articles/page.tsx b/app/(app)/articles/page.tsx
index d26d6e34..fc231498 100644
--- a/app/(app)/articles/page.tsx
+++ b/app/(app)/articles/page.tsx
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
-// Redirect /articles to /feed?type=article
+// Redirect /articles to /?type=article
// The unified feed now handles all content types with filtering
export default function Page() {
- redirect("/feed?type=article");
+ redirect("/?type=article");
}
diff --git a/app/(app)/auth/page.tsx b/app/(app)/auth/page.tsx
index c925dfa3..403ea681 100644
--- a/app/(app)/auth/page.tsx
+++ b/app/(app)/auth/page.tsx
@@ -27,7 +27,7 @@ export const PostAuthPage = (content: {
if (!mounted) return null;
return (
-
+
Codú
@@ -46,15 +46,15 @@ export const PostAuthPage = (content: {
-
+
{content.heading}{" "}
-