feat(web): rename publishers browse to Creators (#2799)

* feat(web): rename publishers browse to Creators

Align /publishers page heading and filter tabs with clearer creator-focused copy, move Official after All, and show full org labels on desktop only.

* test(e2e): align local-auth flows with owner-qualified routes

Update playwright local-auth helpers and specs for canonical profile, skill, and plugin URLs after the main branch routing migration.

* fix(test): export plugin validation href helper for e2e typecheck

* chore: format local-auth helpers import

* chore: retrigger CI after flaky local-auth shards

* feat(web): move publishers browse to /creators route

Keep /publishers and /users as legacy redirects with search preserved, and align registry copy, tests, and reserved slugs with the new path.

* fix(web): drop unused OpenClaw slug re-export after schema move

* chore: format openClawExtensionSlugs re-export cleanup
This commit is contained in:
Vyctor H. Brzezowski
2026-06-23 12:24:25 -07:00
committed by GitHub
parent b927db6340
commit 7cc6b22176
27 changed files with 529 additions and 445 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import {
} from "./publicRouteReservations";
describe("public route reservations", () => {
it.each(["admin", "clawhub", "docs", "plugins", "skills"])(
it.each(["admin", "clawhub", "creators", "docs", "plugins", "publishers", "skills"])(
"reserves @%s as a public owner handle",
(handle) => {
expect(isReservedPublicOwnerHandle(handle)).toBe(true);
+9 -1
View File
@@ -1,6 +1,14 @@
import { OPENCLAW_EXTENSION_SLUG_TO_PACKAGE } from "clawhub-schema";
const RESERVED_PUBLIC_OWNER_HANDLES = new Set(["admin", "clawhub", "docs", "plugins", "skills"]);
const RESERVED_PUBLIC_OWNER_HANDLES = new Set([
"admin",
"clawhub",
"creators",
"docs",
"plugins",
"publishers",
"skills",
]);
const RESERVED_OPENCLAW_EXTENSION_HANDLES = new Set(
Object.keys(OPENCLAW_EXTENSION_SLUG_TO_PACKAGE),
);
+2 -1
View File
@@ -82,6 +82,7 @@ describe("assertValidSkillSlug", () => {
"clawhub",
"souls",
"packages",
"creators",
"publishers",
])("rejects reserved slug %s", (slug) => {
// Some short reserved entries (e.g. "u") are also blocked by the
@@ -89,7 +90,7 @@ describe("assertValidSkillSlug", () => {
expect(() => assertValidSkillSlug(slug)).toThrow();
});
it.each(["openclaw", "publishers"])(
it.each(["openclaw", "creators", "publishers"])(
"emits the reserved-specific error for long reserved slug %s",
(slug) => {
expect(() => assertValidSkillSlug(slug)).toThrow(/reserved/i);
+1
View File
@@ -34,6 +34,7 @@ const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
"orgs",
"packages",
"plugins",
"creators",
"publishers",
"publish",
"publish-plugin",
@@ -7,7 +7,7 @@ import {
trackRuntimeErrors,
waitForHydration,
} from "../helpers/runtimeErrors";
import { escapeRegExp, signInAsLocalPersona } from "./helpers";
import { buildPublisherProfileHref, escapeRegExp, signInAsLocalPersona } from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
@@ -182,7 +182,7 @@ test("users can permanently delete their account and personal publisher resource
await signInAsLocalPersona(page, "user");
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
await page.goto(buildPublisherProfileHref(fixture.handle), { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expect(page.getByRole("heading", { name: "Local User" })).toBeVisible();
await expect(page.getByText(skillDisplayName)).toBeVisible();
@@ -235,7 +235,7 @@ test("users can permanently delete their account and personal publisher resource
await expectHealthyPage(page, errors);
errors.length = 0;
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
await page.goto(buildPublisherProfileHref(fixture.handle), { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expect(
page.getByRole("heading", { name: /publisher not found|we couldn't find that page/i }),
@@ -300,7 +300,7 @@ test("users can permanently delete their account and personal publisher resource
recreationState.activePublisher?.publisherId,
);
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
await page.goto(buildPublisherProfileHref(fixture.handle), { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expect(page.getByRole("heading", { name: "Local User" })).toBeVisible();
await expect(page.getByText(skillDisplayName)).toHaveCount(0);
+14 -1
View File
@@ -1,7 +1,12 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { buildSkillDetailHref } from "../../src/lib/ownerRoute";
import { buildPublisherProfileHref, buildSkillDetailHref } from "../../src/lib/ownerRoute";
import {
buildPluginDetailHref,
buildPluginSecurityAuditHref,
buildPluginValidationHref,
} from "../../src/lib/pluginRoutes";
import { waitForHydration } from "../helpers/runtimeErrors";
type DevPersona = "owner" | "user" | "admin" | "abusePublisher";
@@ -73,6 +78,14 @@ function parseSkillDetailPath(pathname: string) {
throw new Error(`Expected skill detail path, received ${pathname}`);
}
export {
buildPluginDetailHref,
buildPluginSecurityAuditHref,
buildPluginValidationHref,
buildPublisherProfileHref,
buildSkillDetailHref,
};
export function skillMd(args: { slug: string; displayName: string; versionLabel: string }) {
return `---
name: ${args.slug}
@@ -5,7 +5,7 @@ import convexBrowser from "convex/browser";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
import { publishSkillVersion, signInAsLocalPublisher } from "./helpers";
import { buildSkillDetailHref, publishSkillVersion, signInAsLocalPublisher } from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
@@ -203,7 +203,7 @@ test("malicious skill retries keep the clean latest visible, email the publisher
).length === 1,
);
}
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
await page.goto(buildSkillDetailHref(ownerHandle, slug), { waitUntil: "domcontentloaded" });
await waitForHydration(page);
if (version !== finalMaliciousVersion) {
await expectCurrentVersion(page, "1.0.0");
@@ -2,7 +2,7 @@ import { writeFile } from "node:fs/promises";
import { expect, type Page, test, type TestInfo } from "@playwright/test";
import { strToU8, zipSync } from "fflate";
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
import { signInAsLocalPersona } from "./helpers";
import { buildPluginValidationHref, escapeRegExp, signInAsLocalPersona } from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
@@ -136,12 +136,12 @@ test("plugin inspector blocks hard publish errors and publishes warning findings
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
const dashboardWarningLink = page.locator(`a[href="/plugins/${warningName}#validation"]`);
const dashboardWarningLink = page.locator(`a[href="${buildPluginValidationHref(warningName)}"]`);
await expect(dashboardWarningLink).toBeVisible({ timeout: 30_000 });
await captureProof(page, testInfo, "03-dashboard-warning-count");
await dashboardWarningLink.click();
await expect(page).toHaveURL(new RegExp(`/plugins/${warningName}#validation$`));
await expect(page).toHaveURL(new RegExp(`/plugins/${escapeRegExp(warningName)}#validation$`));
await expect(page.getByRole("tab", { name: /Validation \(\d+\)/ })).toHaveAttribute(
"aria-selected",
"true",
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { buildSkillDetailHref } from "../../src/lib/ownerRoute";
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
import {
buildSkillDetailHref,
expectLocalPersonaActive,
publishSkillVersion,
signInAsLocalPersona,
+10 -2
View File
@@ -105,10 +105,18 @@ function publicRouteCases(): PublicRouteCase[] {
},
},
{
label: "publishers browse",
label: "creators browse",
path: () => "/creators",
assert: async (page) => {
await expect(page.getByRole("heading", { name: /^Creators/ })).toBeVisible();
},
},
{
label: "publishers browse redirect",
path: () => "/publishers",
assert: async (page) => {
await expect(page.getByRole("heading", { name: /^Publishers/ })).toBeVisible();
await expect(page).toHaveURL(/\/creators/);
await expect(page.getByRole("heading", { name: /^Creators/ })).toBeVisible();
},
},
{
+1 -1
View File
@@ -116,7 +116,7 @@ export async function renderLlmsTxt(options: { docsDirectory?: string } = {}) {
markdownLink(
"ClawHub",
CLAWHUB_SITE_URL,
"Browse the public registry for skills, plugins, and publishers.",
"Browse the public registry for skills, plugins, and creators.",
),
markdownLink(
"ClawHub documentation",
@@ -59,7 +59,7 @@ vi.mock("../lib/site", () => ({
}));
async function loadRoute() {
return (await import("../routes/publishers/index")).Route as unknown as {
return (await import("../routes/creators/index")).Route as unknown as {
__config: {
component?: ComponentType;
head?: () => {
@@ -74,7 +74,7 @@ async function loadRoute() {
};
}
describe("publishers route", () => {
describe("creators route", () => {
beforeEach(() => {
vi.resetModules();
loaderDataMock.mockReset();
@@ -96,7 +96,7 @@ describe("publishers route", () => {
searchMock.mockReturnValue({});
});
it("renders the public publishers listing surface", async () => {
it("renders the public creators listing surface", async () => {
const route = await loadRoute();
const result = await route.__config.loader?.({ deps: {} });
@@ -172,7 +172,7 @@ describe("publishers route", () => {
render(<Component />);
expect(screen.getByText("Publishers")).toBeTruthy();
expect(screen.getByText("Creators")).toBeTruthy();
expect(screen.getByText("No publishers found")).toBeTruthy();
});
@@ -189,11 +189,12 @@ describe("publishers route", () => {
render(<Component />);
expect(screen.getByRole("heading", { name: "Publishers" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Creators" })).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
expect(screen.getByRole("radio", { name: "All" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Orgs" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Creators" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Official" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Organizations" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Users" })).toBeTruthy();
expect(screen.queryByText("Builders")).toBeNull();
expect(screen.queryByText(/Showing/i)).toBeNull();
});
@@ -212,7 +213,7 @@ describe("publishers route", () => {
render(<Component />);
expect(screen.getByRole("heading", { name: "Publishers" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Creators" })).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
});
@@ -255,11 +256,11 @@ describe("publishers route", () => {
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
it("sets publisher-specific sharing metadata", async () => {
it("sets creators-specific sharing metadata", async () => {
const route = await loadRoute();
const head = route.__config.head?.();
expect(head?.links).toContainEqual({ rel: "canonical", href: "https://clawhub.ai/publishers" });
expect(head?.meta).toContainEqual({ property: "og:title", content: "Publishers · ClawHub" });
expect(head?.links).toContainEqual({ rel: "canonical", href: "https://clawhub.ai/creators" });
expect(head?.meta).toContainEqual({ property: "og:title", content: "Creators · ClawHub" });
});
});
+3 -3
View File
@@ -271,7 +271,7 @@ describe("Header", () => {
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.getAllByText("Publishers")).toHaveLength(1);
expect(screen.getAllByText("Creators")).toHaveLength(1);
expect(screen.getAllByText("Docs")).toHaveLength(2);
expect(screen.queryByText("About")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
@@ -283,7 +283,7 @@ describe("Header", () => {
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
expect(screen.getAllByText("Publishers")).toHaveLength(2);
expect(screen.getAllByText("Creators")).toHaveLength(2);
expect(screen.getAllByText("Docs")).toHaveLength(3);
expect(screen.queryByText("About")).toBeNull();
});
@@ -586,7 +586,7 @@ describe("Header", () => {
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 5)).toEqual(["Home", "Skills", "Plugins", "Publishers", "Docs"]);
expect(labels.slice(0, 5)).toEqual(["Home", "Skills", "Plugins", "Creators", "Docs"]);
expect(
document.querySelector(".mobile-nav-appearance-section .navbar-theme-switcher"),
).toBeTruthy();
@@ -31,7 +31,7 @@ vi.mock("@tanstack/react-router", () => ({
{...props}
className={className}
href={
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/publishers"
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/creators"
}
>
{children}
+1 -1
View File
@@ -112,7 +112,7 @@ describe("restored UI design contract", () => {
expect(headerSource).not.toContain('className="navbar-tabs-secondary"');
expect(navSource).toContain("export const SECONDARY_NAV_ITEMS");
expect(navSource).toContain('label: "Publishers"');
expect(navSource).toContain('label: "Creators"');
expect(navSource).toContain('label: "Docs"');
expect(navSource).toContain("href: CLAWHUB_DOCS_URL");
expect(publicRegistrySource).toContain(
+13 -2
View File
@@ -17,7 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
describe("users route redirect", () => {
it("redirects legacy /users traffic to /publishers", async () => {
it("redirects legacy /users traffic to /creators", async () => {
const route = (await import("../routes/users/index")).Route as unknown as {
__config: { beforeLoad: (args: { search: Record<string, unknown> }) => unknown };
};
@@ -25,7 +25,18 @@ describe("users route redirect", () => {
const search = { q: "builder" };
expect(() => route.__config.beforeLoad({ search })).toThrow();
expect(redirectMock).toHaveBeenCalledWith({ to: "/publishers", search, replace: true });
expect(redirectMock).toHaveBeenCalledWith({ to: "/creators", search, replace: true });
});
it("redirects legacy /publishers traffic to /creators", async () => {
const route = (await import("../routes/publishers/index")).Route as unknown as {
__config: { beforeLoad: (args: { search: Record<string, unknown> }) => unknown };
};
const search = { kind: "orgs", q: "acme" };
expect(() => route.__config.beforeLoad({ search })).toThrow();
expect(redirectMock).toHaveBeenCalledWith({ to: "/creators", search, replace: true });
});
it("redirects legacy /p profile routes to user profiles", async () => {
+14 -1
View File
@@ -15,6 +15,7 @@ import type { BrowseCategory } from "../lib/categories";
type BrowseChoice = {
value: string | undefined;
label: string;
mobileLabel?: string;
icon?: ReactNode;
};
@@ -57,10 +58,22 @@ export function BrowseTabs({ ariaLabel, options, value, onChange }: BrowseTabsPr
type="button"
role="radio"
aria-checked={active}
aria-label={option.mobileLabel ? option.label : undefined}
onClick={() => onChange(option.value)}
>
{option.icon}
{option.label}
{option.mobileLabel ? (
<>
<span className="lg:hidden" aria-hidden="true">
{option.mobileLabel}
</span>
<span className="hidden lg:inline" aria-hidden="true">
{option.label}
</span>
</>
) : (
option.label
)}
</button>
);
})}
@@ -146,7 +146,7 @@ export function HomePopularPublishersSection() {
<h2 id="popular-publishers-title">Popular creators</h2>
<p>Explore skills and plugins from standout builders.</p>
</div>
<Link to="/publishers" className="home-v2-popular-publishers-link">
<Link to="/creators" className="home-v2-popular-publishers-link">
Browse creators <ArrowRight size={14} aria-hidden="true" />
</Link>
</header>
+5 -4
View File
@@ -44,7 +44,7 @@ const SKILLS_SEARCH = {
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the content-type tabs: Skills | Plugins | Publishers
// These map to the content-type tabs: Skills | Plugins | Creators
// ---------------------------------------------------------------------------
export const PRIMARY_NAV_ITEMS: NavItem[] = [
@@ -60,8 +60,9 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
activePathPrefixes: ["/plugin/"],
},
{
label: "Publishers",
to: PublicRegistryPaths.publishers,
label: "Creators",
to: PublicRegistryPaths.creators,
activePathPrefixes: ["/publishers"],
},
];
@@ -116,7 +117,7 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
items: [
{ kind: "link", label: "Skills", to: PublicRegistryPaths.skills, search: SKILLS_SEARCH },
{ kind: "link", label: "Plugins", to: PublicRegistryPaths.plugins },
{ kind: "link", label: "Publishers", to: PublicRegistryPaths.publishers },
{ kind: "link", label: "Creators", to: PublicRegistryPaths.creators },
{
kind: "link",
label: "Audits",
+1 -5
View File
@@ -1,5 +1 @@
export {
getOpenClawExtensionPackageName,
getOpenClawPackageCandidateNames,
OPENCLAW_EXTENSION_SLUG_TO_PACKAGE,
} from "clawhub-schema";
export { getOpenClawExtensionPackageName, getOpenClawPackageCandidateNames } from "clawhub-schema";
+4 -4
View File
@@ -8,7 +8,7 @@ export const PublicRegistryPaths = {
home: "/",
skills: "/skills",
plugins: "/plugins",
publishers: "/publishers",
creators: "/creators",
search: "/search",
audits: "/audits",
publishSkill: "/skills/publish",
@@ -27,9 +27,9 @@ export const PUBLIC_REGISTRY_SURFACES = [
summary: "Browse and search OpenClaw plugin package records.",
},
{
label: "Publishers",
path: PublicRegistryPaths.publishers,
summary: "Browse public user and organization publishers.",
label: "Creators",
path: PublicRegistryPaths.creators,
summary: "Browse public user and organization creators.",
},
{
label: "Search",
+21
View File
@@ -28,6 +28,7 @@ import { Route as SkillsIndexRouteImport } from './routes/skills/index'
import { Route as PublishersIndexRouteImport } from './routes/publishers/index'
import { Route as PluginsIndexRouteImport } from './routes/plugins/index'
import { Route as PackagesIndexRouteImport } from './routes/packages/index'
import { Route as CreatorsIndexRouteImport } from './routes/creators/index'
import { Route as UserHandleRouteImport } from './routes/user/$handle'
import { Route as UHandleRouteImport } from './routes/u/$handle'
import { Route as SkillsPublishRouteImport } from './routes/skills/publish'
@@ -154,6 +155,11 @@ const PackagesIndexRoute = PackagesIndexRouteImport.update({
path: '/packages/',
getParentRoute: () => rootRouteImport,
} as any)
const CreatorsIndexRoute = CreatorsIndexRouteImport.update({
id: '/creators/',
path: '/creators/',
getParentRoute: () => rootRouteImport,
} as any)
const UserHandleRoute = UserHandleRouteImport.update({
id: '/user/$handle',
path: '/user/$handle',
@@ -343,6 +349,7 @@ export interface FileRoutesByFullPath {
'/skills/publish': typeof SkillsPublishRoute
'/u/$handle': typeof UHandleRoute
'/user/$handle': typeof UserHandleRoute
'/creators/': typeof CreatorsIndexRoute
'/packages/': typeof PackagesIndexRoute
'/plugins/': typeof PluginsIndexRoute
'/publishers/': typeof PublishersIndexRoute
@@ -394,6 +401,7 @@ export interface FileRoutesByTo {
'/skills/publish': typeof SkillsPublishRoute
'/u/$handle': typeof UHandleRoute
'/user/$handle': typeof UserHandleRoute
'/creators': typeof CreatorsIndexRoute
'/packages': typeof PackagesIndexRoute
'/plugins': typeof PluginsIndexRoute
'/publishers': typeof PublishersIndexRoute
@@ -446,6 +454,7 @@ export interface FileRoutesById {
'/skills/publish': typeof SkillsPublishRoute
'/u/$handle': typeof UHandleRoute
'/user/$handle': typeof UserHandleRoute
'/creators/': typeof CreatorsIndexRoute
'/packages/': typeof PackagesIndexRoute
'/plugins/': typeof PluginsIndexRoute
'/publishers/': typeof PublishersIndexRoute
@@ -499,6 +508,7 @@ export interface FileRouteTypes {
| '/skills/publish'
| '/u/$handle'
| '/user/$handle'
| '/creators/'
| '/packages/'
| '/plugins/'
| '/publishers/'
@@ -550,6 +560,7 @@ export interface FileRouteTypes {
| '/skills/publish'
| '/u/$handle'
| '/user/$handle'
| '/creators'
| '/packages'
| '/plugins'
| '/publishers'
@@ -601,6 +612,7 @@ export interface FileRouteTypes {
| '/skills/publish'
| '/u/$handle'
| '/user/$handle'
| '/creators/'
| '/packages/'
| '/plugins/'
| '/publishers/'
@@ -653,6 +665,7 @@ export interface RootRouteChildren {
SkillsPublishRoute: typeof SkillsPublishRoute
UHandleRoute: typeof UHandleRoute
UserHandleRoute: typeof UserHandleRoute
CreatorsIndexRoute: typeof CreatorsIndexRoute
PackagesIndexRoute: typeof PackagesIndexRoute
PluginsIndexRoute: typeof PluginsIndexRoute
PublishersIndexRoute: typeof PublishersIndexRoute
@@ -799,6 +812,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PackagesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/creators/': {
id: '/creators/'
path: '/creators'
fullPath: '/creators/'
preLoaderRoute: typeof CreatorsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/user/$handle': {
id: '/user/$handle'
path: '/user/$handle'
@@ -1113,6 +1133,7 @@ const rootRouteChildren: RootRouteChildren = {
SkillsPublishRoute: SkillsPublishRoute,
UHandleRoute: UHandleRoute,
UserHandleRoute: UserHandleRoute,
CreatorsIndexRoute: CreatorsIndexRoute,
PackagesIndexRoute: PackagesIndexRoute,
PluginsIndexRoute: PluginsIndexRoute,
PublishersIndexRoute: PublishersIndexRoute,
+395
View File
@@ -0,0 +1,395 @@
import { createFileRoute } from "@tanstack/react-router";
import { BadgeCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import {
BrowseActions,
BrowseControls,
BrowseControlsRow,
BrowseSearchInput,
BrowseSearchPanel,
BrowseSearchTrigger,
BrowseTabs,
BrowseViewToggle,
useBrowseSearchDisclosure,
} from "../../components/BrowseControls";
import { PublisherListItem } from "../../components/PublisherListItem";
import { Button } from "../../components/ui/button";
import { convexHttp } from "../../convex/client";
import type { PublicPublisherListItem } from "../../lib/publicUser";
import { getClawHubSiteUrl, SITE_NAME } from "../../lib/site";
type PublisherKindSearch = "orgs" | "people";
type PublisherViewSearch = "list" | "grid";
type PublishersSearchState = {
kind?: PublisherKindSearch;
official?: boolean;
q?: string;
view?: PublisherViewSearch;
};
type PublishersLoaderResult = {
page: PublicPublisherListItem[];
counts: {
all: number;
organizations: number;
individuals: number;
};
globalCounts?: {
all: number;
organizations: number;
individuals: number;
};
continueCursor: string;
isDone: boolean;
};
const PUBLISHER_PAGE_SIZE = 25;
const PUBLISHER_KIND_OPTIONS = [
{ value: undefined, label: "All" },
{
value: "official",
label: "Official",
icon: <BadgeCheck size={14} strokeWidth={2.25} aria-hidden="true" />,
},
{ value: "orgs", label: "Organizations", mobileLabel: "Orgs" },
{ value: "people", label: "Users" },
];
function normalizePublisherKind(value: unknown): PublisherKindSearch | undefined {
if (value === "orgs") return "orgs";
if (value === "people" || value === "builders" || value === "individuals") return "people";
return undefined;
}
async function loadPublishersPage({
cursor,
kind,
official,
query,
}: {
cursor: string | null;
kind?: PublisherKindSearch;
official?: boolean;
query?: string;
}): Promise<PublishersLoaderResult> {
const baseArgs = {
kind: kind === "orgs" ? ("org" as const) : kind === "people" ? ("user" as const) : undefined,
query,
paginationOpts: { cursor, numItems: PUBLISHER_PAGE_SIZE },
};
return (await convexHttp.query(api.publishers.listPublicPage, {
...baseArgs,
...(official ? { official: true } : {}),
})) as PublishersLoaderResult;
}
export const Route = createFileRoute("/creators/")({
validateSearch: (search): PublishersSearchState => ({
kind: normalizePublisherKind(search.kind),
official:
search.official === true || search.official === "true" || search.official === "1"
? true
: undefined,
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
view: search.view === "grid" ? "grid" : undefined,
}),
loaderDeps: ({ search }) => ({
kind: search.kind,
official: search.official,
q: search.q,
}),
head: () => {
const siteUrl = getClawHubSiteUrl();
const title = `Creators · ${SITE_NAME}`;
const description =
"Discover the people and organizations publishing skills, plugins, packages, and ecosystem tooling on ClawHub.";
return {
links: [
{
rel: "canonical",
href: `${siteUrl}/creators`,
},
],
meta: [
{ title },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${siteUrl}/creators` },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
],
};
},
loader: async ({ deps }): Promise<PublishersLoaderResult> =>
await loadPublishersPage({
cursor: null,
kind: deps.kind,
official: deps.official,
query: deps.q,
}),
component: PublishersIndex,
});
function PublishersIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const result = Route.useLoaderData() as PublishersLoaderResult;
const [query, setQuery] = useState(search.q ?? "");
const [publishers, setPublishers] = useState(result.page);
const [nextCursor, setNextCursor] = useState<string | null>(
result.isDone ? null : result.continueCursor,
);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadMoreInFlightRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchNavigateTimer = useRef<number>(0);
const activeKind = search.kind;
const officialOnly = search.official === true;
const activeView = search.view ?? "list";
const canLoadMore = Boolean(nextCursor);
const hasQuery = Boolean(search.q?.trim());
const showHighlights = !hasQuery && !activeKind && !officialOnly;
const highlightedPublishers = showHighlights ? publishers.slice(0, 3) : [];
const directoryPublishers = showHighlights ? publishers.slice(3) : publishers;
useEffect(() => {
window.clearTimeout(searchNavigateTimer.current);
setQuery(search.q ?? "");
setPublishers(result.page);
setNextCursor(result.isDone ? null : result.continueCursor);
setIsLoadingMore(false);
loadMoreInFlightRef.current = false;
}, [result, search.q]);
useEffect(() => {
return () => window.clearTimeout(searchNavigateTimer.current);
}, []);
const navigateToPublisherSearch = useCallback(
(next: string, replace: boolean) => {
const trimmed = next.trim();
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
q: trimmed ? next : undefined,
}),
replace,
});
},
[navigate],
);
const handleQueryChange = useCallback(
(next: string) => {
setQuery(next);
window.clearTimeout(searchNavigateTimer.current);
searchNavigateTimer.current = window.setTimeout(() => {
navigateToPublisherSearch(next, true);
}, 250);
},
[navigateToPublisherSearch],
);
const handleClearQuery = useCallback(() => {
window.clearTimeout(searchNavigateTimer.current);
setQuery("");
searchInputRef.current?.focus();
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
q: undefined,
}),
replace: true,
});
}, [navigate]);
const handleSearchSubmit = useCallback(() => {
window.clearTimeout(searchNavigateTimer.current);
navigateToPublisherSearch(query, false);
}, [navigateToPublisherSearch, query]);
const browseSearch = useBrowseSearchDisclosure({
value: query,
onClear: handleClearQuery,
inputRef: searchInputRef,
});
const handleKindChange = useCallback(
(kind: string | undefined) => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
kind: normalizePublisherKind(kind),
official: undefined,
}),
replace: true,
});
},
[navigate],
);
const handleOfficialChange = useCallback(() => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
kind: undefined,
official: true,
}),
replace: true,
});
}, [navigate]);
const handlePublisherTabChange = useCallback(
(value: string | undefined) => {
if (value === "official") {
handleOfficialChange();
return;
}
handleKindChange(value);
},
[handleKindChange, handleOfficialChange],
);
const handleToggleView = useCallback(() => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
view: prev.view === "grid" ? undefined : "grid",
}),
replace: true,
});
}, [navigate]);
const loadMore = useCallback(async () => {
if (!nextCursor || loadMoreInFlightRef.current) return;
loadMoreInFlightRef.current = true;
setIsLoadingMore(true);
try {
const page = await loadPublishersPage({
cursor: nextCursor,
kind: activeKind,
official: officialOnly || undefined,
query: search.q,
});
setPublishers((previous) => [...previous, ...page.page]);
setNextCursor(page.isDone ? null : page.continueCursor);
} finally {
setIsLoadingMore(false);
loadMoreInFlightRef.current = false;
}
}, [activeKind, nextCursor, officialOnly, search.q]);
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === "undefined") return () => {};
const target = loadMoreRef.current;
if (!target) return () => {};
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
void loadMore();
}
},
{ rootMargin: "200px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [canLoadMore, loadMore]);
return (
<main className="browse-page browse-page-borderless-header publishers-browse-page">
<div className="browse-page-header">
<h1 className="browse-title">Creators</h1>
</div>
<BrowseControls>
<BrowseControlsRow>
<BrowseTabs
ariaLabel="Publisher type"
options={PUBLISHER_KIND_OPTIONS}
value={officialOnly ? "official" : activeKind}
onChange={handlePublisherTabChange}
/>
<BrowseActions>
<BrowseSearchTrigger
open={browseSearch.open}
onOpen={browseSearch.openSearch}
label="Search publishers"
/>
<BrowseViewToggle view={activeView} onToggle={handleToggleView} />
</BrowseActions>
</BrowseControlsRow>
<BrowseSearchPanel open={browseSearch.open}>
<BrowseSearchInput
inputRef={searchInputRef}
label="publisher search"
placeholder="Search publishers..."
value={query}
onChange={handleQueryChange}
onClear={browseSearch.closeSearch}
onSubmit={handleSearchSubmit}
closeLabel="Close search"
/>
</BrowseSearchPanel>
</BrowseControls>
<div className="browse-layout">
<div className="browse-results">
{highlightedPublishers.length > 0 ? (
<section className="publisher-highlights" aria-labelledby="publisher-highlights-title">
<div className="publisher-section-heading">
<h2 id="publisher-highlights-title">Popular publishers</h2>
</div>
<div className="publisher-highlight-grid">
{highlightedPublishers.map((publisher) => (
<PublisherListItem
key={publisher._id}
publisher={publisher}
variant="highlight"
/>
))}
</div>
</section>
) : null}
{publishers.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No publishers found</p>
</div>
) : activeView === "grid" ? (
<div className={`publisher-directory-list publisher-directory-${activeView}`}>
{directoryPublishers.map((publisher) => (
<PublisherListItem key={publisher._id} publisher={publisher} variant="grid" />
))}
</div>
) : (
<div className="browse-list-stack">
<div className="browse-list-head browse-list-head-publishers" aria-hidden="true">
<span className="browse-list-head-label">Creator</span>
<span className="browse-list-head-label browse-list-head-stat">Activity</span>
</div>
<div className="publisher-directory-list">
{directoryPublishers.map((publisher) => (
<PublisherListItem key={publisher._id} publisher={publisher} variant="list" />
))}
</div>
</div>
)}
{canLoadMore || isLoadingMore ? (
<div ref={loadMoreRef} className="card mt-4 flex justify-center">
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
</div>
</div>
</main>
);
}
+3 -391
View File
@@ -1,395 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { BadgeCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import {
BrowseActions,
BrowseControls,
BrowseControlsRow,
BrowseSearchInput,
BrowseSearchPanel,
BrowseSearchTrigger,
BrowseTabs,
BrowseViewToggle,
useBrowseSearchDisclosure,
} from "../../components/BrowseControls";
import { PublisherListItem } from "../../components/PublisherListItem";
import { Button } from "../../components/ui/button";
import { convexHttp } from "../../convex/client";
import type { PublicPublisherListItem } from "../../lib/publicUser";
import { getClawHubSiteUrl, SITE_NAME } from "../../lib/site";
type PublisherKindSearch = "orgs" | "people";
type PublisherViewSearch = "list" | "grid";
type PublishersSearchState = {
kind?: PublisherKindSearch;
official?: boolean;
q?: string;
view?: PublisherViewSearch;
};
type PublishersLoaderResult = {
page: PublicPublisherListItem[];
counts: {
all: number;
organizations: number;
individuals: number;
};
globalCounts?: {
all: number;
organizations: number;
individuals: number;
};
continueCursor: string;
isDone: boolean;
};
const PUBLISHER_PAGE_SIZE = 25;
const PUBLISHER_KIND_OPTIONS = [
{ value: undefined, label: "All" },
{ value: "orgs", label: "Orgs" },
{ value: "people", label: "Creators" },
{
value: "official",
label: "Officials",
icon: <BadgeCheck size={14} strokeWidth={2.25} aria-hidden="true" />,
},
];
function normalizePublisherKind(value: unknown): PublisherKindSearch | undefined {
if (value === "orgs") return "orgs";
if (value === "people" || value === "builders" || value === "individuals") return "people";
return undefined;
}
async function loadPublishersPage({
cursor,
kind,
official,
query,
}: {
cursor: string | null;
kind?: PublisherKindSearch;
official?: boolean;
query?: string;
}): Promise<PublishersLoaderResult> {
const baseArgs = {
kind: kind === "orgs" ? ("org" as const) : kind === "people" ? ("user" as const) : undefined,
query,
paginationOpts: { cursor, numItems: PUBLISHER_PAGE_SIZE },
};
return (await convexHttp.query(api.publishers.listPublicPage, {
...baseArgs,
...(official ? { official: true } : {}),
})) as PublishersLoaderResult;
}
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/publishers/")({
validateSearch: (search): PublishersSearchState => ({
kind: normalizePublisherKind(search.kind),
official:
search.official === true || search.official === "true" || search.official === "1"
? true
: undefined,
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
view: search.view === "grid" ? "grid" : undefined,
}),
loaderDeps: ({ search }) => ({
kind: search.kind,
official: search.official,
q: search.q,
}),
head: () => {
const siteUrl = getClawHubSiteUrl();
const title = `Publishers · ${SITE_NAME}`;
const description =
"Discover the people and organizations publishing skills, plugins, packages, and ecosystem tooling on ClawHub.";
return {
links: [
{
rel: "canonical",
href: `${siteUrl}/publishers`,
},
],
meta: [
{ title },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${siteUrl}/publishers` },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
],
};
beforeLoad: ({ search }) => {
throw redirect({ to: "/creators", search, replace: true });
},
loader: async ({ deps }): Promise<PublishersLoaderResult> =>
await loadPublishersPage({
cursor: null,
kind: deps.kind,
official: deps.official,
query: deps.q,
}),
component: PublishersIndex,
});
function PublishersIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const result = Route.useLoaderData() as PublishersLoaderResult;
const [query, setQuery] = useState(search.q ?? "");
const [publishers, setPublishers] = useState(result.page);
const [nextCursor, setNextCursor] = useState<string | null>(
result.isDone ? null : result.continueCursor,
);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadMoreInFlightRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchNavigateTimer = useRef<number>(0);
const activeKind = search.kind;
const officialOnly = search.official === true;
const activeView = search.view ?? "list";
const canLoadMore = Boolean(nextCursor);
const hasQuery = Boolean(search.q?.trim());
const showHighlights = !hasQuery && !activeKind && !officialOnly;
const highlightedPublishers = showHighlights ? publishers.slice(0, 3) : [];
const directoryPublishers = showHighlights ? publishers.slice(3) : publishers;
useEffect(() => {
window.clearTimeout(searchNavigateTimer.current);
setQuery(search.q ?? "");
setPublishers(result.page);
setNextCursor(result.isDone ? null : result.continueCursor);
setIsLoadingMore(false);
loadMoreInFlightRef.current = false;
}, [result, search.q]);
useEffect(() => {
return () => window.clearTimeout(searchNavigateTimer.current);
}, []);
const navigateToPublisherSearch = useCallback(
(next: string, replace: boolean) => {
const trimmed = next.trim();
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
q: trimmed ? next : undefined,
}),
replace,
});
},
[navigate],
);
const handleQueryChange = useCallback(
(next: string) => {
setQuery(next);
window.clearTimeout(searchNavigateTimer.current);
searchNavigateTimer.current = window.setTimeout(() => {
navigateToPublisherSearch(next, true);
}, 250);
},
[navigateToPublisherSearch],
);
const handleClearQuery = useCallback(() => {
window.clearTimeout(searchNavigateTimer.current);
setQuery("");
searchInputRef.current?.focus();
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
q: undefined,
}),
replace: true,
});
}, [navigate]);
const handleSearchSubmit = useCallback(() => {
window.clearTimeout(searchNavigateTimer.current);
navigateToPublisherSearch(query, false);
}, [navigateToPublisherSearch, query]);
const browseSearch = useBrowseSearchDisclosure({
value: query,
onClear: handleClearQuery,
inputRef: searchInputRef,
});
const handleKindChange = useCallback(
(kind: string | undefined) => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
kind: normalizePublisherKind(kind),
official: undefined,
}),
replace: true,
});
},
[navigate],
);
const handleOfficialChange = useCallback(() => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
kind: undefined,
official: true,
}),
replace: true,
});
}, [navigate]);
const handlePublisherTabChange = useCallback(
(value: string | undefined) => {
if (value === "official") {
handleOfficialChange();
return;
}
handleKindChange(value);
},
[handleKindChange, handleOfficialChange],
);
const handleToggleView = useCallback(() => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
view: prev.view === "grid" ? undefined : "grid",
}),
replace: true,
});
}, [navigate]);
const loadMore = useCallback(async () => {
if (!nextCursor || loadMoreInFlightRef.current) return;
loadMoreInFlightRef.current = true;
setIsLoadingMore(true);
try {
const page = await loadPublishersPage({
cursor: nextCursor,
kind: activeKind,
official: officialOnly || undefined,
query: search.q,
});
setPublishers((previous) => [...previous, ...page.page]);
setNextCursor(page.isDone ? null : page.continueCursor);
} finally {
setIsLoadingMore(false);
loadMoreInFlightRef.current = false;
}
}, [activeKind, nextCursor, officialOnly, search.q]);
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === "undefined") return () => {};
const target = loadMoreRef.current;
if (!target) return () => {};
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
void loadMore();
}
},
{ rootMargin: "200px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [canLoadMore, loadMore]);
return (
<main className="browse-page browse-page-borderless-header publishers-browse-page">
<div className="browse-page-header">
<h1 className="browse-title">Publishers</h1>
</div>
<BrowseControls>
<BrowseControlsRow>
<BrowseTabs
ariaLabel="Publisher type"
options={PUBLISHER_KIND_OPTIONS}
value={officialOnly ? "official" : activeKind}
onChange={handlePublisherTabChange}
/>
<BrowseActions>
<BrowseSearchTrigger
open={browseSearch.open}
onOpen={browseSearch.openSearch}
label="Search publishers"
/>
<BrowseViewToggle view={activeView} onToggle={handleToggleView} />
</BrowseActions>
</BrowseControlsRow>
<BrowseSearchPanel open={browseSearch.open}>
<BrowseSearchInput
inputRef={searchInputRef}
label="publisher search"
placeholder="Search publishers..."
value={query}
onChange={handleQueryChange}
onClear={browseSearch.closeSearch}
onSubmit={handleSearchSubmit}
closeLabel="Close search"
/>
</BrowseSearchPanel>
</BrowseControls>
<div className="browse-layout">
<div className="browse-results">
{highlightedPublishers.length > 0 ? (
<section className="publisher-highlights" aria-labelledby="publisher-highlights-title">
<div className="publisher-section-heading">
<h2 id="publisher-highlights-title">Popular publishers</h2>
</div>
<div className="publisher-highlight-grid">
{highlightedPublishers.map((publisher) => (
<PublisherListItem
key={publisher._id}
publisher={publisher}
variant="highlight"
/>
))}
</div>
</section>
) : null}
{publishers.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No publishers found</p>
</div>
) : activeView === "grid" ? (
<div className={`publisher-directory-list publisher-directory-${activeView}`}>
{directoryPublishers.map((publisher) => (
<PublisherListItem key={publisher._id} publisher={publisher} variant="grid" />
))}
</div>
) : (
<div className="browse-list-stack">
<div className="browse-list-head browse-list-head-publishers" aria-hidden="true">
<span className="browse-list-head-label">Creator</span>
<span className="browse-list-head-label browse-list-head-stat">Activity</span>
</div>
<div className="publisher-directory-list">
{directoryPublishers.map((publisher) => (
<PublisherListItem key={publisher._id} publisher={publisher} variant="list" />
))}
</div>
</div>
)}
{canLoadMore || isLoadingMore ? (
<div ref={loadMoreRef} className="card mt-4 flex justify-center">
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
</div>
</div>
</main>
);
}
+1 -1
View File
@@ -186,7 +186,7 @@ export function PublisherProfilePage({
icon={Building2}
title="Publisher not found"
description="This publisher doesn't exist or may have been removed."
action={{ label: "Browse publishers", href: "/publishers" }}
action={{ label: "Browse creators", href: "/creators" }}
/>
</Container>
</main>
+1 -1
View File
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/users/")({
beforeLoad: ({ search }) => {
throw redirect({ to: "/publishers", search, replace: true });
throw redirect({ to: "/creators", search, replace: true });
},
});
+5 -2
View File
@@ -28445,11 +28445,14 @@ a[class*="rounded-full"] {
flex-grow: 0.75;
}
.publishers-browse-page .browse-tab:nth-child(3),
.publishers-browse-page .browse-tab:nth-child(4) {
.publishers-browse-page .browse-tab:nth-child(3) {
flex-grow: 1.2;
}
.publishers-browse-page .browse-tab:nth-child(4) {
flex-grow: 0.75;
}
.browse-category-select {
order: 1;
flex: 1 1 auto;