Skip to content

Commit 72f84be

Browse files
Feat/aggregator (#1322)
* feat(db): add content aggregator schema and Reddit-style voting - Add feed_source, aggregated_article, and aggregated_article_vote tables - Add ContentReport table for moderation with reason tags - Add post_vote table for Reddit-style upvote/downvote on posts - Add upvotes/downvotes columns to Post table - Migrate existing post likes to upvotes with data migration SQL - Update seed data for new content types * feat(api): add feed, content, and voting API routers - Add feed router with getFeed, vote, bookmark, getCategories procedures - Add content router for unified content management - Add discussion router with Reddit-style upvote/downvote - Add report router for content moderation queue - Update admin router with getStats, getUsers, getBannedUsers - Update post router with vote mutation and trending algorithm - Add hot score calculation for trending sort (recency + votes) - Update sidebarData to return upvotes/downvotes instead of likes * feat(feed): add curated developer content feed with RSS aggregation - Add FeedFilters, FeedItemAggregated, FeedItemLoading components - Add feed pages with source profiles and article detail views - Add RSS fetcher Lambda for content aggregation - Update Algolia indexer for feed content - Add og-image utility for Open Graph metadata - Support upvote/downvote, bookmarking, and external link tracking * feat(components): add unified Content card and Discussion voting - Add ContentCard component for rendering all content types - Add ContentMenu with report, share, and edit options - Update Discussion component with upvote/downvote - Update ReportModal with reason tag selector (spam, harassment, etc.) - Add loading skeletons for content cards * feat(admin): add admin dashboard, moderation queue, and user management - Add admin dashboard with platform stats overview - Add moderation queue for reviewing reported content - Add user management with search, ban/unban functionality - Display ban reasons and reviewer info - Filter reports by status (pending, actioned, dismissed) * feat(articles): unify filters and add Reddit-style voting UX - Replace article page sidebar with bottom action bar - Add ArticleActionBar with upvote/downvote, bookmark, share, report - Update articles list to use FeedFilters component - Add trending sort option with hot score algorithm - Unify filter dropdowns between feed and articles pages - Map sort options: recent/trending/popular * fix(seo): update branding from Codu to Codú in metadata * chore(deps): update dependencies and Next.js types * chore(deps): update dependencies and add RSS fetch utility - Update package dependencies - Add local fetch-rss.ts script for testing RSS feeds without Lambda * feat(db): add unified content system and sponsor inquiry schemas Consolidate migrations into unified content table supporting posts and aggregated links. Add sponsor inquiry schema for advertiser contact form. * feat(api): update routers for unified content and voting system Add sponsor inquiry router, update content/feed routers for unified content table, and add Reddit-style voting endpoints. * feat(components): add content detail, sponsorship, and unified content components Add ContentDetail components for article/link display, DiscussionEditor for rich text discussions, SavedItemCard, Sponsorship page sections, and UnifiedContentCard for the feed. * refactor(components): update components for unified content system Update existing components to support unified content table, add voting UI to feed items, and update sidebar/trending posts for new data model. * feat(pages): update pages for unified content and sponsorship Add advertise page, content detail pages for links/articles, update feed and article pages for unified content system, remove alpha sponsorship. * feat(utils): add content sync script and sponsor email template Add sync-content-table script for migrating data to unified content table, useCreateContent hook, and sponsor inquiry email template. * chore(config): update site config, sitemap, and RSS feed for unified content * feat(cdk): update lambdas for unified content indexing Update Algolia indexer and RSS fetcher lambdas to support unified content table structure. * chore(db): remove old incremental migrations * refactor: simplify component file names by removing redundant prefixes Rename verbose component files where folder context already provides clarity: - ContentDetail/: Layout, TypeBadge, MetaHeader, ActionBar - DiscussionEditor/: Editor, Toolbar, useEditor - Feed/: AggregatedItem, ItemLoading, Filters * feat: enhance article and source profile components with inline source info and author bios * refactor(votes): remove manual count updates, rely on DB triggers Database triggers (tr_post_vote_counts, tr_comment_vote_counts) now handle all vote count updates. Removing duplicate manual updates eliminates double-counting risk. * fix(votes): migrate components from feed.vote to content.vote Components were using api.feed.vote which lacked proper count updates. Migrated to api.content.vote for consistent behavior. * feat(scripts): add vote count reconciliation script Adds safety net script to verify and fix vote count discrepancies. Run with: npm run votes:reconcile (or votes:reconcile:dry for dry run) * feat(cdk): add daily vote reconciliation Lambda Scheduled Lambda runs at 5 AM UTC daily to reconcile vote counts, ensuring DB triggers are working correctly. Runs before Algolia indexing to ensure accurate counts before re-indexing. * feat(auth): auto-grant admin role based on ADMIN_EMAILS env var * feat(db): link feed sources to user profiles for author attribution * feat(db): add migrations for legacy Post/Comment data to new schema * refactor(feed): update RSS fetchers and sitemap to use source user profiles * chore: remove redundant inline comments from vote mutations * test(e2e): comprehensive test coverage for unified content system - Update articles.spec.ts for new feed URLs and voting UI - Create feed.spec.ts with 15 tests for filters, voting, bookmarks - Create saved.spec.ts with 6 tests for bookmark management - Create admin.spec.ts with 13 tests for admin dashboard - Add data-testid attributes to VoteButtons, Filters, ContentCard, DiscussionArea - Update e2e setup with admin user and new posts table schema - Add loggedInAsAdmin utility for admin test authentication Total: 63 passing e2e tests * Fixes for linting/prettier * chore: remove console logs and unused success messages from content creation hooks * refactor: optimize state management and error handling across components * fix: update redirect method and adjust sitemap routes for consistency * fix: update slug handling in metadata generation and comments area styling * fix: handle potential migration issues for feed sources and articles in sitemap generation * fix: adjust class order for HeartIcon in CommentsArea component * fix: add revalidation period for sitemap regeneration
1 parent 1bdefc7 commit 72f84be

File tree

146 files changed

+27026
-2701
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

146 files changed

+27026
-2701
lines changed

app/(app)/[username]/[slug]/_feedArticleContent.tsx

Lines changed: 439 additions & 0 deletions
Large diffs are not rendered by default.

app/(app)/[username]/[slug]/_linkContentDetail.tsx

Lines changed: 413 additions & 0 deletions
Large diffs are not rendered by default.

app/(app)/[username]/[slug]/page.tsx

Lines changed: 685 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { LinkIcon } from "@heroicons/react/20/solid";
5+
import { api } from "@/server/trpc/react";
6+
import { useInView } from "react-intersection-observer";
7+
import { useEffect } from "react";
8+
import { Heading } from "@/components/ui-components/heading";
9+
import { UnifiedContentCard } from "@/components/UnifiedContentCard";
10+
11+
type Props = {
12+
sourceSlug: string;
13+
};
14+
15+
// Get favicon URL from a website
16+
const getFaviconUrl = (
17+
websiteUrl: string | null | undefined,
18+
): string | null => {
19+
if (!websiteUrl) return null;
20+
try {
21+
const url = new URL(websiteUrl);
22+
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=128`;
23+
} catch {
24+
return null;
25+
}
26+
};
27+
28+
function getDomainFromUrl(url: string) {
29+
const domain = url.replace(/(https?:\/\/)?(www.)?/i, "");
30+
if (domain[domain.length - 1] === "/") {
31+
return domain.slice(0, domain.length - 1);
32+
}
33+
return domain;
34+
}
35+
36+
const SourceProfileContent = ({ sourceSlug }: Props) => {
37+
const { ref: loadMoreRef, inView } = useInView({ threshold: 0 });
38+
39+
const { data: source, status: sourceStatus } =
40+
api.feed.getSourceBySlug.useQuery({ slug: sourceSlug });
41+
42+
const {
43+
data: articlesData,
44+
status: articlesStatus,
45+
fetchNextPage,
46+
hasNextPage,
47+
isFetchingNextPage,
48+
} = api.feed.getArticlesBySource.useInfiniteQuery(
49+
{ sourceSlug, sort: "recent", limit: 25 },
50+
{
51+
getNextPageParam: (lastPage) => lastPage.nextCursor,
52+
},
53+
);
54+
55+
useEffect(() => {
56+
if (inView && hasNextPage && !isFetchingNextPage) {
57+
fetchNextPage();
58+
}
59+
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
60+
61+
if (sourceStatus === "pending") {
62+
return (
63+
<div className="mx-auto max-w-2xl px-4 text-black dark:text-white">
64+
<main className="pt-6 sm:flex">
65+
<div className="mr-4 flex-shrink-0 self-center">
66+
<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" />
67+
</div>
68+
<div className="flex flex-col justify-center">
69+
<div className="mb-2 h-6 w-48 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
70+
<div className="h-4 w-32 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
71+
</div>
72+
</main>
73+
</div>
74+
);
75+
}
76+
77+
if (sourceStatus === "error" || !source) {
78+
return (
79+
<div className="mx-auto max-w-2xl px-4 py-8 text-black dark:text-white">
80+
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950">
81+
<h1 className="text-lg font-semibold text-red-700 dark:text-red-300">
82+
Source Not Found
83+
</h1>
84+
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
85+
This source may have been removed or the link is invalid.
86+
</p>
87+
<Link
88+
href="/feed"
89+
className="mt-4 inline-block text-sm text-blue-500 hover:underline"
90+
>
91+
Back to Feed
92+
</Link>
93+
</div>
94+
</div>
95+
);
96+
}
97+
98+
const faviconUrl = getFaviconUrl(source.websiteUrl);
99+
const articles = articlesData?.pages.flatMap((page) => page.articles) ?? [];
100+
101+
return (
102+
<>
103+
<div className="text-900 mx-auto max-w-2xl px-4 text-black dark:text-white">
104+
{/* Profile header - matching user profile pattern exactly */}
105+
<main className="pt-6 sm:flex">
106+
<div className="mr-4 flex-shrink-0 self-center">
107+
{source.logoUrl ? (
108+
<img
109+
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"
110+
alt={`Avatar for ${source.name}`}
111+
src={source.logoUrl}
112+
/>
113+
) : faviconUrl ? (
114+
<img
115+
className="mb-2 h-20 w-20 rounded-full sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32"
116+
alt={`Avatar for ${source.name}`}
117+
src={faviconUrl}
118+
/>
119+
) : (
120+
<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">
121+
{source.name?.charAt(0).toUpperCase() || "?"}
122+
</div>
123+
)}
124+
</div>
125+
<div className="flex flex-col justify-center">
126+
<h1 className="mb-0 text-lg font-bold md:text-xl">{source.name}</h1>
127+
<h2 className="text-sm font-bold text-neutral-500 dark:text-neutral-400">
128+
@{sourceSlug}
129+
</h2>
130+
<p className="mt-1">{source.description || ""}</p>
131+
{source.websiteUrl && (
132+
<Link
133+
href={source.websiteUrl}
134+
className="flex flex-row items-center"
135+
target="_blank"
136+
rel="noopener noreferrer"
137+
>
138+
<LinkIcon className="mr-2 h-5 text-neutral-500 dark:text-neutral-400" />
139+
<p className="mt-1 text-blue-500">
140+
{getDomainFromUrl(source.websiteUrl)}
141+
</p>
142+
</Link>
143+
)}
144+
</div>
145+
</main>
146+
147+
{/* Articles header - matching user profile */}
148+
<div className="mx-auto mt-4 sm:max-w-2xl lg:max-w-5xl">
149+
<Heading level={1}>{`Articles (${source.articleCount})`}</Heading>
150+
</div>
151+
152+
{/* Articles list using UnifiedContentCard */}
153+
<div>
154+
{articlesStatus === "pending" ? (
155+
<div className="space-y-4">
156+
{[...Array(5)].map((_, i) => (
157+
<div
158+
key={i}
159+
className="animate-pulse rounded-lg border border-neutral-200 p-3 dark:border-neutral-700"
160+
>
161+
<div className="mb-2 h-4 w-1/4 rounded bg-neutral-200 dark:bg-neutral-700" />
162+
<div className="mb-2 h-5 w-3/4 rounded bg-neutral-200 dark:bg-neutral-700" />
163+
<div className="h-4 w-1/2 rounded bg-neutral-200 dark:bg-neutral-700" />
164+
</div>
165+
))}
166+
</div>
167+
) : articles.length === 0 ? (
168+
<p className="py-4 font-medium">Nothing published yet... 🥲</p>
169+
) : (
170+
<>
171+
{articles.map((article) => {
172+
// Use slug for SEO-friendly URLs, fallback to shortId for legacy articles
173+
const articleSlug = article.slug || article.shortId;
174+
175+
return (
176+
<UnifiedContentCard
177+
key={article.id}
178+
type="LINK"
179+
id={article.id}
180+
title={article.title}
181+
excerpt={article.excerpt}
182+
slug={articleSlug}
183+
imageUrl={article.imageUrl}
184+
externalUrl={article.url}
185+
publishedAt={article.publishedAt}
186+
upvotes={article.upvotes}
187+
downvotes={article.downvotes}
188+
userVote={article.userVote}
189+
isBookmarked={article.isBookmarked}
190+
discussionCount={0}
191+
source={{
192+
name: source.name,
193+
slug: sourceSlug,
194+
logo: source.logoUrl,
195+
websiteUrl: source.websiteUrl,
196+
}}
197+
linkAuthor={article.author}
198+
/>
199+
);
200+
})}
201+
202+
{/* Load more trigger */}
203+
<div ref={loadMoreRef} className="py-4 text-center">
204+
{isFetchingNextPage && (
205+
<div className="text-sm text-neutral-500 dark:text-neutral-400">
206+
Loading more articles...
207+
</div>
208+
)}
209+
{!hasNextPage && articles.length > 0 && (
210+
<div className="text-sm text-neutral-500 dark:text-neutral-400">
211+
No more articles
212+
</div>
213+
)}
214+
</div>
215+
</>
216+
)}
217+
</div>
218+
</div>
219+
</>
220+
);
221+
};
222+
223+
export default SourceProfileContent;

app/(app)/[username]/_usernameClient.tsx

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import * as Sentry from "@sentry/nextjs";
44
import React from "react";
55
import Link from "next/link";
6-
import ArticlePreview from "@/components/ArticlePreview/ArticlePreview";
6+
import { UnifiedContentCard } from "@/components/UnifiedContentCard";
77
import { LinkIcon } from "@heroicons/react/20/solid";
88
import { api } from "@/server/trpc/react";
99
import { useRouter, useSearchParams } from "next/navigation";
@@ -16,11 +16,11 @@ type Props = {
1616
isOwner: boolean;
1717
profile: {
1818
posts: {
19-
published: string | null;
19+
publishedAt: string | null;
2020
title: string;
21-
excerpt: string;
21+
excerpt: string | null;
2222
slug: string;
23-
readTimeMins: number;
23+
readingTime: number | null;
2424
id: string;
2525
}[];
2626
accountLocked: boolean;
@@ -127,36 +127,38 @@ const Profile = ({ profile, isOwner, session }: Props) => {
127127
slug,
128128
title,
129129
excerpt,
130-
readTimeMins,
131-
published,
130+
readingTime,
131+
publishedAt,
132132
id,
133133
}) => {
134-
if (!published) return;
134+
if (!publishedAt) return null;
135135
return (
136-
<ArticlePreview
137-
key={slug}
138-
slug={slug}
139-
title={title}
140-
excerpt={excerpt}
141-
name={name}
142-
username={username || ""}
143-
image={image}
144-
date={published}
145-
readTime={readTimeMins}
146-
menuOptions={
147-
isOwner
148-
? [
149-
{
150-
label: "Edit",
151-
href: `/create/${id}`,
152-
postId: id,
153-
},
154-
]
155-
: undefined
156-
}
157-
showBookmark={!isOwner}
158-
id={id}
159-
/>
136+
<div key={slug} className="relative">
137+
<UnifiedContentCard
138+
type="POST"
139+
id={id}
140+
title={title}
141+
excerpt={excerpt}
142+
slug={slug}
143+
publishedAt={publishedAt}
144+
readTimeMins={readingTime}
145+
upvotes={0}
146+
downvotes={0}
147+
author={{
148+
name: name,
149+
username: username || "",
150+
image: image,
151+
}}
152+
/>
153+
{isOwner && (
154+
<Link
155+
href={`/create/${id}`}
156+
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"
157+
>
158+
Edit
159+
</Link>
160+
)}
161+
</div>
160162
);
161163
},
162164
)

0 commit comments

Comments
 (0)