mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: replace creators directory with official orgs (#3247)
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
} from "./publicRouteReservations";
|
||||
|
||||
describe("public route reservations", () => {
|
||||
it.each(["admin", "clawhub", "creators", "docs", "plugins", "publishers", "skills"])(
|
||||
it.each(["admin", "clawhub", "docs", "official", "plugins", "publishers", "skills"])(
|
||||
"reserves @%s as a public owner handle",
|
||||
(handle) => {
|
||||
expect(isReservedPublicOwnerHandle(handle)).toBe(true);
|
||||
@@ -17,6 +17,10 @@ describe("public route reservations", () => {
|
||||
expect(isReservedPublicOwnerHandle(handle)).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the removed creators route handle", () => {
|
||||
expect(isReservedPublicOwnerHandle("creators")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not normalize at-sign prefixes", () => {
|
||||
expect(isReservedPublicOwnerHandle("@clawhub")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { OPENCLAW_EXTENSION_SLUG_TO_PACKAGE } from "clawhub-schema";
|
||||
const RESERVED_PUBLIC_OWNER_HANDLES = new Set([
|
||||
"admin",
|
||||
"clawhub",
|
||||
"creators",
|
||||
"docs",
|
||||
"official",
|
||||
"plugins",
|
||||
"publishers",
|
||||
"skills",
|
||||
|
||||
@@ -56,6 +56,10 @@ describe("assertValidSkillSlug", () => {
|
||||
expect(assertValidSkillSlug("A-B-C")).toBe("a-b-c");
|
||||
});
|
||||
|
||||
it("allows the removed creators route slug", () => {
|
||||
expect(assertValidSkillSlug("creators")).toBe("creators");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "required"],
|
||||
[" ", "required"],
|
||||
@@ -82,7 +86,7 @@ describe("assertValidSkillSlug", () => {
|
||||
"clawhub",
|
||||
"souls",
|
||||
"packages",
|
||||
"creators",
|
||||
"official",
|
||||
"publishers",
|
||||
])("rejects reserved slug %s", (slug) => {
|
||||
// Some short reserved entries (e.g. "u") are also blocked by the
|
||||
@@ -90,7 +94,7 @@ describe("assertValidSkillSlug", () => {
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow();
|
||||
});
|
||||
|
||||
it.each(["openclaw", "creators", "publishers"])(
|
||||
it.each(["openclaw", "official", "publishers"])(
|
||||
"emits the reserved-specific error for long reserved slug %s",
|
||||
(slug) => {
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow(/reserved/i);
|
||||
|
||||
@@ -34,7 +34,7 @@ const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
|
||||
"orgs",
|
||||
"packages",
|
||||
"plugins",
|
||||
"creators",
|
||||
"official",
|
||||
"publishers",
|
||||
"publish",
|
||||
"publish-plugin",
|
||||
@@ -86,7 +86,6 @@ const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
|
||||
"system",
|
||||
"root",
|
||||
"owner",
|
||||
"official",
|
||||
"staff",
|
||||
"team",
|
||||
"mod",
|
||||
|
||||
@@ -126,18 +126,18 @@ function publicRouteCases(): PublicRouteCase[] {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "creators browse",
|
||||
path: () => "/creators",
|
||||
label: "official browse",
|
||||
path: () => "/official",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /^Creators/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /^Official/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "publishers browse redirect",
|
||||
path: () => "/publishers",
|
||||
assert: async (page) => {
|
||||
await expect(page).toHaveURL(/\/creators/);
|
||||
await expect(page.getByRole("heading", { name: /^Creators/ })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/official/);
|
||||
await expect(page.getByRole("heading", { name: /^Official/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -241,3 +241,10 @@ for (const route of publicRouteCases()) {
|
||||
await expectPublicRouteHealthy(page, route, fixtures);
|
||||
});
|
||||
}
|
||||
|
||||
test("removed creators route renders not found", async ({ page }) => {
|
||||
await stubExternalMediaInVitePreview(page);
|
||||
await page.goto("/creators", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: "We couldn't find that page." })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { loaderDataMock, navigateMock, queryMock, searchMock } = vi.hoisted(() => ({
|
||||
loaderDataMock: vi.fn(),
|
||||
navigateMock: vi.fn(),
|
||||
queryMock: vi.fn(),
|
||||
searchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: {
|
||||
component?: unknown;
|
||||
head?: unknown;
|
||||
loader?: unknown;
|
||||
loaderDeps?: unknown;
|
||||
validateSearch?: unknown;
|
||||
}) => ({
|
||||
__config: config,
|
||||
useLoaderData: () => loaderDataMock(),
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => searchMock(),
|
||||
}),
|
||||
Link: ({
|
||||
children,
|
||||
className,
|
||||
resetScroll: _resetScroll,
|
||||
to,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
resetScroll?: boolean;
|
||||
to?: string;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<a className={className} href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../components/PublisherListItem", () => ({
|
||||
PublisherListItem: ({ publisher }: { publisher: { _id: string } }) => <div>{publisher._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
SITE_NAME: "ClawHub",
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/creators/index")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
head?: () => {
|
||||
links?: Array<{ rel: string; href: string }>;
|
||||
meta?: Array<Record<string, string>>;
|
||||
};
|
||||
loader?: (args: {
|
||||
deps: { kind?: "orgs" | "people"; official?: boolean; q?: string };
|
||||
}) => Promise<unknown>;
|
||||
validateSearch?: (search: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("creators route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
loaderDataMock.mockReset();
|
||||
loaderDataMock.mockReturnValue({
|
||||
page: [],
|
||||
counts: { all: 0, organizations: 0, individuals: 0 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
navigateMock.mockReset();
|
||||
queryMock.mockReset();
|
||||
queryMock.mockResolvedValue({
|
||||
page: [],
|
||||
counts: { all: 0, organizations: 0, individuals: 0 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
searchMock.mockReset();
|
||||
searchMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("renders the public creators listing surface", async () => {
|
||||
const route = await loadRoute();
|
||||
const result = await route.__config.loader?.({ deps: {} });
|
||||
|
||||
expect(result).toEqual({
|
||||
page: [],
|
||||
counts: { all: 0, organizations: 0, individuals: 0 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
kind: undefined,
|
||||
query: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the organization filter to the public publishers query", async () => {
|
||||
const route = await loadRoute();
|
||||
await route.__config.loader?.({ deps: { kind: "orgs" } });
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
kind: "org",
|
||||
query: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes legacy builder URLs to people", async () => {
|
||||
const route = await loadRoute();
|
||||
|
||||
expect(route.__config.validateSearch?.({ kind: "builders" })).toEqual({
|
||||
kind: "people",
|
||||
official: undefined,
|
||||
q: undefined,
|
||||
view: undefined,
|
||||
});
|
||||
expect(route.__config.validateSearch?.({ kind: "individuals" })).toEqual({
|
||||
kind: "people",
|
||||
official: undefined,
|
||||
q: undefined,
|
||||
view: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the people filter and query to the public publishers query", async () => {
|
||||
const route = await loadRoute();
|
||||
await route.__config.loader?.({ deps: { kind: "people", q: "openclaw" } });
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
kind: "user",
|
||||
query: "openclaw",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the official filter to the public publishers query without a legacy scan", async () => {
|
||||
const route = await loadRoute();
|
||||
|
||||
await route.__config.loader?.({ deps: { official: true } });
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
kind: undefined,
|
||||
query: undefined,
|
||||
official: true,
|
||||
});
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the loaded publisher results", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Creators")).toBeTruthy();
|
||||
expect(screen.getByText("No publishers found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("labels highlighted publishers as popular creators", async () => {
|
||||
loaderDataMock.mockReturnValue({
|
||||
page: [{ _id: "publishers:one" }],
|
||||
counts: { all: 1, organizations: 0, individuals: 1 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Popular creators" })).toBeTruthy();
|
||||
expect(screen.queryByText("Popular publishers")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not present the bounded publisher result count as a global total", async () => {
|
||||
loaderDataMock.mockReturnValue({
|
||||
page: [],
|
||||
counts: { all: 17, organizations: 6, individuals: 11 },
|
||||
globalCounts: { all: 17, organizations: 6, individuals: 11 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Creators" })).toBeTruthy();
|
||||
expect(screen.queryByText("17")).toBeNull();
|
||||
expect(screen.getByRole("radio", { name: "All" })).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();
|
||||
});
|
||||
|
||||
it("keeps the publisher heading unchanged when filters are active", async () => {
|
||||
searchMock.mockReturnValue({ kind: "orgs" });
|
||||
loaderDataMock.mockReturnValue({
|
||||
page: [],
|
||||
counts: { all: 6, organizations: 6, individuals: 0 },
|
||||
globalCounts: { all: 17, organizations: 6, individuals: 11 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Creators" })).toBeTruthy();
|
||||
expect(screen.queryByText("17")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps publisher type filters and view controls in the horizontal controls", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
const allTab = screen.getByRole("radio", { name: "All" });
|
||||
const listView = screen.getByRole("button", { name: "List" });
|
||||
const searchInput = screen.getByPlaceholderText("Search publishers...");
|
||||
|
||||
expect(allTab.closest(".browse-controls")).not.toBeNull();
|
||||
expect(listView.closest(".browse-controls")).not.toBeNull();
|
||||
expect(
|
||||
Boolean(listView.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("clears publisher search from the search field", async () => {
|
||||
searchMock.mockReturnValue({ q: "ope", kind: "orgs" });
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close search" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace?: boolean;
|
||||
};
|
||||
expect(lastCall.search({ q: "ope", kind: "orgs" })).toEqual({
|
||||
q: undefined,
|
||||
kind: "orgs",
|
||||
});
|
||||
expect(lastCall.replace).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
|
||||
});
|
||||
|
||||
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/creators" });
|
||||
expect(head?.meta).toContainEqual({ property: "og:title", content: "Creators · ClawHub" });
|
||||
});
|
||||
});
|
||||
@@ -298,7 +298,7 @@ describe("Header", () => {
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Creators")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Official")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Docs")).toHaveLength(2);
|
||||
expect(screen.queryByText("About")).toBeNull();
|
||||
expect(screen.queryByText("Dashboard")).toBeNull();
|
||||
@@ -310,7 +310,7 @@ describe("Header", () => {
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Creators")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Official")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Docs")).toHaveLength(3);
|
||||
expect(screen.queryByText("About")).toBeNull();
|
||||
});
|
||||
@@ -657,7 +657,7 @@ describe("Header", () => {
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
expect(labels.slice(0, 5)).toEqual(["Home", "Skills", "Plugins", "Creators", "Docs"]);
|
||||
expect(labels.slice(0, 5)).toEqual(["Home", "Skills", "Plugins", "Official", "Docs"]);
|
||||
expect(
|
||||
document.querySelector(".mobile-nav-appearance-section .navbar-theme-switcher"),
|
||||
).toBeTruthy();
|
||||
@@ -684,7 +684,7 @@ describe("Header", () => {
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
expect(labels).toEqual(["Home", "Skills", "Plugins", "Creators", "Docs"]);
|
||||
expect(labels).toEqual(["Home", "Skills", "Plugins", "Official", "Docs"]);
|
||||
});
|
||||
|
||||
it("links profile and starred skills from the signed-in avatar menu", () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
className={className}
|
||||
data-search={search ? JSON.stringify(search) : undefined}
|
||||
href={
|
||||
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/creators"
|
||||
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/official"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
@@ -105,9 +105,7 @@ describe("HomePopularPublishersSection", () => {
|
||||
expect(convexQueryMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("heading", { name: "Official creators" })).toBeTruthy();
|
||||
expect(screen.getByText("Explore skills and plugins from official creators.")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Browse creators" }).dataset.search).toBe(
|
||||
'{"official":true,"kind":"orgs"}',
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Browse official" }).dataset.search).toBeUndefined();
|
||||
|
||||
await enterPublisherSection();
|
||||
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentType } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { loaderDataMock, navigateMock, publisherListItemMock, queryMock, searchMock } = vi.hoisted(
|
||||
() => ({
|
||||
loaderDataMock: vi.fn(),
|
||||
navigateMock: vi.fn(),
|
||||
publisherListItemMock: vi.fn(),
|
||||
queryMock: vi.fn(),
|
||||
searchMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: {
|
||||
component?: unknown;
|
||||
head?: unknown;
|
||||
loader?: unknown;
|
||||
loaderDeps?: unknown;
|
||||
validateSearch?: unknown;
|
||||
}) => ({
|
||||
__config: config,
|
||||
useLoaderData: () => loaderDataMock(),
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => searchMock(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../components/PublisherListItem", () => ({
|
||||
PublisherListItem: (props: {
|
||||
publisher: { _id: string };
|
||||
showOfficialBadge?: boolean;
|
||||
variant?: string;
|
||||
}) => {
|
||||
publisherListItemMock(props);
|
||||
return <div>{props.publisher._id}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
SITE_NAME: "ClawHub",
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/official/index")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
head?: () => {
|
||||
links?: Array<{ rel: string; href: string }>;
|
||||
meta?: Array<Record<string, string>>;
|
||||
};
|
||||
loader?: (args: { deps: { q?: string } }) => Promise<unknown>;
|
||||
validateSearch?: (search: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("official route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
loaderDataMock.mockReset();
|
||||
loaderDataMock.mockReturnValue({
|
||||
page: [],
|
||||
counts: { all: 0, organizations: 0, individuals: 0 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
navigateMock.mockReset();
|
||||
publisherListItemMock.mockReset();
|
||||
queryMock.mockReset();
|
||||
queryMock.mockResolvedValue({
|
||||
page: [],
|
||||
counts: { all: 0, organizations: 0, individuals: 0 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
searchMock.mockReset();
|
||||
searchMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("loads only official organizations", async () => {
|
||||
const route = await loadRoute();
|
||||
const result = await route.__config.loader?.({ deps: {} });
|
||||
|
||||
expect(result).toEqual({
|
||||
page: [],
|
||||
counts: { all: 0, organizations: 0, individuals: 0 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
kind: "org",
|
||||
official: true,
|
||||
query: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes search queries without exposing kind or official controls", async () => {
|
||||
const route = await loadRoute();
|
||||
await route.__config.loader?.({ deps: { q: "openclaw" } });
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
kind: "org",
|
||||
official: true,
|
||||
query: "openclaw",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the official header and list-only empty state", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Official" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("The organizations behind the top skills and plugins on ClawHub"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("No official organizations found")).toBeTruthy();
|
||||
expect(screen.queryByRole("radio")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Grid" })).toBeNull();
|
||||
expect(screen.queryByText("Popular publishers")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders official organizations in the existing table without redundant badges", async () => {
|
||||
loaderDataMock.mockReturnValue({
|
||||
page: [{ _id: "publishers:openclaw" }],
|
||||
counts: { all: 1, organizations: 1, individuals: 0 },
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Organization")).toBeTruthy();
|
||||
expect(screen.getByText("Activity")).toBeTruthy();
|
||||
expect(screen.getByText("publishers:openclaw")).toBeTruthy();
|
||||
expect(publisherListItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showOfficialBadge: false,
|
||||
variant: "list",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears official organization search from the search field", async () => {
|
||||
searchMock.mockReturnValue({ q: "ope" });
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close search" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace?: boolean;
|
||||
};
|
||||
expect(lastCall.search({ q: "ope" })).toEqual({ q: undefined });
|
||||
expect(lastCall.replace).toBe(true);
|
||||
});
|
||||
|
||||
it("sets official-specific sharing metadata", async () => {
|
||||
const route = await loadRoute();
|
||||
const head = route.__config.head?.();
|
||||
|
||||
expect(head?.links).toContainEqual({ rel: "canonical", href: "https://clawhub.ai/official" });
|
||||
expect(head?.meta).toContainEqual({ property: "og:title", content: "Official · ClawHub" });
|
||||
expect(head?.meta).toContainEqual({
|
||||
property: "og:description",
|
||||
content: "The organizations behind the top skills and plugins on ClawHub.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -421,7 +421,7 @@ describe("search route", () => {
|
||||
expect(screen.queryByRole("button", { name: "Search all types" })).toBeNull();
|
||||
});
|
||||
|
||||
it("links to creators browse when creator search has no matches", async () => {
|
||||
it("links to official organizations when creator search has no matches", async () => {
|
||||
searchMock = { q: "zzzz", type: "creators" };
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
@@ -429,9 +429,9 @@ describe("search route", () => {
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText('No matches for "zzzz"')).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Show all creators" }).getAttribute("href")).toBe(
|
||||
"/creators",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Browse official organizations" }).getAttribute("href"),
|
||||
).toBe("/official");
|
||||
expect(screen.queryByRole("link", { name: "Show all skills" })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Show all plugins" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -273,7 +273,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: "Creators"');
|
||||
expect(navSource).toContain('label: "Official"');
|
||||
expect(navSource).toContain('label: "Docs"');
|
||||
expect(navSource).toContain("href: CLAWHUB_DOCS_URL");
|
||||
expect(publicRegistrySource).toContain(
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
describe("users route redirect", () => {
|
||||
it("redirects legacy /users traffic to /creators", async () => {
|
||||
it("redirects legacy /users traffic to /official", async () => {
|
||||
const route = (await import("../routes/users/index")).Route as unknown as {
|
||||
__config: { beforeLoad: (args: { search: Record<string, unknown> }) => unknown };
|
||||
};
|
||||
@@ -25,10 +25,10 @@ describe("users route redirect", () => {
|
||||
const search = { q: "builder" };
|
||||
|
||||
expect(() => route.__config.beforeLoad({ search })).toThrow();
|
||||
expect(redirectMock).toHaveBeenCalledWith({ to: "/creators", search, replace: true });
|
||||
expect(redirectMock).toHaveBeenCalledWith({ to: "/official", search, replace: true });
|
||||
});
|
||||
|
||||
it("redirects legacy /publishers traffic to /creators", async () => {
|
||||
it("redirects legacy /publishers traffic to /official", async () => {
|
||||
const route = (await import("../routes/publishers/index")).Route as unknown as {
|
||||
__config: { beforeLoad: (args: { search: Record<string, unknown> }) => unknown };
|
||||
};
|
||||
@@ -36,7 +36,7 @@ describe("users route redirect", () => {
|
||||
const search = { kind: "orgs", q: "acme" };
|
||||
|
||||
expect(() => route.__config.beforeLoad({ search })).toThrow();
|
||||
expect(redirectMock).toHaveBeenCalledWith({ to: "/creators", search, replace: true });
|
||||
expect(redirectMock).toHaveBeenCalledWith({ to: "/official", search, replace: true });
|
||||
});
|
||||
|
||||
it("redirects legacy /p profile routes to user profiles", async () => {
|
||||
|
||||
@@ -146,12 +146,8 @@ export function HomePopularPublishersSection() {
|
||||
</h2>
|
||||
<p className="oc-section-copy">Explore skills and plugins from official creators.</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/creators"
|
||||
search={{ official: true, kind: "orgs" }}
|
||||
className="home-v2-popular-publishers-link oc-action oc-action-ghost"
|
||||
>
|
||||
Browse creators <ArrowRight size={14} aria-hidden="true" />
|
||||
<Link to="/official" className="home-v2-popular-publishers-link oc-action oc-action-ghost">
|
||||
Browse official <ArrowRight size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
<div
|
||||
|
||||
@@ -21,6 +21,15 @@ describe("PublisherListItem", () => {
|
||||
expect(container.querySelector(".official-badge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can hide the official mark when the surrounding page already communicates it", () => {
|
||||
const { container } = render(
|
||||
<PublisherListItem publisher={makePublisher()} showOfficialBadge={false} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Official")).toBeNull();
|
||||
expect(container.querySelector(".official-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders downloads as the adoption metric", () => {
|
||||
render(<PublisherListItem publisher={makePublisher()} />);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { OfficialBadge } from "./OfficialBadge";
|
||||
|
||||
type PublisherListItemProps = {
|
||||
publisher: PublicPublisherListItem;
|
||||
showOfficialBadge?: boolean;
|
||||
variant?: "list" | "grid" | "highlight";
|
||||
};
|
||||
|
||||
@@ -29,7 +30,11 @@ function PublishedRail({ items }: { items: PublicPublisherPublishedItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PublisherListItem({ publisher, variant = "list" }: PublisherListItemProps) {
|
||||
export function PublisherListItem({
|
||||
publisher,
|
||||
showOfficialBadge = true,
|
||||
variant = "list",
|
||||
}: PublisherListItemProps) {
|
||||
const handle = publisher.handle.trim();
|
||||
if (!handle) return null;
|
||||
|
||||
@@ -57,7 +62,7 @@ export function PublisherListItem({ publisher, variant = "list" }: PublisherList
|
||||
<span className="publisher-card-identity">
|
||||
<span className="publisher-card-title-row">
|
||||
<span className="publisher-card-name">{publisher.displayName}</span>
|
||||
{publisher.official ? <OfficialBadge /> : null}
|
||||
{showOfficialBadge && publisher.official ? <OfficialBadge /> : null}
|
||||
</span>
|
||||
<span className="publisher-card-handle">@{handle}</span>
|
||||
</span>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function DashboardWelcome({ ownerHandle, publisherSelector }: DashboardWe
|
||||
Skills
|
||||
</Link>
|
||||
<Link to="/plugins">Plugins</Link>
|
||||
<Link to="/creators">Creators</Link>
|
||||
<Link to="/official">Official</Link>
|
||||
<a href={CLAWHUB_DOCS_URL} target="_blank" rel="noreferrer">
|
||||
Docs
|
||||
<ArrowUpRight size={12} aria-hidden="true" />
|
||||
|
||||
@@ -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 | Creators
|
||||
// These map to the content-type tabs: Skills | Plugins | Official
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
@@ -60,9 +60,8 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
activePathPrefixes: ["/plugin/"],
|
||||
},
|
||||
{
|
||||
label: "Creators",
|
||||
to: PublicRegistryPaths.creators,
|
||||
activePathPrefixes: ["/publishers"],
|
||||
label: "Official",
|
||||
to: PublicRegistryPaths.official,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -117,7 +116,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: "Creators", to: PublicRegistryPaths.creators },
|
||||
{ kind: "link", label: "Official", to: PublicRegistryPaths.official },
|
||||
{
|
||||
kind: "link",
|
||||
label: "Audits",
|
||||
|
||||
@@ -8,7 +8,7 @@ export const PublicRegistryPaths = {
|
||||
home: "/",
|
||||
skills: "/skills",
|
||||
plugins: "/plugins",
|
||||
creators: "/creators",
|
||||
official: "/official",
|
||||
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: "Creators",
|
||||
path: PublicRegistryPaths.creators,
|
||||
summary: "Browse public user and organization creators.",
|
||||
label: "Official",
|
||||
path: PublicRegistryPaths.official,
|
||||
summary: "Browse official organizations publishing on ClawHub.",
|
||||
},
|
||||
{
|
||||
label: "Search",
|
||||
|
||||
+17
-17
@@ -28,7 +28,7 @@ import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
|
||||
import { Route as AuthDocsRouteImport } from './routes/auth/docs'
|
||||
import { Route as CliAuthRouteImport } from './routes/cli/auth'
|
||||
import { Route as CliDeviceRouteImport } from './routes/cli/device'
|
||||
import { Route as CreatorsIndexRouteImport } from './routes/creators/index'
|
||||
import { Route as OfficialIndexRouteImport } from './routes/official/index'
|
||||
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
|
||||
import { Route as PHandleRouteImport } from './routes/p/$handle'
|
||||
import { Route as PackagesIndexRouteImport } from './routes/packages/index'
|
||||
@@ -158,9 +158,9 @@ const CliDeviceRoute = CliDeviceRouteImport.update({
|
||||
path: '/cli/device',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CreatorsIndexRoute = CreatorsIndexRouteImport.update({
|
||||
id: '/creators/',
|
||||
path: '/creators/',
|
||||
const OfficialIndexRoute = OfficialIndexRouteImport.update({
|
||||
id: '/official/',
|
||||
path: '/official/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OrgsHandleRoute = OrgsHandleRouteImport.update({
|
||||
@@ -369,7 +369,7 @@ export interface FileRoutesByFullPath {
|
||||
'/skills/publish': typeof SkillsPublishRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
'/user/$handle': typeof UserHandleRoute
|
||||
'/creators/': typeof CreatorsIndexRoute
|
||||
'/official/': typeof OfficialIndexRoute
|
||||
'/packages/': typeof PackagesIndexRoute
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/publishers/': typeof PublishersIndexRoute
|
||||
@@ -424,7 +424,7 @@ export interface FileRoutesByTo {
|
||||
'/skills/publish': typeof SkillsPublishRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
'/user/$handle': typeof UserHandleRoute
|
||||
'/creators': typeof CreatorsIndexRoute
|
||||
'/official': typeof OfficialIndexRoute
|
||||
'/packages': typeof PackagesIndexRoute
|
||||
'/plugins': typeof PluginsIndexRoute
|
||||
'/publishers': typeof PublishersIndexRoute
|
||||
@@ -480,7 +480,7 @@ export interface FileRoutesById {
|
||||
'/skills/publish': typeof SkillsPublishRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
'/user/$handle': typeof UserHandleRoute
|
||||
'/creators/': typeof CreatorsIndexRoute
|
||||
'/official/': typeof OfficialIndexRoute
|
||||
'/packages/': typeof PackagesIndexRoute
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/publishers/': typeof PublishersIndexRoute
|
||||
@@ -537,7 +537,7 @@ export interface FileRouteTypes {
|
||||
| '/skills/publish'
|
||||
| '/u/$handle'
|
||||
| '/user/$handle'
|
||||
| '/creators/'
|
||||
| '/official/'
|
||||
| '/packages/'
|
||||
| '/plugins/'
|
||||
| '/publishers/'
|
||||
@@ -592,7 +592,7 @@ export interface FileRouteTypes {
|
||||
| '/skills/publish'
|
||||
| '/u/$handle'
|
||||
| '/user/$handle'
|
||||
| '/creators'
|
||||
| '/official'
|
||||
| '/packages'
|
||||
| '/plugins'
|
||||
| '/publishers'
|
||||
@@ -647,7 +647,7 @@ export interface FileRouteTypes {
|
||||
| '/skills/publish'
|
||||
| '/u/$handle'
|
||||
| '/user/$handle'
|
||||
| '/creators/'
|
||||
| '/official/'
|
||||
| '/packages/'
|
||||
| '/plugins/'
|
||||
| '/publishers/'
|
||||
@@ -703,7 +703,7 @@ export interface RootRouteChildren {
|
||||
SkillsPublishRoute: typeof SkillsPublishRoute
|
||||
UHandleRoute: typeof UHandleRoute
|
||||
UserHandleRoute: typeof UserHandleRoute
|
||||
CreatorsIndexRoute: typeof CreatorsIndexRoute
|
||||
OfficialIndexRoute: typeof OfficialIndexRoute
|
||||
PackagesIndexRoute: typeof PackagesIndexRoute
|
||||
PluginsIndexRoute: typeof PluginsIndexRoute
|
||||
PublishersIndexRoute: typeof PublishersIndexRoute
|
||||
@@ -851,11 +851,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof CliDeviceRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/creators/': {
|
||||
id: '/creators/'
|
||||
path: '/creators'
|
||||
fullPath: '/creators/'
|
||||
preLoaderRoute: typeof CreatorsIndexRouteImport
|
||||
'/official/': {
|
||||
id: '/official/'
|
||||
path: '/official'
|
||||
fullPath: '/official/'
|
||||
preLoaderRoute: typeof OfficialIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/orgs/$handle': {
|
||||
@@ -1197,7 +1197,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
SkillsPublishRoute: SkillsPublishRoute,
|
||||
UHandleRoute: UHandleRoute,
|
||||
UserHandleRoute: UserHandleRoute,
|
||||
CreatorsIndexRoute: CreatorsIndexRoute,
|
||||
OfficialIndexRoute: OfficialIndexRoute,
|
||||
PackagesIndexRoute: PackagesIndexRoute,
|
||||
PluginsIndexRoute: PluginsIndexRoute,
|
||||
PublishersIndexRoute: PublishersIndexRoute,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 {
|
||||
@@ -9,8 +8,6 @@ import {
|
||||
BrowseSearchInput,
|
||||
BrowseSearchPanel,
|
||||
BrowseSearchTrigger,
|
||||
BrowseTabs,
|
||||
BrowseViewToggle,
|
||||
useBrowseSearchDisclosure,
|
||||
} from "../../components/BrowseControls";
|
||||
import { PublisherListItem } from "../../components/PublisherListItem";
|
||||
@@ -19,17 +16,11 @@ 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;
|
||||
type OfficialSearchState = {
|
||||
q?: string;
|
||||
view?: PublisherViewSearch;
|
||||
};
|
||||
|
||||
type PublishersLoaderResult = {
|
||||
type OfficialLoaderResult = {
|
||||
page: PublicPublisherListItem[];
|
||||
counts: {
|
||||
all: number;
|
||||
@@ -46,72 +37,39 @@ type PublishersLoaderResult = {
|
||||
};
|
||||
|
||||
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({
|
||||
async function loadOfficialOrganizationsPage({
|
||||
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,
|
||||
}): Promise<OfficialLoaderResult> {
|
||||
return (await convexHttp.query(api.publishers.listPublicPage, {
|
||||
kind: "org",
|
||||
official: true,
|
||||
query,
|
||||
paginationOpts: { cursor, numItems: PUBLISHER_PAGE_SIZE },
|
||||
};
|
||||
|
||||
return (await convexHttp.query(api.publishers.listPublicPage, {
|
||||
...baseArgs,
|
||||
...(official ? { official: true } : {}),
|
||||
})) as PublishersLoaderResult;
|
||||
})) as OfficialLoaderResult;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/creators/")({
|
||||
validateSearch: (search): PublishersSearchState => ({
|
||||
kind: normalizePublisherKind(search.kind),
|
||||
official:
|
||||
search.official === true || search.official === "true" || search.official === "1"
|
||||
? true
|
||||
: undefined,
|
||||
export const Route = createFileRoute("/official/")({
|
||||
validateSearch: (search): OfficialSearchState => ({
|
||||
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.";
|
||||
const title = `Official · ${SITE_NAME}`;
|
||||
const description = "The organizations behind the top skills and plugins on ClawHub.";
|
||||
|
||||
return {
|
||||
links: [
|
||||
{
|
||||
rel: "canonical",
|
||||
href: `${siteUrl}/creators`,
|
||||
href: `${siteUrl}/official`,
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
@@ -120,26 +78,24 @@ export const Route = createFileRoute("/creators/")({
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: `${siteUrl}/creators` },
|
||||
{ property: "og:url", content: `${siteUrl}/official` },
|
||||
{ name: "twitter:title", content: title },
|
||||
{ name: "twitter:description", content: description },
|
||||
],
|
||||
};
|
||||
},
|
||||
loader: async ({ deps }): Promise<PublishersLoaderResult> =>
|
||||
await loadPublishersPage({
|
||||
loader: async ({ deps }): Promise<OfficialLoaderResult> =>
|
||||
await loadOfficialOrganizationsPage({
|
||||
cursor: null,
|
||||
kind: deps.kind,
|
||||
official: deps.official,
|
||||
query: deps.q,
|
||||
}),
|
||||
component: PublishersIndex,
|
||||
component: OfficialIndex,
|
||||
});
|
||||
|
||||
function PublishersIndex() {
|
||||
function OfficialIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const result = Route.useLoaderData() as PublishersLoaderResult;
|
||||
const result = Route.useLoaderData() as OfficialLoaderResult;
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [publishers, setPublishers] = useState(result.page);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(
|
||||
@@ -150,14 +106,7 @@ function PublishersIndex() {
|
||||
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);
|
||||
@@ -176,7 +125,7 @@ function PublishersIndex() {
|
||||
(next: string, replace: boolean) => {
|
||||
const trimmed = next.trim();
|
||||
void navigate({
|
||||
search: (prev: PublishersSearchState) => ({
|
||||
search: (prev: OfficialSearchState) => ({
|
||||
...prev,
|
||||
q: trimmed ? next : undefined,
|
||||
}),
|
||||
@@ -202,7 +151,7 @@ function PublishersIndex() {
|
||||
setQuery("");
|
||||
searchInputRef.current?.focus();
|
||||
void navigate({
|
||||
search: (prev: PublishersSearchState) => ({
|
||||
search: (prev: OfficialSearchState) => ({
|
||||
...prev,
|
||||
q: undefined,
|
||||
}),
|
||||
@@ -220,62 +169,13 @@ function PublishersIndex() {
|
||||
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({
|
||||
const page = await loadOfficialOrganizationsPage({
|
||||
cursor: nextCursor,
|
||||
kind: activeKind,
|
||||
official: officialOnly || undefined,
|
||||
query: search.q,
|
||||
});
|
||||
setPublishers((previous) => [...previous, ...page.page]);
|
||||
@@ -284,7 +184,7 @@ function PublishersIndex() {
|
||||
setIsLoadingMore(false);
|
||||
loadMoreInFlightRef.current = false;
|
||||
}
|
||||
}, [activeKind, nextCursor, officialOnly, search.q]);
|
||||
}, [nextCursor, search.q]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canLoadMore || typeof IntersectionObserver === "undefined") return () => {};
|
||||
@@ -304,32 +204,31 @@ function PublishersIndex() {
|
||||
}, [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>
|
||||
<main className="browse-page browse-page-borderless-header official-browse-page">
|
||||
<div className="browse-page-header official-page-header">
|
||||
<div className="browse-page-header-main">
|
||||
<h1 className="browse-title">Official</h1>
|
||||
<p className="official-page-description">
|
||||
The organizations behind the top skills and plugins on ClawHub
|
||||
</p>
|
||||
</div>
|
||||
</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"
|
||||
label="Search official organizations"
|
||||
/>
|
||||
<BrowseViewToggle view={activeView} onToggle={handleToggleView} />
|
||||
</BrowseActions>
|
||||
</BrowseControlsRow>
|
||||
<BrowseSearchPanel open={browseSearch.open}>
|
||||
<BrowseSearchInput
|
||||
inputRef={searchInputRef}
|
||||
label="publisher search"
|
||||
placeholder="Search publishers..."
|
||||
label="official organization search"
|
||||
placeholder="Search official organizations..."
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onClear={browseSearch.closeSearch}
|
||||
@@ -341,42 +240,24 @@ function PublishersIndex() {
|
||||
|
||||
<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 creators</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" />
|
||||
))}
|
||||
<p className="empty-state-title">No official organizations found</p>
|
||||
</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">Organization</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" />
|
||||
{publishers.map((publisher) => (
|
||||
<PublisherListItem
|
||||
key={publisher._id}
|
||||
publisher={publisher}
|
||||
variant="list"
|
||||
showOfficialBadge={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/publishers/")({
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({ to: "/creators", search, replace: true });
|
||||
throw redirect({ to: "/official", search, replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -315,12 +315,12 @@ function SearchEmptyState({
|
||||
query: string;
|
||||
}) {
|
||||
const browseHref =
|
||||
activeType === "plugins" ? "/plugins" : activeType === "creators" ? "/creators" : "/skills";
|
||||
activeType === "plugins" ? "/plugins" : activeType === "creators" ? "/official" : "/skills";
|
||||
const browseLabel =
|
||||
activeType === "plugins"
|
||||
? "Show all plugins"
|
||||
: activeType === "creators"
|
||||
? "Show all creators"
|
||||
? "Browse official organizations"
|
||||
: "Show all skills";
|
||||
|
||||
return (
|
||||
|
||||
@@ -709,7 +709,7 @@ export function PublisherProfilePage({
|
||||
icon={Building2}
|
||||
title="Publisher not found"
|
||||
description="This publisher doesn't exist or may have been removed."
|
||||
action={{ label: "Browse creators", href: "/creators" }}
|
||||
action={{ label: "Browse official organizations", href: "/official" }}
|
||||
/>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/users/")({
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({ to: "/creators", search, replace: true });
|
||||
throw redirect({ to: "/official", search, replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18307,6 +18307,17 @@ body:has(.browse-page-borderless-header) .navbar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.official-page-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.official-page-description {
|
||||
max-width: 760px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.browse-page-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -19367,6 +19378,10 @@ body:has(.browse-page-borderless-header) .navbar {
|
||||
.browse-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.official-page-description {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.browse-sidebar-toggle {
|
||||
|
||||
Reference in New Issue
Block a user