Skip to content

Commit 35b5baa

Browse files
Feat/cleanup and admin features (#1326)
* feat: unified tag system with Medium-style UX and admin dashboard - Extend tag schema with slug, description, and cached postCount - Add tag_merge_suggestions table for AI-powered duplicate detection - Enhance TagInput with autocomplete showing post counts - Add PopularTagsSidebar component for feed filtering - Implement tag filtering in feed via URL params (?tag=javascript) - Create admin dashboard at /admin/tags with: - Searchable/sortable tag table - Tag editing (title, slug, description) - Merge mode for consolidating duplicate tags - AI merge suggestions panel (ready for integration) - Add migration to convert RSS source categories to tags - Add tag.search, tag.getPopular, tag.getOrCreate API endpoints - Add admin endpoints: getAdminStats, update, mergeTags, recalculateCounts * feat: enhance admin dashboard with new stats and logo upload functionality * fix: restore notifications functionality and update card styling - Add missing postId and commentId to notification inserts in comment and discussion routers - Update notification table foreign keys to reference new posts/comments tables instead of legacy Post/Comment tables - Change postId column type from text to uuid and commentId from integer to uuid to match new table schemas - Update notification cards with modern grey/white styling (rounded corners, proper borders, hover effects) - Add E2E tests for notification display and creation flow - Add seed script for testing notifications locally * feat: add JSON-LD structured data for SEO and improve admin sources UI Add JsonLd component and structured data schemas (Article, NewsArticle, Person, Organization, WebSite, Breadcrumb) Integrate JSON-LD into homepage, layout, user profiles, and article pages Update robots.txt to block problematic paths and allow AI crawlers (GPTBot, Claude-Web, PerplexityBot, etc.) Add data completeness badge to admin sources showing missing fields Improve admin sources action button styling Remove debug blue background from table styles * Refactor code for improved readability and consistency - Added missing newline at the end of _journal.json file. - Simplified import statements in notifications.spec.ts and structured-data/index.ts for better clarity. - Reformatted code in seed-notifications.ts for consistent indentation and readability. - Enhanced readability of import statements in content.ts and tag.ts by aligning them properly. - Updated test expectations in notifications.spec.ts to improve clarity and maintainability. * chore: remove outdated development test suite documentation * fix: update notification migration to convert column types before adding FK constraints The migration was failing because postId (text) and commentId (integer) couldn't reference posts.id and comments.id (both uuid). This updates the migration to: - Convert postId from text to uuid using legacy_post_id lookup - Convert commentId from integer to uuid using legacy_comment_id lookup - Make the migration idempotent (safe to run multiple times) - Update snapshot to reflect the new column types Co-Authored-By: Claude Opus 4.5 <[email protected]> * test: enhance e2e tests with load state waits and improve visibility checks * test: enhance e2e tests by waiting for TRPC responses to improve reliability * refactor: streamline error handling and improve test reliability across actions and notifications * test: improve notification and article tests by enhancing reliability and reducing dependency on TRPC responses * test: enhance reliability of article bookmarking and loading tests by waiting for network idle state * refactor: update load state waits from "networkidle" to "domcontentloaded" for improved test reliability * Fix E2E test failures and hide banned users' posts from feeds Fix editor "Saved" indicator for new posts by setting savedTime in the CREATE branch. Add test.slow() for Firefox publish test timeout. Replace force-click bookmark buttons with proper TRPC mutation response waits. Hide banned users' posts by updating status to draft on ban and restoring on unban. Add defense-in-depth LEFT JOIN on banned_users to post and feed query endpoints. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix Prettier formatting in E2E test files Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent d8add79 commit 35b5baa

57 files changed

Lines changed: 13317 additions & 322 deletions

Some content is hidden

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

.claude/commands/do-test.md

Lines changed: 0 additions & 52 deletions
This file was deleted.

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"typescript.tsdk": "node_modules/typescript/lib"
2+
"typescript.tsdk": "node_modules/typescript/lib",
3+
"snyk.advanced.autoSelectOrganization": true
34
}

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

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,6 @@ type Props = {
2121
contentSlug: string;
2222
};
2323

24-
// Get favicon URL from a website
25-
const getFaviconUrl = (
26-
websiteUrl: string | null | undefined,
27-
): string | null => {
28-
if (!websiteUrl) return null;
29-
try {
30-
const url = new URL(websiteUrl);
31-
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`;
32-
} catch {
33-
return null;
34-
}
35-
};
36-
3724
// Get hostname from URL
3825
const getHostname = (urlString: string): string => {
3926
try {
@@ -189,7 +176,6 @@ const UserLinkDetail = ({ username, contentSlug }: Props) => {
189176
})
190177
: null;
191178

192-
const faviconUrl = getFaviconUrl(externalUrl);
193179
const hostname = externalUrl ? getHostname(externalUrl) : null;
194180
const score = votes.upvotes - votes.downvotes;
195181

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

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import { eq, and, lte } from "drizzle-orm";
2424
import FeedArticleContent from "./_feedArticleContent";
2525
import LinkContentDetail from "./_linkContentDetail";
2626
import UserLinkDetail from "./_userLinkDetail";
27+
import { JsonLd } from "@/components/JsonLd";
28+
import {
29+
getArticleSchema,
30+
getBreadcrumbSchema,
31+
getNewsArticleSchema,
32+
} from "@/lib/structured-data";
2733

2834
type Props = { params: Promise<{ username: string; slug: string }> };
2935

@@ -457,8 +463,40 @@ const UnifiedPostPage = async (props: Props) => {
457463
}) as unknown as string;
458464
}
459465

466+
// Prepare JSON-LD structured data
467+
const articleSchema = getArticleSchema({
468+
title: userPost.title,
469+
excerpt: userPost.excerpt,
470+
slug: userPost.slug,
471+
publishedAt: userPost.published,
472+
updatedAt: userPost.updatedAt,
473+
readingTime: userPost.readTimeMins,
474+
canonicalUrl: userPost.canonicalUrl,
475+
tags: userPost.tags.map((t) => ({ title: t.tag.title })),
476+
author: {
477+
name: userPost.user.name,
478+
username: userPost.user.username,
479+
image: userPost.user.image,
480+
bio: userPost.user.bio,
481+
},
482+
});
483+
484+
const breadcrumbSchema = getBreadcrumbSchema([
485+
{ name: "Home", url: "https://www.codu.co" },
486+
{ name: "Feed", url: "https://www.codu.co/feed" },
487+
{
488+
name: userPost.user.name || "Author",
489+
url: `https://www.codu.co/${userPost.user.username}`,
490+
},
491+
{ name: userPost.title },
492+
]);
493+
460494
return (
461495
<>
496+
{/* JSON-LD Structured Data for SEO */}
497+
<JsonLd data={articleSchema} />
498+
<JsonLd data={breadcrumbSchema} />
499+
462500
<div className="mx-auto max-w-3xl px-4 py-8">
463501
{/* Breadcrumb navigation */}
464502
<nav className="mb-6 flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
@@ -618,8 +656,40 @@ const UnifiedPostPage = async (props: Props) => {
618656
}) as unknown as string;
619657
}
620658

659+
// Prepare JSON-LD structured data
660+
const articleSchema = getArticleSchema({
661+
title: userArticle.title,
662+
excerpt: userArticle.excerpt,
663+
slug: userArticle.slug,
664+
publishedAt: userArticle.publishedAt,
665+
updatedAt: userArticle.updatedAt,
666+
readingTime: userArticle.readTimeMins,
667+
canonicalUrl: userArticle.canonicalUrl,
668+
tags: userArticle.tags?.map((t) => ({ title: t.tag.title })),
669+
author: {
670+
name: userArticle.user.name,
671+
username: userArticle.user.username,
672+
image: userArticle.user.image,
673+
bio: userArticle.user.bio,
674+
},
675+
});
676+
677+
const breadcrumbSchema = getBreadcrumbSchema([
678+
{ name: "Home", url: "https://www.codu.co" },
679+
{ name: "Feed", url: "https://www.codu.co/feed" },
680+
{
681+
name: userArticle.user.name || "Author",
682+
url: `https://www.codu.co/${userArticle.user.username}`,
683+
},
684+
{ name: userArticle.title },
685+
]);
686+
621687
return (
622688
<>
689+
{/* JSON-LD Structured Data for SEO */}
690+
<JsonLd data={articleSchema} />
691+
<JsonLd data={breadcrumbSchema} />
692+
623693
<div className="mx-auto max-w-3xl px-4 py-8">
624694
{/* Breadcrumb navigation */}
625695
<nav className="mb-6 flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
@@ -773,16 +843,78 @@ const UnifiedPostPage = async (props: Props) => {
773843
const feedArticle = await getFeedArticle(username, slug);
774844

775845
if (feedArticle) {
776-
// Render feed article
777-
return <FeedArticleContent sourceSlug={username} articleSlug={slug} />;
846+
// Prepare JSON-LD structured data for feed article
847+
const newsArticleSchema = getNewsArticleSchema({
848+
title: feedArticle.title,
849+
excerpt: feedArticle.excerpt,
850+
slug: feedArticle.slug,
851+
externalUrl: feedArticle.externalUrl || "",
852+
coverImage: feedArticle.imageUrl || feedArticle.ogImageUrl,
853+
publishedAt: feedArticle.publishedAt,
854+
source: {
855+
name: feedArticle.source?.name || null,
856+
slug: feedArticle.source?.slug || username,
857+
logoUrl: feedArticle.source?.logoUrl,
858+
},
859+
});
860+
861+
const breadcrumbSchema = getBreadcrumbSchema([
862+
{ name: "Home", url: "https://www.codu.co" },
863+
{ name: "Feed", url: "https://www.codu.co/feed" },
864+
{
865+
name: feedArticle.source?.name || username,
866+
url: `https://www.codu.co/${feedArticle.source?.slug || username}`,
867+
},
868+
{ name: feedArticle.title },
869+
]);
870+
871+
// Render feed article with JSON-LD
872+
return (
873+
<>
874+
<JsonLd data={newsArticleSchema} />
875+
<JsonLd data={breadcrumbSchema} />
876+
<FeedArticleContent sourceSlug={username} articleSlug={slug} />
877+
</>
878+
);
778879
}
779880

780881
// Try unified content table (new LINK type items)
781882
const linkContent = await getLinkContent(username, slug);
782883

783884
if (linkContent) {
784-
// Render link content
785-
return <LinkContentDetail sourceSlug={username} contentSlug={slug} />;
885+
// Prepare JSON-LD structured data for link content
886+
const newsArticleSchema = getNewsArticleSchema({
887+
title: linkContent.title,
888+
excerpt: linkContent.excerpt,
889+
slug: linkContent.slug,
890+
externalUrl: linkContent.externalUrl || "",
891+
coverImage: linkContent.imageUrl || linkContent.ogImageUrl,
892+
publishedAt: linkContent.publishedAt,
893+
source: {
894+
name: linkContent.source?.name || null,
895+
slug: linkContent.source?.slug || username,
896+
logoUrl: linkContent.source?.logoUrl,
897+
},
898+
});
899+
900+
const breadcrumbSchema = getBreadcrumbSchema([
901+
{ name: "Home", url: "https://www.codu.co" },
902+
{ name: "Feed", url: "https://www.codu.co/feed" },
903+
{
904+
name: linkContent.source?.name || username,
905+
url: `https://www.codu.co/${linkContent.source?.slug || username}`,
906+
},
907+
{ name: linkContent.title },
908+
]);
909+
910+
// Render link content with JSON-LD
911+
return (
912+
<>
913+
<JsonLd data={newsArticleSchema} />
914+
<JsonLd data={breadcrumbSchema} />
915+
<LinkContentDetail sourceSlug={username} contentSlug={slug} />
916+
</>
917+
);
786918
}
787919

788920
// Nothing found

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { type Metadata } from "next";
77
import { db } from "@/server/db";
88
import { feed_sources } from "@/server/db/schema";
99
import { eq } from "drizzle-orm";
10+
import { JsonLd } from "@/components/JsonLd";
11+
import { getPersonSchema } from "@/lib/structured-data";
1012

1113
type Props = { params: Promise<{ username: string }> };
1214

@@ -133,8 +135,20 @@ export default async function Page(props: {
133135
accountLocked,
134136
};
135137

138+
// Prepare Person JSON-LD for SEO
139+
const personSchema = getPersonSchema({
140+
name: shapedProfile.name,
141+
username: shapedProfile.username,
142+
image: shapedProfile.image,
143+
bio: shapedProfile.bio,
144+
websiteUrl: shapedProfile.websiteUrl,
145+
});
146+
136147
return (
137148
<>
149+
{/* Person JSON-LD for profile SEO */}
150+
<JsonLd data={personSchema} />
151+
138152
<h1 className="sr-only">{`${shapedProfile.name || shapedProfile.username}'s Coding Profile`}</h1>
139153
<Content profile={shapedProfile} isOwner={isOwner} session={session} />
140154
</>

app/(app)/admin/_client.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
FlagIcon,
88
RssIcon,
99
ShieldExclamationIcon,
10-
NewspaperIcon,
10+
TagIcon,
1111
} from "@heroicons/react/24/outline";
1212
import { api } from "@/server/trpc/react";
1313

@@ -99,13 +99,6 @@ const AdminDashboard = () => {
9999
color="green"
100100
isLoading={isLoading}
101101
/>
102-
<StatCard
103-
title="Aggregated Articles"
104-
value={stats?.aggregatedArticles}
105-
icon={NewspaperIcon}
106-
color="purple"
107-
isLoading={isLoading}
108-
/>
109102
<StatCard
110103
title="Active Feed Sources"
111104
value={stats?.activeFeedSources}
@@ -114,6 +107,14 @@ const AdminDashboard = () => {
114107
href="/admin/sources"
115108
isLoading={isLoading}
116109
/>
110+
<StatCard
111+
title="Total Reports"
112+
value={reportCounts?.total}
113+
icon={FlagIcon}
114+
color="purple"
115+
href="/admin/moderation"
116+
isLoading={isLoading}
117+
/>
117118
</div>
118119

119120
{/* Moderation Stats */}
@@ -205,6 +206,21 @@ const AdminDashboard = () => {
205206
</p>
206207
</div>
207208
</Link>
209+
210+
<Link
211+
href="/admin/tags"
212+
className="flex items-center gap-3 rounded-lg border border-neutral-200 bg-white p-4 transition-colors hover:border-green-300 hover:bg-green-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-green-700 dark:hover:bg-green-900/20"
213+
>
214+
<TagIcon className="h-6 w-6 text-green-500" />
215+
<div>
216+
<p className="font-medium text-neutral-900 dark:text-white">
217+
Tag Management
218+
</p>
219+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
220+
Merge, curate, and manage tags
221+
</p>
222+
</div>
223+
</Link>
208224
</div>
209225
</div>
210226
</div>

app/(app)/admin/moderation/_client.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useState } from "react";
44
import Link from "next/link";
55
import {
66
FlagIcon,
7-
CheckCircleIcon,
87
XCircleIcon,
98
ExclamationTriangleIcon,
109
ArrowLeftIcon,

0 commit comments

Comments
 (0)