mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 +1 @@
|
||||
export {
|
||||
getOpenClawExtensionPackageName,
|
||||
getOpenClawPackageCandidateNames,
|
||||
OPENCLAW_EXTENSION_SLUG_TO_PACKAGE,
|
||||
} from "clawhub-schema";
|
||||
export { getOpenClawExtensionPackageName, getOpenClawPackageCandidateNames } from "clawhub-schema";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user