Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d2b5fd7
feat(db): add content aggregator schema and Reddit-style voting
NiallJoeMaher Jan 4, 2026
34a5c12
feat(api): add feed, content, and voting API routers
NiallJoeMaher Jan 4, 2026
f223195
feat(feed): add curated developer content feed with RSS aggregation
NiallJoeMaher Jan 4, 2026
3ad783c
feat(components): add unified Content card and Discussion voting
NiallJoeMaher Jan 4, 2026
42720c0
feat(admin): add admin dashboard, moderation queue, and user management
NiallJoeMaher Jan 4, 2026
394b8b3
feat(articles): unify filters and add Reddit-style voting UX
NiallJoeMaher Jan 4, 2026
d6ce206
fix(seo): update branding from Codu to Codú in metadata
NiallJoeMaher Jan 4, 2026
c3493e7
chore(deps): update dependencies and Next.js types
NiallJoeMaher Jan 4, 2026
dc999d8
chore(deps): update dependencies and add RSS fetch utility
NiallJoeMaher Jan 4, 2026
d4a7a12
feat(db): add unified content system and sponsor inquiry schemas
NiallJoeMaher Jan 6, 2026
6675b9a
feat(api): update routers for unified content and voting system
NiallJoeMaher Jan 6, 2026
731d19d
feat(components): add content detail, sponsorship, and unified conten…
NiallJoeMaher Jan 6, 2026
d3fd206
refactor(components): update components for unified content system
NiallJoeMaher Jan 6, 2026
2ce4be1
feat(pages): update pages for unified content and sponsorship
NiallJoeMaher Jan 6, 2026
4070201
feat(utils): add content sync script and sponsor email template
NiallJoeMaher Jan 6, 2026
92ea9db
chore(config): update site config, sitemap, and RSS feed for unified …
NiallJoeMaher Jan 6, 2026
1bdfcb6
feat(cdk): update lambdas for unified content indexing
NiallJoeMaher Jan 6, 2026
f49bd90
chore(db): remove old incremental migrations
NiallJoeMaher Jan 6, 2026
fab704b
refactor: simplify component file names by removing redundant prefixes
NiallJoeMaher Jan 6, 2026
d4cf737
feat: enhance article and source profile components with inline sourc…
NiallJoeMaher Jan 6, 2026
4b61206
refactor(votes): remove manual count updates, rely on DB triggers
NiallJoeMaher Jan 6, 2026
4ae6732
fix(votes): migrate components from feed.vote to content.vote
NiallJoeMaher Jan 6, 2026
d8794b7
feat(scripts): add vote count reconciliation script
NiallJoeMaher Jan 6, 2026
bcec7d1
feat(cdk): add daily vote reconciliation Lambda
NiallJoeMaher Jan 6, 2026
e2d0cdf
feat(auth): auto-grant admin role based on ADMIN_EMAILS env var
NiallJoeMaher Jan 6, 2026
a0a2abd
feat(db): link feed sources to user profiles for author attribution
NiallJoeMaher Jan 6, 2026
72b5599
feat(db): add migrations for legacy Post/Comment data to new schema
NiallJoeMaher Jan 6, 2026
e9ad2be
refactor(feed): update RSS fetchers and sitemap to use source user p…
NiallJoeMaher Jan 6, 2026
db7ee63
chore: remove redundant inline comments from vote mutations
NiallJoeMaher Jan 6, 2026
d189eba
test(e2e): comprehensive test coverage for unified content system
NiallJoeMaher Jan 6, 2026
3488618
Fixes for linting/prettier
NiallJoeMaher Jan 6, 2026
01e3a47
chore: remove console logs and unused success messages from content c…
NiallJoeMaher Jan 6, 2026
44175bc
refactor: optimize state management and error handling across components
NiallJoeMaher Jan 6, 2026
e978387
fix: update redirect method and adjust sitemap routes for consistency
NiallJoeMaher Jan 6, 2026
92acded
fix: update slug handling in metadata generation and comments area st…
NiallJoeMaher Jan 6, 2026
1e2d8dc
fix: handle potential migration issues for feed sources and articles …
NiallJoeMaher Jan 6, 2026
f045ba1
fix: adjust class order for HeartIcon in CommentsArea component
NiallJoeMaher Jan 6, 2026
2e28b32
fix: add revalidation period for sitemap regeneration
NiallJoeMaher Jan 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
439 changes: 439 additions & 0 deletions app/(app)/[username]/[slug]/_feedArticleContent.tsx

Large diffs are not rendered by default.

413 changes: 413 additions & 0 deletions app/(app)/[username]/[slug]/_linkContentDetail.tsx

Large diffs are not rendered by default.

685 changes: 685 additions & 0 deletions app/(app)/[username]/[slug]/page.tsx

Large diffs are not rendered by default.

223 changes: 223 additions & 0 deletions app/(app)/[username]/_sourceProfileClient.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-2xl px-4 text-black dark:text-white">
<main className="pt-6 sm:flex">
<div className="mr-4 flex-shrink-0 self-center">
<div className="mb-2 h-20 w-20 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-700 sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32" />
</div>
<div className="flex flex-col justify-center">
<div className="mb-2 h-6 w-48 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
<div className="h-4 w-32 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
</div>
</main>
</div>
);
}

if (sourceStatus === "error" || !source) {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-black dark:text-white">
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950">
<h1 className="text-lg font-semibold text-red-700 dark:text-red-300">
Source Not Found
</h1>
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
This source may have been removed or the link is invalid.
</p>
<Link
href="/feed"
className="mt-4 inline-block text-sm text-blue-500 hover:underline"
>
Back to Feed
</Link>
</div>
</div>
);
}

const faviconUrl = getFaviconUrl(source.websiteUrl);
const articles = articlesData?.pages.flatMap((page) => page.articles) ?? [];

return (
<>
<div className="text-900 mx-auto max-w-2xl px-4 text-black dark:text-white">
{/* Profile header - matching user profile pattern exactly */}
<main className="pt-6 sm:flex">
<div className="mr-4 flex-shrink-0 self-center">
{source.logoUrl ? (
<img
className="mb-2 h-20 w-20 rounded-full object-cover sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32"
alt={`Avatar for ${source.name}`}
src={source.logoUrl}
/>
) : faviconUrl ? (
<img
className="mb-2 h-20 w-20 rounded-full sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32"
alt={`Avatar for ${source.name}`}
src={faviconUrl}
/>
) : (
<div className="mb-2 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-orange-400 to-orange-600 text-3xl font-bold text-white sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32 lg:text-4xl">
{source.name?.charAt(0).toUpperCase() || "?"}
</div>
)}
</div>
<div className="flex flex-col justify-center">
<h1 className="mb-0 text-lg font-bold md:text-xl">{source.name}</h1>
<h2 className="text-sm font-bold text-neutral-500 dark:text-neutral-400">
@{sourceSlug}
</h2>
<p className="mt-1">{source.description || ""}</p>
{source.websiteUrl && (
<Link
href={source.websiteUrl}
className="flex flex-row items-center"
target="_blank"
rel="noopener noreferrer"
>
<LinkIcon className="mr-2 h-5 text-neutral-500 dark:text-neutral-400" />
<p className="mt-1 text-blue-500">
{getDomainFromUrl(source.websiteUrl)}
</p>
</Link>
)}
</div>
</main>

{/* Articles header - matching user profile */}
<div className="mx-auto mt-4 sm:max-w-2xl lg:max-w-5xl">
<Heading level={1}>{`Articles (${source.articleCount})`}</Heading>
</div>

{/* Articles list using UnifiedContentCard */}
<div>
{articlesStatus === "pending" ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="animate-pulse rounded-lg border border-neutral-200 p-3 dark:border-neutral-700"
>
<div className="mb-2 h-4 w-1/4 rounded bg-neutral-200 dark:bg-neutral-700" />
<div className="mb-2 h-5 w-3/4 rounded bg-neutral-200 dark:bg-neutral-700" />
<div className="h-4 w-1/2 rounded bg-neutral-200 dark:bg-neutral-700" />
</div>
))}
</div>
) : articles.length === 0 ? (
<p className="py-4 font-medium">Nothing published yet... 🥲</p>
) : (
<>
{articles.map((article) => {
// Use slug for SEO-friendly URLs, fallback to shortId for legacy articles
const articleSlug = article.slug || article.shortId;

return (
<UnifiedContentCard
key={article.id}
type="LINK"
id={article.id}
title={article.title}
excerpt={article.excerpt}
slug={articleSlug}
imageUrl={article.imageUrl}
externalUrl={article.url}
publishedAt={article.publishedAt}
upvotes={article.upvotes}
downvotes={article.downvotes}
userVote={article.userVote}
isBookmarked={article.isBookmarked}
discussionCount={0}
source={{
name: source.name,
slug: sourceSlug,
logo: source.logoUrl,
websiteUrl: source.websiteUrl,
}}
linkAuthor={article.author}
/>
);
})}

{/* Load more trigger */}
<div ref={loadMoreRef} className="py-4 text-center">
{isFetchingNextPage && (
<div className="text-sm text-neutral-500 dark:text-neutral-400">
Loading more articles...
</div>
)}
{!hasNextPage && articles.length > 0 && (
<div className="text-sm text-neutral-500 dark:text-neutral-400">
No more articles
</div>
)}
</div>
</>
)}
</div>
</div>
</>
);
};

export default SourceProfileContent;
64 changes: 33 additions & 31 deletions app/(app)/[username]/_usernameClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<ArticlePreview
key={slug}
slug={slug}
title={title}
excerpt={excerpt}
name={name}
username={username || ""}
image={image}
date={published}
readTime={readTimeMins}
menuOptions={
isOwner
? [
{
label: "Edit",
href: `/create/${id}`,
postId: id,
},
]
: undefined
}
showBookmark={!isOwner}
id={id}
/>
<div key={slug} className="relative">
<UnifiedContentCard
type="POST"
id={id}
title={title}
excerpt={excerpt}
slug={slug}
publishedAt={publishedAt}
readTimeMins={readingTime}
upvotes={0}
downvotes={0}
author={{
name: name,
username: username || "",
image: image,
}}
/>
{isOwner && (
<Link
href={`/create/${id}`}
className="absolute right-2 top-2 rounded-md bg-neutral-100 px-2 py-1 text-xs font-medium text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
>
Edit
</Link>
)}
</div>
);
},
)
Expand Down
Loading
Loading