Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 24 additions & 1 deletion apps/cursor/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,32 @@ const nextConfig = {
destination: "/",
permanent: true,
},
{
// Legacy query-param tabs now live on dedicated prerendered routes.
// The named capture is consumed by the destination, so `tab` is
// stripped while other params (q, sort) pass through.
source: "/members",
has: [
{
type: "query",
key: "tab",
value: "(?<tab>ambassadors|companies)",
},
],
destination: "/members/:tab",
permanent: false,
},
{
// Old paginated members URLs (/members/2, ...). Digits only, so the
// named tab routes (/members/ambassadors, /members/companies) are
// never shadowed.
source: "/members/:number(\\d+)",
destination: "/members",
permanent: true,
},
{
source: "/companies",
destination: "/members?tab=companies",
destination: "/members/companies",
permanent: true,
},
{
Expand Down
37 changes: 12 additions & 25 deletions apps/cursor/src/app/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,28 @@
import { notFound, redirect } from "next/navigation";
import { getPlugins } from "@/data/queries";
import { getRuleRedirectSlugs, getRuleRedirectTarget } from "@/data/queries";

type Params = Promise<{ slug: string }>;

/**
* Rules predate plugins and now live as `rule`-type plugin components.
* Maps every rule component slug to its parent plugin's slug (first plugin
* wins, matching the newest-first order plugins are fetched in).
*/
async function getRuleRedirects(): Promise<Map<string, string>> {
const { data: plugins } = await getPlugins({ fetchAll: true });

const redirects = new Map<string, string>();
for (const plugin of plugins ?? []) {
for (const component of plugin.plugin_components ?? []) {
if (component.type === "rule" && !redirects.has(component.slug)) {
redirects.set(component.slug, plugin.slug);
}
}
}
return redirects;
}

export async function generateStaticParams() {
const redirects = await getRuleRedirects();
return [...redirects.keys()].map((slug) => ({ slug }));
const { data } = await getRuleRedirectSlugs();
const unique = [...new Set((data ?? []).map((row) => row.slug))];
return unique.map((slug) => ({ slug }));
}

/**
* Legacy rule URLs (`/{rule-slug}`) redirect to the plugin that now contains
* the rule component.
* the rule component. Rules predate plugins and live on as `rule`-type
* plugin components.
*
* Each page resolves its own slug with a small per-slug cached query.
* Don't share a fetch-the-whole-plugins-table cache entry here: thousands
* of these pages prerender concurrently, and waiting on that slow fill
* times out the build (`USE_CACHE_TIMEOUT`).
*/
export default async function Page({ params }: { params: Params }) {
const { slug } = await params;

const redirects = await getRuleRedirects();
const pluginSlug = redirects.get(slug);
const { data: pluginSlug } = await getRuleRedirectTarget(slug);

if (pluginSlug) {
redirect(`/plugins/${pluginSlug}`);
Expand Down
19 changes: 19 additions & 0 deletions apps/cursor/src/app/members/ambassadors/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { MembersPageContent } from "@/components/members/members-page-content";

export const metadata: Metadata = {
title: "Ambassadors | Cursor Directory",
description: "Cursor Ambassadors helping the community build with Cursor.",
openGraph: {
title: "Ambassadors | Cursor Directory",
description: "Cursor Ambassadors helping the community build with Cursor.",
},
twitter: {
title: "Ambassadors | Cursor Directory",
description: "Cursor Ambassadors helping the community build with Cursor.",
},
};

export default function Page() {
return <MembersPageContent tab="ambassadors" />;
}
19 changes: 19 additions & 0 deletions apps/cursor/src/app/members/companies/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { MembersPageContent } from "@/components/members/members-page-content";

export const metadata: Metadata = {
title: "Companies | Cursor Directory",
description: "Companies building with Cursor.",
openGraph: {
title: "Companies | Cursor Directory",
description: "Companies building with Cursor.",
},
twitter: {
title: "Companies | Cursor Directory",
description: "Companies building with Cursor.",
},
};

export default function Page() {
return <MembersPageContent tab="companies" />;
}
19 changes: 19 additions & 0 deletions apps/cursor/src/app/members/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { MembersPageContent } from "@/components/members/members-page-content";

export const metadata: Metadata = {
title: "Members | Cursor Directory",
description: "Thousands of developers and companies building with Cursor.",
openGraph: {
title: "Members | Cursor Directory",
description: "Thousands of developers and companies building with Cursor.",
},
twitter: {
title: "Members | Cursor Directory",
description: "Thousands of developers and companies building with Cursor.",
},
};

export default function Page() {
return <MembersPageContent tab="developers" />;
}
14 changes: 13 additions & 1 deletion apps/cursor/src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.9,
},
{
url: `${BASE_URL}/members?tab=companies`,
url: `${BASE_URL}/members`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${BASE_URL}/members/ambassadors`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${BASE_URL}/members/companies`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
Expand Down
2 changes: 1 addition & 1 deletion apps/cursor/src/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
title: "Community",
links: [
{ href: "/members", label: "Members" },
{ href: "/members?tab=companies", label: "Companies" },
{ href: "/members/companies", label: "Companies" },
],
},
{
Expand Down Expand Up @@ -158,11 +158,11 @@

<div className="mt-16 flex flex-col items-center justify-between gap-6 border-t border-border pt-8 md:flex-row">
<div className="flex items-center">
<img
src="/logo-lockup.svg"
alt="Cursor Directory"
className="h-[18px] w-auto opacity-40 dark:brightness-100 brightness-0"
/>

Check warning on line 165 in apps/cursor/src/components/footer.tsx

View workflow job for this annotation

GitHub Actions / Lint & typecheck

lint/performance/noImgElement

Don't use <img> element.
</div>

<div className="flex items-center gap-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,44 @@
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import type { Company } from "@/components/company/company-card";
import { JoinCommunityLink } from "@/components/members/join-community-link";
import { type Member, MembersTabs } from "@/components/members/members-tabs";
import {
type Member,
type MembersTab,
MembersTabs,
} from "@/components/members/members-tabs";
import { getCompanies, getMembers, getTotalUsers } from "@/data/queries";
import { formatCount } from "@/lib/utils";

export const metadata: Metadata = {
title: "Members | Cursor Directory",
description: "Thousands of developers and companies building with Cursor.",
openGraph: {
title: "Members | Cursor Directory",
description: "Thousands of developers and companies building with Cursor.",
},
twitter: {
title: "Members | Cursor Directory",
description: "Thousands of developers and companies building with Cursor.",
},
};

/**
* The entire page is cached (stale-while-revalidate, 5-minute background
* Shared content for the /members, /members/ambassadors and
* /members/companies routes. Each tab has a dedicated, fully prerendered
* URL so a hard reload serves the exact same HTML as a client-side tab
* switch — no layout shift from hydrating a `?tab=` query param.
*
* The whole subtree is cached (stale-while-revalidate, 5-minute background
* refresh): no per-request rendering, no streaming holes. The session-aware
* "Join" CTA is a client component gated on an auth-cookie check, and the
* tab/search filters are client-only state (see `nuqs-static-adapter`), so
* nothing defers to request time. The `[[...number]]` segment is ignored —
* legacy paginated URLs all serve this same cached page.
* search/sort filters are client-only state (see `nuqs-static-adapter`),
* so nothing defers to request time.
*/
export default async function Page() {
export async function MembersPageContent({ tab }: { tab: MembersTab }) {
"use cache";
cacheLife({ stale: 300, revalidate: 300, expire: 86400 });
cacheTag("users", "companies");

const [{ data: totalUsers }, { data: companies }, { data: initialMembers }] =
await Promise.all([
getTotalUsers(),
getCompanies(),
getMembers({ page: 1, limit: 90 }),
tab === "companies"
? getCompanies()
: Promise.resolve({ data: null, error: null }),
tab === "companies"
? Promise.resolve({ data: null, error: null })
: getMembers({
page: 1,
limit: 90,
ambassadorsOnly: tab === "ambassadors",
}),
]);

return (
Expand All @@ -54,6 +56,7 @@ export default async function Page() {
</div>

<MembersTabs
tab={tab}
totalMembers={totalUsers?.count ?? 0}
companies={(companies as Company[] | null) ?? []}
initialMembers={(initialMembers as Member[] | null) ?? []}
Expand Down
Loading
Loading