Skip to content

Commit d072cb9

Browse files
committed
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
1 parent eef9ff2 commit d072cb9

File tree

20 files changed

+656
-48
lines changed

20 files changed

+656
-48
lines changed

.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]/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/sources/_client.tsx

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
PencilSquareIcon,
1616
XMarkIcon,
1717
PhotoIcon,
18+
ExclamationTriangleIcon,
1819
} from "@heroicons/react/20/solid";
1920

2021
const statusColors = {
@@ -33,12 +34,10 @@ const statusIcons = {
3334
// Component for logo with fallback
3435
const LogoWithFallback = ({
3536
logoUrl,
36-
websiteUrl,
3737
name,
3838
size = "sm",
3939
}: {
4040
logoUrl: string | null;
41-
websiteUrl: string | null;
4241
name: string;
4342
size?: "sm" | "md";
4443
}) => {
@@ -67,6 +66,39 @@ const LogoWithFallback = ({
6766
);
6867
};
6968

69+
// Helper to check which fields are missing for data completeness
70+
const getMissingFields = (source: {
71+
logoUrl: string | null;
72+
websiteUrl: string | null;
73+
category: string | null;
74+
description: string | null;
75+
}): string[] => {
76+
const missing: string[] = [];
77+
if (!source.logoUrl) missing.push("Logo");
78+
if (!source.websiteUrl) missing.push("Website URL");
79+
if (!source.category) missing.push("Category");
80+
if (!source.description) missing.push("Description");
81+
return missing;
82+
};
83+
84+
// Data completeness badge component
85+
const DataCompletenessBadge = ({
86+
missingFields,
87+
}: {
88+
missingFields: string[];
89+
}) => {
90+
if (missingFields.length === 0) return null;
91+
92+
return (
93+
<span className="group relative ml-1.5 inline-flex">
94+
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
95+
<span className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-neutral-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-neutral-700">
96+
Missing: {missingFields.join(", ")}
97+
</span>
98+
</span>
99+
);
100+
};
101+
70102
const AdminSourcesPage = () => {
71103
const [showAddForm, setShowAddForm] = useState(false);
72104
const [syncingAll, setSyncingAll] = useState(false);
@@ -90,8 +122,6 @@ const AdminSourcesPage = () => {
90122
});
91123
const logoInputRef = useRef<HTMLInputElement>(null);
92124

93-
const utils = api.useUtils();
94-
95125
// Sync all sources
96126
const handleSyncAll = async () => {
97127
setSyncingAll(true);
@@ -657,12 +687,14 @@ const AdminSourcesPage = () => {
657687
<span className="inline-flex items-center gap-3">
658688
<LogoWithFallback
659689
logoUrl={source.logoUrl}
660-
websiteUrl={source.websiteUrl}
661690
name={source.sourceName}
662691
/>
663692
<span>
664-
<span className="block font-medium text-neutral-900 dark:text-neutral-100">
693+
<span className="flex items-center font-medium text-neutral-900 dark:text-neutral-100">
665694
{source.sourceName}
695+
<DataCompletenessBadge
696+
missingFields={getMissingFields(source)}
697+
/>
666698
</span>
667699
{source.websiteUrl && (
668700
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
@@ -705,49 +737,49 @@ const AdminSourcesPage = () => {
705737
{source.errorCount}
706738
</td>
707739
<td className="whitespace-nowrap px-6 py-4 text-right text-sm">
708-
<div className="flex items-center justify-end gap-2">
740+
<div className="flex items-center justify-end gap-1">
709741
<button
710742
onClick={() => openEditModal(source)}
711-
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
743+
className="rounded bg-transparent p-1.5 text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-200"
712744
title="Edit"
713745
>
714-
<PencilSquareIcon className="h-5 w-5" />
746+
<PencilSquareIcon className="h-4 w-4" />
715747
</button>
716748
<button
717749
onClick={() =>
718750
handleSyncSource(source.sourceId, source.sourceName)
719751
}
720752
disabled={syncingSourceId === source.sourceId}
721-
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 disabled:opacity-50 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
753+
className="rounded bg-transparent p-1.5 text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-200 disabled:opacity-50"
722754
title="Sync now"
723755
>
724756
<ArrowPathIcon
725-
className={`h-5 w-5 ${syncingSourceId === source.sourceId ? "animate-spin" : ""}`}
757+
className={`h-4 w-4 ${syncingSourceId === source.sourceId ? "animate-spin" : ""}`}
726758
/>
727759
</button>
728760
<button
729761
onClick={() =>
730762
handleStatusToggle(source.sourceId, source.status)
731763
}
732-
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
764+
className="rounded bg-transparent p-1.5 text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-200"
733765
title={
734766
source.status === "active" ? "Pause" : "Activate"
735767
}
736768
>
737769
{source.status === "active" ? (
738-
<PauseCircleIcon className="h-5 w-5" />
770+
<PauseCircleIcon className="h-4 w-4" />
739771
) : (
740-
<CheckCircleIcon className="h-5 w-5" />
772+
<CheckCircleIcon className="h-4 w-4" />
741773
)}
742774
</button>
743775
<button
744776
onClick={() =>
745777
handleDelete(source.sourceId, source.sourceName)
746778
}
747-
className="rounded p-1 text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950"
779+
className="rounded bg-transparent p-1.5 text-red-400 transition-colors hover:bg-red-950 hover:text-red-300"
748780
title="Delete"
749781
>
750-
<TrashIcon className="h-5 w-5" />
782+
<TrashIcon className="h-4 w-4" />
751783
</button>
752784
</div>
753785
</td>

app/(app)/layout.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { db } from "@/server/db";
44
import { eq } from "drizzle-orm";
55
import { user } from "@/server/db/schema";
66
import { SidebarAppLayout } from "@/components/Layout/SidebarAppLayout";
7+
import { JsonLd } from "@/components/JsonLd";
8+
import { getOrganizationSchema } from "@/lib/structured-data";
79

810
export const metadata = {
911
title: "Codú - Join Our Web Developer Community",
@@ -71,12 +73,17 @@ export default async function RootLayout({
7173
: null;
7274

7375
return (
74-
<SidebarAppLayout
75-
session={session}
76-
algoliaSearchConfig={algoliaSearchConfig}
77-
username={userData?.username || null}
78-
>
79-
{children}
80-
</SidebarAppLayout>
76+
<>
77+
{/* Organization JSON-LD for site-wide SEO */}
78+
<JsonLd data={getOrganizationSchema()} />
79+
80+
<SidebarAppLayout
81+
session={session}
82+
algoliaSearchConfig={algoliaSearchConfig}
83+
username={userData?.username || null}
84+
>
85+
{children}
86+
</SidebarAppLayout>
87+
</>
8188
);
8289
}

0 commit comments

Comments
 (0)