diff --git a/apps/cursor/next.config.mjs b/apps/cursor/next.config.mjs index 94234941..fa649bb4 100644 --- a/apps/cursor/next.config.mjs +++ b/apps/cursor/next.config.mjs @@ -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: "(?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, }, { diff --git a/apps/cursor/src/app/[slug]/page.tsx b/apps/cursor/src/app/[slug]/page.tsx index c768147a..632f45db 100644 --- a/apps/cursor/src/app/[slug]/page.tsx +++ b/apps/cursor/src/app/[slug]/page.tsx @@ -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> { - const { data: plugins } = await getPlugins({ fetchAll: true }); - - const redirects = new Map(); - 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}`); diff --git a/apps/cursor/src/app/members/ambassadors/page.tsx b/apps/cursor/src/app/members/ambassadors/page.tsx new file mode 100644 index 00000000..3a39fadb --- /dev/null +++ b/apps/cursor/src/app/members/ambassadors/page.tsx @@ -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 ; +} diff --git a/apps/cursor/src/app/members/companies/page.tsx b/apps/cursor/src/app/members/companies/page.tsx new file mode 100644 index 00000000..19d9ea23 --- /dev/null +++ b/apps/cursor/src/app/members/companies/page.tsx @@ -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 ; +} diff --git a/apps/cursor/src/app/members/page.tsx b/apps/cursor/src/app/members/page.tsx new file mode 100644 index 00000000..dc0046c1 --- /dev/null +++ b/apps/cursor/src/app/members/page.tsx @@ -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 ; +} diff --git a/apps/cursor/src/app/sitemap.ts b/apps/cursor/src/app/sitemap.ts index 249fb55e..50c49433 100644 --- a/apps/cursor/src/app/sitemap.ts +++ b/apps/cursor/src/app/sitemap.ts @@ -23,7 +23,19 @@ export default async function sitemap(): Promise { 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, diff --git a/apps/cursor/src/components/footer.tsx b/apps/cursor/src/components/footer.tsx index 0887704c..40f0ea50 100644 --- a/apps/cursor/src/components/footer.tsx +++ b/apps/cursor/src/components/footer.tsx @@ -17,7 +17,7 @@ const columns = [ title: "Community", links: [ { href: "/members", label: "Members" }, - { href: "/members?tab=companies", label: "Companies" }, + { href: "/members/companies", label: "Companies" }, ], }, { diff --git a/apps/cursor/src/app/members/[[...number]]/page.tsx b/apps/cursor/src/components/members/members-page-content.tsx similarity index 59% rename from apps/cursor/src/app/members/[[...number]]/page.tsx rename to apps/cursor/src/components/members/members-page-content.tsx index c39f87e0..7b298da2 100644 --- a/apps/cursor/src/app/members/[[...number]]/page.tsx +++ b/apps/cursor/src/components/members/members-page-content.tsx @@ -1,33 +1,27 @@ -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"); @@ -35,8 +29,16 @@ export default async function Page() { 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 ( @@ -54,6 +56,7 @@ export default async function Page() { (initialMembers); - const [loading, setLoading] = useState(initialMembers.length === 0); + const [loading, setLoading] = useState( + !isCompanies && initialMembers.length === 0, + ); const [hasMoreMembers, setHasMoreMembers] = useState( initialMembers.length === PAGE_SIZE, ); @@ -78,12 +92,20 @@ export function MembersTabs({ sortRef.current = sort; searchRef.current = search; - const isAmbassadors = selectedTab === "ambassadors"; - const ambassadorsRef = useRef(isAmbassadors); - ambassadorsRef.current = isAmbassadors; + useEffect(() => { + if (legacyTab !== null) setLegacyTab(null); + }, [legacyTab, setLegacyTab]); useEffect(() => { - if (initialLoadRef.current && !sort && !search && !isAmbassadors) { + if (isCompanies) return; + + // The server already rendered the first unfiltered page for this tab. + if ( + initialLoadRef.current && + !sort && + !search && + initialMembers.length > 0 + ) { initialLoadRef.current = false; return; } @@ -116,7 +138,7 @@ export function MembersTabs({ cancelled = true; clearTimeout(debounce); }; - }, [sort, search, isAmbassadors]); + }, [sort, search, isAmbassadors, isCompanies, initialMembers.length]); const loadMoreMembers = useCallback(() => { if (loadingRef.current || !hasMoreMembers) return; @@ -126,14 +148,14 @@ export function MembersTabs({ offsetRef.current, sortRef.current, searchRef.current, - ambassadorsRef.current, + isAmbassadors, ).then(({ data, hasMore }) => { setMembers((prev) => [...prev, ...data]); offsetRef.current += data.length; setHasMoreMembers(hasMore); loadingRef.current = false; }); - }, [hasMoreMembers]); + }, [hasMoreMembers, isAmbassadors]); const filteredCompanies = useMemo(() => { const q = (search ?? "").toLowerCase(); @@ -153,7 +175,6 @@ export function MembersTabs({ [filteredCompanies.length], ); - const isCompanies = selectedTab === "companies"; const hasMore = isCompanies ? companyVisible < filteredCompanies.length : hasMoreMembers; @@ -178,15 +199,25 @@ export function MembersTabs({ loadCursor, ); - const handleTabChange = (key: string | null) => { - setSelectedTab(key); - setCompanyVisible(PAGE_SIZE); + // Carry the active search/sort over when switching tabs, mirroring the + // previous query-param tab behavior. + const tabHref = (href: string) => { + const params = new URLSearchParams(); + if (search) params.set("q", search); + if (sort) params.set("sort", sort); + const qs = params.toString(); + return qs ? `${href}?${qs}` : href; }; const handleSortChange = (key: string | null) => { setSort(key); }; + const clearFilters = () => { + setSearch(null); + setSort(null); + }; + const visibleItems = isCompanies ? filteredCompanies.slice(0, companyVisible) : members; @@ -218,27 +249,19 @@ export function MembersTabs({
- {categoryTabs.map((tab) => ( + {categoryTabs.map((category) => ( ))}
@@ -280,7 +303,7 @@ export function MembersTabs({ diff --git a/apps/cursor/src/components/plugins/plugin-leaderboard.tsx b/apps/cursor/src/components/plugins/plugin-leaderboard.tsx index 39d1b48d..f19226c3 100644 --- a/apps/cursor/src/components/plugins/plugin-leaderboard.tsx +++ b/apps/cursor/src/components/plugins/plugin-leaderboard.tsx @@ -143,24 +143,47 @@ type Row = sort: LeaderboardSort; }; +// While searching, a plugin whose name or slug exactly matches the query +// is pinned to the top regardless of the tab metric — searching "vercel" +// should always surface the Vercel plugin first, even on Trending where +// fuzzier matches may have higher recent install velocity. +function isExactMatch(item: LeaderboardItem, query: string): boolean { + if (!query) return false; + return ( + item.name.trim().toLowerCase() === query || + item.slug.toLowerCase() === query + ); +} + function buildRows( items: LeaderboardItem[], sort: LeaderboardSort, groupByAuthor: boolean, now: number, + searchQuery: string, ): Row[] { + const query = searchQuery.trim().toLowerCase(); const safeItems = items.filter((i) => !isExcluded(i)); // Trending requires *some* signal: either real recent installs, or // at least a positive lifetime install_count (so we can compute a // synthetic per-month estimate from the install rate). Plugins with - // zero installs ever are not "trending". + // zero installs ever are not "trending" — unless they exactly match an + // active search, in which case hiding them would be more confusing. const candidates = sort === "trending" - ? safeItems.filter((i) => (i.installs30d ?? 0) > 0 || i.installCount > 0) + ? safeItems.filter( + (i) => + (i.installs30d ?? 0) > 0 || + i.installCount > 0 || + isExactMatch(i, query), + ) : safeItems; - const sorted = [...candidates].sort( - (a, b) => metricFor(b, sort, now) - metricFor(a, sort, now), - ); + const sorted = [...candidates].sort((a, b) => { + const exactDiff = + Number(isExactMatch(b, query)) - Number(isExactMatch(a, query)); + if (exactDiff !== 0) return exactDiff; + return metricFor(b, sort, now) - metricFor(a, sort, now); + }); if (!groupByAuthor) { return sorted.map((item, i) => ({ kind: "item", rank: i + 1, item })); @@ -307,6 +330,7 @@ export function PluginLeaderboard({ groupByAuthor = false, maxItems = Number.POSITIVE_INFINITY, chunkSize = 50, + searchQuery = "", }: { items: LeaderboardItem[]; /** @@ -319,14 +343,16 @@ export function PluginLeaderboard({ groupByAuthor?: boolean; maxItems?: number; chunkSize?: number; + /** Active search query, used to pin exact matches to the top. */ + searchQuery?: string; }) { const [sort, setSort] = useState(initialSort); const [visible, setVisible] = useState(chunkSize); const rows = useMemo(() => { - const built = buildRows(items, sort, groupByAuthor, now); + const built = buildRows(items, sort, groupByAuthor, now, searchQuery); return built.slice(0, maxItems); - }, [items, sort, groupByAuthor, maxItems, now]); + }, [items, sort, groupByAuthor, maxItems, now, searchQuery]); const visibleRows = rows.slice(0, visible); const hasMore = visible < rows.length; diff --git a/apps/cursor/src/components/startpage.tsx b/apps/cursor/src/components/startpage.tsx index f2bfbdcc..f57fcf7b 100644 --- a/apps/cursor/src/components/startpage.tsx +++ b/apps/cursor/src/components/startpage.tsx @@ -21,7 +21,11 @@ export function Startpage({ }) { const [search] = useQueryState("q", { defaultValue: "" }); - const isSearching = search.trim().length > 0; + // Don't enter search mode on the first character: Fuse needs two + // characters to match (`minMatchCharLength: 2`), so filtering at one + // character would always flash the "no plugins found" empty state. + const query = search.trim(); + const isSearching = query.length >= 2; const fuse = useMemo( () => @@ -42,8 +46,8 @@ export function Startpage({ const visibleItems = useMemo(() => { if (!isSearching) return leaderboardItems; - return fuse.search(search).map((r) => r.item); - }, [isSearching, fuse, search, leaderboardItems]); + return fuse.search(query).map((r) => r.item); + }, [isSearching, fuse, query, leaderboardItems]); return (
@@ -57,7 +61,11 @@ export function Startpage({ {visibleItems.length > 0 ? (
- +
) : (
diff --git a/apps/cursor/src/data/queries.ts b/apps/cursor/src/data/queries.ts index fa020d81..bfb9dbd8 100644 --- a/apps/cursor/src/data/queries.ts +++ b/apps/cursor/src/data/queries.ts @@ -366,6 +366,66 @@ export async function getPlugins({ return { data: data as PluginRow[] | null, error }; } +// Slugs of every `rule` component on an active plugin. Legacy rule URLs +// (`/{rule-slug}`) prerender a redirect to the parent plugin, and there are +// thousands of them — they need a slugs-only query. Building the list from +// `getPlugins({ fetchAll: true })` (full table, all columns, serial pages) +// inside every prerendering page caused `USE_CACHE_TIMEOUT` build failures. +export async function getRuleRedirectSlugs() { + "use cache"; + cacheLife("hours"); + cacheTag("plugins"); + + const supabase = await createClient(); + + return fetchAllPages<{ slug: string }>((from, to) => + supabase + .from("plugin_components") + .select("slug, plugins!inner(id)") + .eq("type", "rule") + .eq("plugins.active", true) + .order("id", { ascending: true }) + .range(from, to), + ); +} + +// Resolves a legacy rule slug to the slug of the active plugin that contains +// it. The newest plugin wins when several contain the same rule slug, +// matching the old redirect map that was built from a newest-first plugin +// list. Cached per slug, so each redirect page fills a tiny cache entry +// instead of waiting on the full-table plugins fetch. +export async function getRuleRedirectTarget(ruleSlug: string): Promise<{ + data: string | null; + error: unknown; +}> { + "use cache"; + cacheLife("hours"); + cacheTag("plugins"); + + const supabase = await createClient(); + const { data, error } = await supabase + .from("plugin_components") + .select("plugins!inner(slug, created_at)") + .eq("type", "rule") + .eq("slug", ruleSlug) + .eq("plugins.active", true); + + if (error) return { data: null, error }; + + const newest = (data ?? []) + .map( + (row) => + row.plugins as unknown as { slug: string; created_at: string } | null, + ) + .filter((plugin) => plugin !== null) + .sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + )[0]; + + return { data: newest?.slug ?? null, error: null }; +} + // Returns a Map, derived // from `plugin_install_snapshots` via the `plugin_install_velocity` SQL // function. Plugins with no snapshot history yet (or no fresh installs) @@ -515,12 +575,14 @@ type GetMembersParams = { page?: number; limit?: number; q?: string; + ambassadorsOnly?: boolean; }; export async function getMembers({ page = 1, limit = 33, q, + ambassadorsOnly = false, }: GetMembersParams = {}) { "use cache"; // Mirrors the old `revalidate = 300` behavior on the members page. @@ -530,13 +592,17 @@ export async function getMembers({ const supabase = await createClient(); const query = supabase .from("users") - .select("id, name, image, slug, follower_count") + .select("id, name, image, slug, follower_count, is_ambassador") .eq("public", true) .order("created_at", { ascending: false }) .limit(limit) .range((page - 1) * limit, page * limit - 1) .neq("name", "unknown user"); + if (ambassadorsOnly) { + query.eq("is_ambassador", true); + } + if (q) { query.textSearch("name", q, { type: "websearch",