mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
Refresh homepage experience (#2756)
* style: add Mobbin-inspired home listing toolbar and grid Introduce a catalog section below the hero with Skills/Plugins toggle, sort tabs, filter affordance, and a card grid fed by public list APIs. Co-authored-by: Cursor <cursoragent@cursor.com> * style: switch home listing from card grid to horizontal rows Use icon, single-line summary, stats, and install CTA in scan-friendly rows inside one bordered list instead of a three-column card grid. Co-authored-by: Cursor <cursoragent@cursor.com> * style: refine home listing rows like reference catalogs Drop install CTAs, use muted icon tiles with brighter glyphs, and flatten the list into divider rows without index numbers. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(home): rebuild listing with search and category select Add inline catalog search, category dropdown with icons, grid/list views, and empty states tuned for the home listing toolbar. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: refresh homepage hero and listing * feat: add homepage apps section * feat: add homepage publish section * feat: add homepage ecosystem footer * fix: use favicon icons for homepage app plugins * fix: standardize footer styling globally * fix: add spacing above homepage apps link * fix: smooth homepage publish section transition * fix: neutralize homepage light mode background * fix: expand homepage app shortcuts grid * fix: neutralize light theme surfaces * fix: remove light publish section transition overlay * fix: square footer bottom edge * fix: simplify category menu scrollbar * fix: clarify homepage category filters * fix: refine mobile listing controls * fix: restore homepage listing fold fade * fix: tighten homepage listing row labels * fix: space homepage apps cta * fix: align homepage icons and app grids * fix: scroll homepage app tabs on mobile * fix: balance homepage app copy on mobile * fix: balance homepage cli copy on mobile * fix: refine mobile homepage motion * feat: add popular publishers to homepage * fix: remove stars from homepage listings * fix: rank homepage listings by installs * feat: show publisher catalog counts * fix: align publisher card stats * refactor: simplify publisher catalog counts * fix: tighten publisher section heading * fix: show app tiles on mobile * fix: center mobile apps banner * fix: use install icon in homepage listings * feat: expand popular publishers * feat: add mouse drag to publisher carousel * chore: update pinned publishers * fix: allow dragging publisher cards * fix: show app card backgrounds on hover * feat: curate homepage app shortcuts * chore: reorder featured publishers * fix: diversify homepage app shortcuts * fix: hide native listing search clear * fix: refine homepage app shortcuts and search reset * fix: keep app icon frames rounded * fix: hide officials tab for skills listing * fix: replace featured homepage tabs with new * fix: call homepage publishers creators * fix: simplify homepage hero subtitle * fix: use singular official plugin tab * fix: make official the first plugin tab * feat: add homepage trending skills tab * fix: use existing trending skills leaderboard * fix: show trending leaderboard installs * fix: tighten listing kind toggle padding * fix: normalize listing kind control padding * fix: restore equal listing kind padding * fix: shorten skills top tab label * fix: shorten plugin top tab label * fix: align trending skills installs metric * fix: normalize listing view toggle padding * Revert "fix: align trending skills installs metric" This reverts commit 5c421b5cd4326347b4b702949b035fdf954811d8. * Revert "fix: show trending leaderboard installs" This reverts commit 8c7daa0e59c9e07cb99c232a2a34e5afbab80eeb. * fix: hide stats on homepage trending skills * fix: center listing row hover background * chore: clean up homepage pre-pr checks * fix: page homepage listing results * fix: resolve homepage skill categories * fix: stabilize homepage listing shortcut * fix: harden homepage section rendering * fix: preserve homepage skill category results * perf: optimize homepage footer artwork * fix: extend homepage byos reveal field * fix: stabilize homepage CI --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
fc07846839
commit
ea0125d87e
Binary file not shown.
|
After Width: | Height: | Size: 722 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,533 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const convexQueryMock = vi.fn();
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
className,
|
||||
to,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a className={className} href={typeof to === "string" ? to : "/"}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
const convexActionMock = vi.fn();
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: {
|
||||
query: (...args: unknown[]) => convexQueryMock(...args),
|
||||
action: (...args: unknown[]) => convexActionMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
skills: {
|
||||
listPublicPageV4: "skills:listPublicPageV4",
|
||||
listPublicTrendingPage: "skills:listPublicTrendingPage",
|
||||
},
|
||||
search: {
|
||||
searchSkills: "search:searchSkills",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../lib/packageApi", () => ({
|
||||
fetchPluginCatalog: (...args: unknown[]) => fetchPluginCatalogMock(...args),
|
||||
}));
|
||||
|
||||
import { HomeListingSection } from "../components/HomeListingSection";
|
||||
|
||||
describe("HomeListingSection", () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
convexQueryMock.mockReset();
|
||||
convexActionMock.mockReset();
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
convexQueryMock.mockResolvedValue({
|
||||
page: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
summary: "A helpful skill.",
|
||||
stats: { stars: 12, downloads: 340 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Runs workflows.",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestVersion: "1.0.0",
|
||||
stats: { stars: 8, downloads: 120, installs: 120, versions: 1 },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the listing toolbar and skill cards by default", async () => {
|
||||
render(<HomeListingSection />);
|
||||
|
||||
expect(screen.getByRole("group", { name: "Content type" })).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Trending" })).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Skill")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to plugins and loads plugin cards", async () => {
|
||||
render(<HomeListingSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Top" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("120")).toBeTruthy();
|
||||
});
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens listing search from the toolbar icon and with slash", async () => {
|
||||
convexActionMock.mockResolvedValue([
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "alpha-skill",
|
||||
displayName: "Alpha Skill",
|
||||
summary: "Alpha",
|
||||
stats: { stars: 1, downloads: 1 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
expect(document.querySelector(".home-v2-listing-search.is-open")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close search" }));
|
||||
expect(document.querySelector(".home-v2-listing-search.is-open")).toBeNull();
|
||||
|
||||
fireEvent.keyDown(document, { key: "/" });
|
||||
|
||||
const searchInput = await screen.findByRole("searchbox", { name: "Search skills" });
|
||||
expect(document.querySelector(".home-v2-listing-search.is-open")).toBeTruthy();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "alpha" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(convexActionMock).toHaveBeenCalledWith("search:searchSkills", {
|
||||
query: "alpha",
|
||||
limit: 20,
|
||||
});
|
||||
expect(screen.getByText("Alpha Skill")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("searches skills inside the selected category before truncating results", async () => {
|
||||
convexActionMock.mockResolvedValue([
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:dev-alpha",
|
||||
slug: "dev-alpha",
|
||||
displayName: "Dev Alpha",
|
||||
summary: "Alpha",
|
||||
categories: ["development"],
|
||||
stats: { stars: 1, downloads: 1 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Category" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Development" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain(
|
||||
"Development",
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
const searchInput = await screen.findByRole("searchbox", { name: "Search skills" });
|
||||
fireEvent.change(searchInput, { target: { value: "alpha" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(convexActionMock).toHaveBeenCalledWith("search:searchSkills", {
|
||||
query: "alpha",
|
||||
limit: 20,
|
||||
categorySlug: "development",
|
||||
});
|
||||
expect(screen.getByText("Dev Alpha")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the canonical skill and plugin category definitions", async () => {
|
||||
render(<HomeListingSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Skill").textContent).toBe("Demo Skill");
|
||||
});
|
||||
|
||||
const categorySelect = screen.getByRole("combobox", { name: "Category" });
|
||||
expect(categorySelect.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(categorySelect.textContent).toContain("All categories");
|
||||
|
||||
fireEvent.click(categorySelect);
|
||||
expect(
|
||||
screen.getByRole("listbox", { name: "Category" }).getAttribute("aria-multiselectable"),
|
||||
).toBe("true");
|
||||
expect(screen.getByRole("option", { name: "All categories" }).textContent).toContain(
|
||||
"All categories",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Integrations" }).textContent).toContain(
|
||||
"Integrations",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Security" }).textContent).toContain("Security");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
expect(screen.getByRole("option", { name: "Channels" }).textContent).toContain("Channels");
|
||||
expect(screen.getByRole("option", { name: "Runtime" }).textContent).toContain("Runtime");
|
||||
});
|
||||
|
||||
it("expands the listing preview when see more is clicked", async () => {
|
||||
const rows = Array.from({ length: 35 }, (_, index) => ({
|
||||
skill: {
|
||||
_id: `skills:${index}`,
|
||||
slug: `skill-${index}`,
|
||||
displayName: `Skill ${index}`,
|
||||
summary: "Summary",
|
||||
stats: { stars: 1, downloads: 1 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
}));
|
||||
convexQueryMock.mockImplementation((_, args: { numItems: number }) =>
|
||||
Promise.resolve({
|
||||
page: rows.slice(0, args.numItems),
|
||||
hasMore: args.numItems < rows.length,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Skill 0")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("Skill 20")).toBeNull();
|
||||
expect(screen.getByText("Skill 19")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Skill 34")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
it("loads the existing trending skills leaderboard for the Trending tab", async () => {
|
||||
convexQueryMock.mockImplementation((name) => {
|
||||
if (name === "skills:listPublicTrendingPage") {
|
||||
return Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:trending",
|
||||
slug: "trending-skill",
|
||||
displayName: "Trending Skill",
|
||||
summary: "Hot this week.",
|
||||
stats: { installsAllTime: 999 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
page: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
summary: "A helpful skill.",
|
||||
stats: { stars: 12, downloads: 340 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
convexQueryMock.mockClear();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Trending" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(convexQueryMock).toHaveBeenCalledWith("skills:listPublicTrendingPage", { limit: 20 });
|
||||
expect(screen.getByText("Trending Skill")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("Popularity")).toBeNull();
|
||||
expect(screen.queryByText("999")).toBeNull();
|
||||
});
|
||||
|
||||
it("requests official plugins from the catalog API", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
name: "community-plugin",
|
||||
displayName: "Community Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
stats: { stars: 1, downloads: 2, installs: 0, versions: 1 },
|
||||
},
|
||||
{
|
||||
name: "official-plugin",
|
||||
displayName: "Official Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
stats: { stars: 4, downloads: 8, installs: 0, versions: 1 },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Official" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Official Plugin").textContent).toBe("Official Plugin");
|
||||
});
|
||||
expect(screen.queryByText("Community Plugin")).toBeNull();
|
||||
const latestRequest = fetchPluginCatalogMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(latestRequest).toEqual(expect.objectContaining({ isOfficial: true, limit: 20 }));
|
||||
});
|
||||
|
||||
it("uses the skills cursor when loading beyond the first page", async () => {
|
||||
const firstSkill = {
|
||||
skill: {
|
||||
_id: "skills:first",
|
||||
slug: "first-skill",
|
||||
displayName: "First Skill",
|
||||
summary: "First page.",
|
||||
stats: { installsAllTime: 100 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
};
|
||||
const secondSkill = {
|
||||
skill: {
|
||||
_id: "skills:second",
|
||||
slug: "second-skill",
|
||||
displayName: "Second Skill",
|
||||
summary: "Second page.",
|
||||
stats: { installsAllTime: 90 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
};
|
||||
convexQueryMock
|
||||
.mockResolvedValueOnce({
|
||||
page: [firstSkill],
|
||||
hasMore: true,
|
||||
nextCursor: "skills-cursor-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
page: [firstSkill],
|
||||
hasMore: true,
|
||||
nextCursor: "skills-cursor-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
page: [secondSkill],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Skill")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Second Skill")).toBeTruthy();
|
||||
});
|
||||
expect(convexQueryMock).toHaveBeenCalledWith(
|
||||
"skills:listPublicPageV4",
|
||||
expect.objectContaining({ cursor: "skills-cursor-2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps load more available when selected skill categories overflow after merging", async () => {
|
||||
const makeEntry = (index: number, category: string) => ({
|
||||
skill: {
|
||||
_id: `skills:${category}:${index}`,
|
||||
slug: `${category}-skill-${index}`,
|
||||
displayName: `${category} Skill ${index}`,
|
||||
summary: "Category skill.",
|
||||
categories: [category],
|
||||
stats: { installsAllTime: 100 - index },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
});
|
||||
const development = Array.from({ length: 12 }, (_, index) => makeEntry(index, "development"));
|
||||
const security = Array.from({ length: 12 }, (_, index) => makeEntry(index, "security"));
|
||||
|
||||
convexQueryMock.mockImplementation((name, args?: { categorySlug?: string }) => {
|
||||
if (name !== "skills:listPublicPageV4") {
|
||||
return Promise.resolve({ items: [], nextCursor: null });
|
||||
}
|
||||
if (args?.categorySlug === "development") {
|
||||
return Promise.resolve({ page: development, hasMore: false, nextCursor: null });
|
||||
}
|
||||
if (args?.categorySlug === "security") {
|
||||
return Promise.resolve({ page: security, hasMore: false, nextCursor: null });
|
||||
}
|
||||
return Promise.resolve({ page: development, hasMore: false, nextCursor: null });
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("development Skill 0")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Category" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Development" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain(
|
||||
"Development",
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "Security" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain(
|
||||
"2 categories",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Load more" })).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("security Skill 11")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("security Skill 11")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows selecting multiple skill categories and refetches each selected category", async () => {
|
||||
convexQueryMock.mockResolvedValue({
|
||||
page: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:inferred",
|
||||
slug: "inferred-skill",
|
||||
displayName: "Inferred Skill",
|
||||
summary: "Uses inferred category metadata.",
|
||||
inferredCategories: ["development"],
|
||||
latestVersionId: "versions:1",
|
||||
inferredFromVersionId: "versions:1",
|
||||
stats: { installsAllTime: 10 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Inferred Skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
convexQueryMock.mockClear();
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Category" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Development" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(convexQueryMock).toHaveBeenCalledWith(
|
||||
"skills:listPublicPageV4",
|
||||
expect.objectContaining({ categorySlug: "development" }),
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain("Development");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Inferred Skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
convexQueryMock.mockClear();
|
||||
fireEvent.click(screen.getByRole("option", { name: "Security" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(convexQueryMock).toHaveBeenCalledWith(
|
||||
"skills:listPublicPageV4",
|
||||
expect.objectContaining({ categorySlug: "development" }),
|
||||
);
|
||||
expect(convexQueryMock).toHaveBeenCalledWith(
|
||||
"skills:listPublicPageV4",
|
||||
expect.objectContaining({ categorySlug: "security" }),
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain(
|
||||
"2 categories",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Development" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Security" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "All categories" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "Category" }).textContent).toContain(
|
||||
"All categories",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,7 @@
|
||||
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const { convexQueryMock, fetchFeaturedPluginsMock } = vi.hoisted(() => ({
|
||||
convexQueryMock: vi.fn(),
|
||||
fetchFeaturedPluginsMock: vi.fn(),
|
||||
}));
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: unknown }) => ({
|
||||
@@ -19,40 +13,25 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useAction: () => vi.fn(),
|
||||
useQuery: () => undefined,
|
||||
vi.mock("../components/HomeListingSection", () => ({
|
||||
HomeListingSection: () => <section data-testid="home-listing-stub" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
skills: {
|
||||
listHighlightedPublic: "skills:listHighlightedPublic",
|
||||
listPublicPageV4: "skills:listPublicPageV4",
|
||||
},
|
||||
},
|
||||
vi.mock("../components/HomePopularPublishersSection", () => ({
|
||||
HomePopularPublishersSection: () => <section data-testid="home-publishers-stub" />,
|
||||
}));
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: {
|
||||
query: convexQueryMock,
|
||||
},
|
||||
vi.mock("../components/HomeAppsSection", () => ({
|
||||
HomeAppsSection: () => <section data-testid="home-apps-stub" />,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/featuredCatalog", () => ({
|
||||
fetchFeaturedPlugins: fetchFeaturedPluginsMock,
|
||||
vi.mock("../components/HomeBringSkillsSection", () => ({
|
||||
HomeBringSkillsSection: () => <section data-testid="home-bring-skills-stub" />,
|
||||
}));
|
||||
|
||||
describe("home route", () => {
|
||||
beforeEach(() => {
|
||||
convexQueryMock.mockResolvedValue([]);
|
||||
fetchFeaturedPluginsMock.mockResolvedValue([]);
|
||||
navigateMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -68,7 +47,7 @@ describe("home route", () => {
|
||||
}
|
||||
|
||||
function clickHeroLabelTriple() {
|
||||
const label = screen.getByText("BUILT BY THE COMMUNITY.");
|
||||
const label = screen.getByText("BUILT BY THE COMMUNITY");
|
||||
act(() => {
|
||||
fireEvent.click(label);
|
||||
fireEvent.click(label);
|
||||
@@ -80,16 +59,24 @@ describe("home route", () => {
|
||||
it("renders the restored community hero copy", async () => {
|
||||
await renderHome();
|
||||
|
||||
expect(screen.getByText("BUILT BY THE COMMUNITY.")).toBeTruthy();
|
||||
expect(screen.getByText("Tools built by thousands, ready in one search.")).toBeTruthy();
|
||||
expect(screen.getByText("BUILT BY THE COMMUNITY").textContent).toBe("BUILT BY THE COMMUNITY");
|
||||
expect(screen.getByText("Discover skills and plugins from top creators").textContent).toBe(
|
||||
"Discover skills and plugins from top creators",
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "200k+ publishers" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the three home category options", async () => {
|
||||
it("renders the catalog and new homepage sections without the old hero search", async () => {
|
||||
await renderHome();
|
||||
|
||||
expect(screen.getByText("Skills")).toBeTruthy();
|
||||
expect(screen.getByText("Plugins")).toBeTruthy();
|
||||
expect(screen.getByText("Publishers")).toBeTruthy();
|
||||
expect(screen.getByTestId("home-listing-stub").tagName).toBe("SECTION");
|
||||
expect(screen.getByTestId("home-publishers-stub").tagName).toBe("SECTION");
|
||||
expect(screen.getByTestId("home-apps-stub").tagName).toBe("SECTION");
|
||||
expect(screen.getByTestId("home-bring-skills-stub").tagName).toBe("SECTION");
|
||||
expect(screen.queryByPlaceholderText("What are you looking for?")).toBeNull();
|
||||
expect(screen.queryByText("Featured skills")).toBeNull();
|
||||
expect(screen.queryByText("Trending Now")).toBeNull();
|
||||
expect(screen.queryByText(/claw for your claw/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the homepage social proof stats strip", async () => {
|
||||
@@ -102,163 +89,6 @@ describe("home route", () => {
|
||||
expect(screen.queryByText("avg rating")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the featured skill carousel as a duplicated scrolling track", async () => {
|
||||
convexQueryMock.mockResolvedValue([
|
||||
{
|
||||
skill: {
|
||||
_id: "skill-one",
|
||||
ownerUserId: "user-one",
|
||||
slug: "one",
|
||||
displayName: "One",
|
||||
summary: "First highlighted skill.",
|
||||
stats: { stars: 3, downloads: 12 },
|
||||
},
|
||||
ownerHandle: "openclaw",
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
_id: "skill-two",
|
||||
ownerUserId: "user-two",
|
||||
slug: "two",
|
||||
displayName: "Two",
|
||||
summary: "Second highlighted skill.",
|
||||
stats: { stars: 5, downloads: 24 },
|
||||
},
|
||||
ownerHandle: "ritual",
|
||||
},
|
||||
]);
|
||||
|
||||
await renderHome();
|
||||
|
||||
expect(await screen.findByText("Featured skills")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-carousel-track")).toBeTruthy();
|
||||
expect(document.querySelectorAll(".home-v2-carousel-track .home-v2-c-card")).toHaveLength(4);
|
||||
expect(
|
||||
document.querySelector(".home-v2-carousel-track .home-v2-c-card")?.getAttribute("href"),
|
||||
).toBe("/openclaw/one");
|
||||
});
|
||||
|
||||
it("wires carousel previous and next controls to scroll the featured track", async () => {
|
||||
const scrollByMock = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollBy", {
|
||||
configurable: true,
|
||||
value: scrollByMock,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get: () => 320,
|
||||
});
|
||||
convexQueryMock.mockResolvedValue([
|
||||
{
|
||||
skill: {
|
||||
_id: "skill-one",
|
||||
ownerUserId: "user-one",
|
||||
slug: "one",
|
||||
displayName: "One",
|
||||
summary: "First highlighted skill.",
|
||||
stats: { stars: 3, downloads: 12 },
|
||||
},
|
||||
ownerHandle: "openclaw",
|
||||
},
|
||||
]);
|
||||
|
||||
await renderHome();
|
||||
fireEvent.click(await screen.findByLabelText("Next"));
|
||||
fireEvent.click(screen.getByLabelText("Previous"));
|
||||
|
||||
expect(scrollByMock).toHaveBeenNthCalledWith(1, { left: 336, behavior: "smooth" });
|
||||
expect(scrollByMock).toHaveBeenNthCalledWith(2, { left: -336, behavior: "smooth" });
|
||||
});
|
||||
|
||||
it("falls back to recommended public skill cards when no highlighted carousel cards exist", async () => {
|
||||
convexQueryMock.mockImplementation((queryName: string) => {
|
||||
if (queryName === "skills:listHighlightedPublic") return Promise.resolve([]);
|
||||
if (queryName === "skills:listPublicPageV4") {
|
||||
return Promise.resolve({
|
||||
page: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skill-popular",
|
||||
ownerUserId: "user-popular",
|
||||
slug: "popular",
|
||||
displayName: "Popular",
|
||||
summary: "Popular fallback skill.",
|
||||
stats: { stars: 8, downloads: 48 },
|
||||
},
|
||||
ownerHandle: "clawhub",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await renderHome();
|
||||
|
||||
expect(await screen.findByText("Featured skills")).toBeTruthy();
|
||||
expect(
|
||||
Array.from(document.querySelectorAll(".home-v2-carousel-track .home-v2-c-name")).map(
|
||||
(node) => node.textContent,
|
||||
),
|
||||
).toEqual(["Popular", "Popular"]);
|
||||
expect(document.querySelector(".home-v2-carousel-section")?.getAttribute("data-source")).toBe(
|
||||
"popular",
|
||||
);
|
||||
expect(document.querySelectorAll(".home-v2-carousel-track .home-v2-c-card")).toHaveLength(2);
|
||||
const listArgs = getListPublicPageArgs();
|
||||
expect(listArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
numItems: 6,
|
||||
dir: "desc",
|
||||
}),
|
||||
);
|
||||
expect(listArgs).not.toHaveProperty("sort");
|
||||
});
|
||||
|
||||
it("restores the Trending Now skill grid from the recommended public feed", async () => {
|
||||
const trendingEntries = Array.from({ length: 6 }, (_, index) => ({
|
||||
skill: {
|
||||
_id: `skill-trending-${index}`,
|
||||
ownerUserId: `user-trending-${index}`,
|
||||
slug: `trending-${index}`,
|
||||
displayName: `Trending Skill ${index + 1}`,
|
||||
summary: `Trending skill ${index + 1} summary.`,
|
||||
stats: { stars: 100 + index, downloads: 12_000 + index * 1000 },
|
||||
},
|
||||
ownerHandle: `creator${index + 1}`,
|
||||
}));
|
||||
|
||||
convexQueryMock.mockImplementation((queryName: string) => {
|
||||
if (queryName === "skills:listHighlightedPublic") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
skill: {
|
||||
_id: "skill-highlighted",
|
||||
ownerUserId: "user-highlighted",
|
||||
slug: "highlighted",
|
||||
displayName: "Highlighted",
|
||||
summary: "Highlighted skill.",
|
||||
stats: { stars: 1, downloads: 2 },
|
||||
},
|
||||
ownerHandle: "featured",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (queryName === "skills:listPublicPageV4") {
|
||||
return Promise.resolve({ page: trendingEntries });
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await renderHome();
|
||||
|
||||
expect(await screen.findByText("Trending Now")).toBeTruthy();
|
||||
expect(document.querySelectorAll(".home-v2-trending-grid .home-v2-trend-card")).toHaveLength(6);
|
||||
expect(document.querySelector(".home-v2-trend-title")?.textContent).toBe("Trending Skill 1");
|
||||
expect(document.querySelector(".home-v2-trend-creator")?.textContent).toBe("by creator1");
|
||||
expect(document.querySelector(".home-v2-trend-install")?.textContent).toContain("Install");
|
||||
});
|
||||
|
||||
it("starts the slot machine when the community label is triple-clicked", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-29T00:00:00Z"));
|
||||
@@ -320,18 +150,3 @@ describe("home route", () => {
|
||||
expect(document.querySelector(".home-v2-hack-lobster")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function getListPublicPageArgs(): Record<string, unknown> {
|
||||
const call = convexQueryMock.mock.calls.find((candidate: unknown[]) => {
|
||||
return candidate[0] === "skills:listPublicPageV4";
|
||||
});
|
||||
const args = call?.[1];
|
||||
if (!isRecord(args)) {
|
||||
throw new Error("Expected listPublicPageV4 args to be an object");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "./helpers/convexReactMocks";
|
||||
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
const fetchFeaturedPluginsMock = vi.fn();
|
||||
const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
|
||||
@@ -64,10 +63,6 @@ vi.mock("../lib/packageApi", () => ({
|
||||
isRateLimitedPackageApiError: (error: unknown) => isRateLimitedPackageApiErrorMock(error),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/featuredCatalog", () => ({
|
||||
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
|
||||
}));
|
||||
@@ -98,7 +93,6 @@ describe("plugins route", () => {
|
||||
beforeEach(() => {
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
|
||||
fetchFeaturedPluginsMock.mockReset();
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
resetConvexReactMocks();
|
||||
setupDefaultConvexReactMocks();
|
||||
|
||||
@@ -173,42 +173,25 @@ describe("restored UI design contract", () => {
|
||||
expect(compact).not.toContain(".navbar-search {\n display: none;");
|
||||
});
|
||||
|
||||
it("requires the restored home hero, carousel, category grid, and Trending Now sections", () => {
|
||||
it("requires the experiment hero and canonical home catalog without later sections", () => {
|
||||
const homeSource = home();
|
||||
const listingSource = read("src/components/HomeListingSection.tsx");
|
||||
const css = styles();
|
||||
|
||||
expect(homeSource).toContain("BUILT BY THE COMMUNITY.");
|
||||
expect(homeSource).toContain("Tools built by thousands, ready in one search.");
|
||||
expect(homeSource).toContain("api.skills.listHighlightedPublic");
|
||||
expect(homeSource).toContain("api.skills.listPublicPageV4");
|
||||
expect(homeSource).toContain("const [popular, setPopular]");
|
||||
expect(homeSource).toContain('className="home-v2-carousel-section"');
|
||||
expect(homeSource).toContain(
|
||||
'data-source={carouselUsesHighlighted ? "highlighted" : "popular"}',
|
||||
expect(homeSource).toContain("BUILT BY THE COMMUNITY");
|
||||
expect(homeSource).toContain("Discover skills and plugins from top creators");
|
||||
expect(homeSource).not.toContain("home-v2-sub-stat");
|
||||
expect(homeSource).toContain("HomeListingSection");
|
||||
expect(homeSource).not.toContain("What are you looking for?");
|
||||
expect(homeSource).not.toContain("Featured skills");
|
||||
expect(homeSource).not.toContain("Trending Now");
|
||||
expect(listingSource).toContain("SKILL_CATEGORIES");
|
||||
expect(listingSource).toContain("PLUGIN_CATEGORIES");
|
||||
expect(listingSource).toContain("HomeListingCategorySelect");
|
||||
expect(cssRule(css, ".home-v2-listing-toolbar")).toContain("display: flex");
|
||||
expect(cssRule(css, ".home-v2-listing-grid")).toContain(
|
||||
"grid-template-columns: repeat(3, minmax(0, 1fr))",
|
||||
);
|
||||
expect(homeSource).toContain("Featured skills");
|
||||
expect(homeSource).toContain("Trending Now");
|
||||
expect(homeSource).toContain('className="home-v2-trending-grid"');
|
||||
|
||||
const searchShell = cssRule(css, ".home-v2-search-bar");
|
||||
expect(searchShell).toContain("border: 1px solid var(--hv2-border-strong)");
|
||||
expect(searchShell).not.toContain("border-color: var(--hv2-accent-border)");
|
||||
|
||||
const searchFocus = cssRule(css, ".home-v2-search-bar:focus-within");
|
||||
expect(searchFocus).toContain("border-color: var(--hv2-accent-border)");
|
||||
|
||||
const categories = cssRule(css, ".home-v2-categories-grid");
|
||||
expect(categories).toContain("--home-v2-category-columns: 3");
|
||||
expect(categories).toContain("grid-template-columns: repeat(var(--home-v2-category-columns)");
|
||||
|
||||
const trending = cssRule(css, ".home-v2-trending-grid");
|
||||
expect(trending).toContain("grid-template-columns: repeat(3, 1fr)");
|
||||
cssMediaContaining(css, "(max-width: 1024px)", [
|
||||
".home-v2-trending-grid {\n grid-template-columns: repeat(2, 1fr);",
|
||||
]);
|
||||
cssMediaContaining(css, "(max-width: 768px)", [
|
||||
".home-v2-trending-grid {\n grid-template-columns: 1fr;",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires the restored footer columns and mobile section toggles", () => {
|
||||
@@ -218,8 +201,8 @@ describe("restored UI design contract", () => {
|
||||
|
||||
expect(navSource).toContain('title: "Browse"');
|
||||
expect(navSource).toContain('title: "Publish"');
|
||||
expect(navSource).toContain('title: "Ecosystem"');
|
||||
expect(navSource).toContain('title: "Community"');
|
||||
expect(navSource).toContain('title: "Platform"');
|
||||
expect(navSource).toContain('label: "Publish Skill"');
|
||||
expect(navSource).toContain('label: "Publish Plugin"');
|
||||
expect(navSource).toContain('label: "GitHub"');
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("Footer", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the restored four-column public footer", () => {
|
||||
it("renders the public footer columns and bottom platform links", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const columns = container.querySelectorAll(".footer-col");
|
||||
@@ -36,13 +36,14 @@ describe("Footer", () => {
|
||||
|
||||
const browse = screen.getByRole("heading", { name: "Browse" }).closest(".footer-col");
|
||||
const publish = screen.getByRole("heading", { name: "Publish" }).closest(".footer-col");
|
||||
const ecosystem = screen.getByRole("heading", { name: "Ecosystem" }).closest(".footer-col");
|
||||
const community = screen.getByRole("heading", { name: "Community" }).closest(".footer-col");
|
||||
const platform = screen.getByRole("heading", { name: "Platform" }).closest(".footer-col");
|
||||
|
||||
expect(browse).not.toBeNull();
|
||||
expect(publish).not.toBeNull();
|
||||
expect(ecosystem).not.toBeNull();
|
||||
expect(community).not.toBeNull();
|
||||
expect(platform).not.toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Platform" })).toBeNull();
|
||||
|
||||
expect(
|
||||
within(browse as HTMLElement)
|
||||
@@ -70,21 +71,17 @@ describe("Footer", () => {
|
||||
.getAttribute("href"),
|
||||
).toBe("https://github.com/openclaw/clawhub");
|
||||
expect(
|
||||
within(community as HTMLElement)
|
||||
within(ecosystem as HTMLElement)
|
||||
.getByRole("link", { name: "OpenClaw" })
|
||||
.getAttribute("href"),
|
||||
).toBe("https://openclaw.ai");
|
||||
expect(within(community as HTMLElement).queryByRole("link", { name: "About" })).toBeNull();
|
||||
expect(
|
||||
within(platform as HTMLElement)
|
||||
.getByRole("link", { name: "Deployed on Vercel" })
|
||||
.getAttribute("href"),
|
||||
).toBe("https://vercel.com");
|
||||
expect(
|
||||
within(platform as HTMLElement)
|
||||
.getByRole("link", { name: "Powered by Convex" })
|
||||
.getAttribute("href"),
|
||||
).toBe("https://www.convex.dev");
|
||||
expect(screen.getByRole("link", { name: "Deployed on Vercel" }).getAttribute("href")).toBe(
|
||||
"https://vercel.com",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Powered by Convex" }).getAttribute("href")).toBe(
|
||||
"https://www.convex.dev",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses footer sections by heading until toggled open", async () => {
|
||||
@@ -93,15 +90,11 @@ describe("Footer", () => {
|
||||
|
||||
const browseToggle = screen.getByRole("button", { name: "Browse" });
|
||||
const browseLinks = document.getElementById("footer-section-browse-links");
|
||||
const platformToggle = screen.getByRole("button", { name: "Platform" });
|
||||
const platformLinks = document.getElementById("footer-section-platform-links");
|
||||
|
||||
expect(browseLinks).not.toBeNull();
|
||||
expect(platformLinks).not.toBeNull();
|
||||
await waitFor(() => expect(browseToggle.getAttribute("aria-expanded")).toBe("false"));
|
||||
expect(browseLinks?.getAttribute("data-open")).toBe("false");
|
||||
expect(platformToggle.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(platformLinks?.getAttribute("data-open")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Platform" })).toBeNull();
|
||||
|
||||
fireEvent.click(browseToggle);
|
||||
|
||||
@@ -112,7 +105,6 @@ describe("Footer", () => {
|
||||
.getByRole("link", { name: "Skills" })
|
||||
.getAttribute("href"),
|
||||
).toBe("/skills");
|
||||
expect(platformLinks?.getAttribute("data-open")).toBe("false");
|
||||
|
||||
fireEvent.click(browseToggle);
|
||||
|
||||
|
||||
+350
-50
@@ -1,22 +1,177 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
|
||||
import { ArrowUpRight, ChevronDown } from "lucide-react";
|
||||
import type { PointerEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
FOOTER_ECOSYSTEM_PROJECTS,
|
||||
FOOTER_NAV_SECTIONS,
|
||||
FOOTER_PLATFORM_LINKS,
|
||||
type FooterEcosystemProject,
|
||||
OPENCLAW_CLAWHUB_DOCS_URL,
|
||||
OPENCLAW_ECOSYSTEM_URL,
|
||||
OPENCLAW_LOGO_URL,
|
||||
OPENCLAW_SITE_URL,
|
||||
} from "../lib/nav-items";
|
||||
|
||||
const FOOTER_BRAND_MARK_SRC = "/og-clawhub-watermark.png";
|
||||
const FOOTER_EASTER_ASCII = [
|
||||
"....:: clawhub/openclaw ::.... skills plugins publishers trust signals",
|
||||
">>> install scan publish verify @@ gateway @@ registry @@ agents @@",
|
||||
" 30 skills 12 plugins /api/v1/skills /owners /audit /ship",
|
||||
":::: signed manifests ::::: moderated releases ::::: version history ::::",
|
||||
" hooks runners slash-commands skill.md templates scanners review-bots",
|
||||
"openclaw ecosystem crabbox clickclack crawler packs gateway plugins",
|
||||
"---- downloads installs stars lineage ownership docs package integrity",
|
||||
" safe browse paths official gateways publisher handles org trust",
|
||||
];
|
||||
const FOOTER_EASTER_ASCII_FIELD = Array.from({ length: 44 }, (_, row) => {
|
||||
const a = FOOTER_EASTER_ASCII[row % FOOTER_EASTER_ASCII.length];
|
||||
const b = FOOTER_EASTER_ASCII[(row + 3) % FOOTER_EASTER_ASCII.length];
|
||||
const c = FOOTER_EASTER_ASCII[(row + 5) % FOOTER_EASTER_ASCII.length];
|
||||
return `${a} ${b} ${c}`;
|
||||
}).join("\n");
|
||||
|
||||
function sectionId(title: string) {
|
||||
return `footer-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
}
|
||||
|
||||
// Must match the `@media (max-width: 760px)` breakpoint in styles.css where
|
||||
// `.footer-col-links` is hidden by default and shown only when [data-open="true"].
|
||||
const MOBILE_BREAKPOINT = 760;
|
||||
|
||||
function FooterSocialIcon({ icon }: { icon: "github" | "discord" }) {
|
||||
if (icon === "github") {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="footer-col-link-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .97-.31 3.16 1.18.92-.26 1.9-.38 2.88-.39.98 0 1.96.13 2.88.39 2.19-1.49 3.15-1.18 3.15-1.18.63 1.58.24 2.75.12 3.04.74.8 1.18 1.83 1.18 3.08 0 4.42-2.69 5.39-5.25 5.67.42.36.78 1.07.78 2.15 0 1.55-.01 2.8-.01 3.18 0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="footer-col-link-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterEcoMark({
|
||||
project,
|
||||
decorative = false,
|
||||
}: {
|
||||
project: FooterEcosystemProject;
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
const className = "footer-v2-eco-mark";
|
||||
const content = (
|
||||
<>
|
||||
<span className="footer-v2-eco-mark-logo" aria-hidden="true">
|
||||
<img src={project.logoUrl} alt="" width={28} height={28} decoding="async" />
|
||||
</span>
|
||||
<span className="footer-v2-eco-mark-label">{project.label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (decorative) {
|
||||
return <span className={className}>{content}</span>;
|
||||
}
|
||||
|
||||
if (project.internal) {
|
||||
return (
|
||||
<Link to={project.href} className={className} title={project.blurb}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
href={project.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={project.blurb}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterEasterBackdrop() {
|
||||
const easterRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll-driven parallax: as the reveal area below the footer scrolls into
|
||||
// view, drift the composition into place for a depth effect. 0 = hidden
|
||||
// below the fold, 1 = fully revealed.
|
||||
useEffect(() => {
|
||||
const el = easterRef.current;
|
||||
if (!el) return undefined;
|
||||
if (
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
el.style.setProperty("--footer-easter-reveal", "1");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportH = window.innerHeight || 1;
|
||||
const progress = (viewportH - rect.top) / (rect.height || viewportH);
|
||||
el.style.setProperty("--footer-easter-reveal", String(Math.max(0, Math.min(1, progress))));
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = window.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
event.currentTarget.style.setProperty("--footer-easter-x", `${event.clientX - rect.left}px`);
|
||||
event.currentTarget.style.setProperty("--footer-easter-y", `${event.clientY - rect.top}px`);
|
||||
event.currentTarget.style.setProperty("--footer-easter-intensity", "1");
|
||||
};
|
||||
|
||||
const handlePointerLeave = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.style.setProperty("--footer-easter-intensity", "0");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={easterRef}
|
||||
className="footer-v2-easter"
|
||||
aria-hidden="true"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<div className="footer-v2-easter-image footer-v2-easter-image--base" />
|
||||
<pre className="footer-v2-easter-ascii">{FOOTER_EASTER_ASCII_FIELD}</pre>
|
||||
<div className="footer-v2-easter-image footer-v2-easter-image--top" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
const [openSections, setOpenSections] = useState<ReadonlySet<string>>(() => new Set());
|
||||
// Track whether the mobile disclosure behavior is active so aria-expanded matches
|
||||
// actual link visibility. Initialized to false (= desktop assumption) so that
|
||||
// SSR and the first client render agree: on desktop links are always visible and
|
||||
// aria-expanded=true is correct. On mobile, useEffect corrects this after hydration.
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,51 +199,196 @@ export function Footer() {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="site-footer" role="contentinfo">
|
||||
<div className="site-footer-inner">
|
||||
<div className="footer-grid">
|
||||
{FOOTER_NAV_SECTIONS.map((section) => {
|
||||
const isOpen = openSections.has(section.title);
|
||||
const id = sectionId(section.title);
|
||||
// On desktop the links are always visible; aria-expanded must be true.
|
||||
// On mobile the links are hidden/shown via the disclosure button.
|
||||
const ariaExpanded = isMobile ? isOpen : true;
|
||||
const year = new Date().getFullYear();
|
||||
const ecosystemProjects = FOOTER_ECOSYSTEM_PROJECTS.filter(
|
||||
(project) => project.label !== "ClawHub",
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={section.title} className="footer-col">
|
||||
<h4 className="footer-col-title">
|
||||
<button
|
||||
type="button"
|
||||
className="footer-col-toggle"
|
||||
aria-controls={`${id}-links`}
|
||||
aria-expanded={ariaExpanded}
|
||||
onClick={() => {
|
||||
if (isMobile) toggleSection(section.title);
|
||||
}}
|
||||
>
|
||||
<span>{section.title}</span>
|
||||
<ChevronDown className="footer-col-toggle-icon" size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
<div className="footer-col-links" id={`${id}-links`} data-open={isOpen}>
|
||||
{section.items.map((item) =>
|
||||
item.kind === "link" ? (
|
||||
<Link key={item.label} to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
|
||||
{item.label}
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
return (
|
||||
<footer className="site-footer site-footer-v2" role="contentinfo">
|
||||
<div className="site-footer-inner">
|
||||
<div className="footer-v2-main">
|
||||
<div className="footer-v2-brand">
|
||||
<Link to="/" className="footer-v2-brand-lockup">
|
||||
<img
|
||||
className="footer-v2-brand-mark"
|
||||
src={FOOTER_BRAND_MARK_SRC}
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
decoding="async"
|
||||
/>
|
||||
<span className="footer-v2-brand-name">ClawHub</span>
|
||||
</Link>
|
||||
<p className="footer-v2-brand-tagline">
|
||||
Skills and plugins for OpenClaw agents. Part of the wider OpenClaw ecosystem.
|
||||
</p>
|
||||
<a
|
||||
className="footer-v2-eco-link"
|
||||
href={OPENCLAW_CLAWHUB_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Explore docs
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="footer-grid">
|
||||
{FOOTER_NAV_SECTIONS.map((section) => {
|
||||
const isOpen = openSections.has(section.title);
|
||||
const id = sectionId(section.title);
|
||||
const ariaExpanded = isMobile ? isOpen : true;
|
||||
|
||||
return (
|
||||
<div key={section.title} className="footer-col">
|
||||
<h4 className="footer-col-title">
|
||||
<button
|
||||
type="button"
|
||||
className="footer-col-toggle"
|
||||
aria-controls={`${id}-links`}
|
||||
aria-expanded={ariaExpanded}
|
||||
onClick={() => {
|
||||
if (isMobile) toggleSection(section.title);
|
||||
}}
|
||||
>
|
||||
<span>{section.title}</span>
|
||||
<ChevronDown
|
||||
className="footer-col-toggle-icon"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</h4>
|
||||
<div className="footer-col-links" id={`${id}-links`} data-open={isOpen}>
|
||||
{section.items
|
||||
.filter((item) => item.featureFlag !== false)
|
||||
.map((item) => {
|
||||
if (item.kind === "link") {
|
||||
return (
|
||||
<Link key={item.label} to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (item.kind === "external") {
|
||||
return (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`footer-col-link-external${item.icon ? " footer-col-link-with-icon" : ""}`}
|
||||
>
|
||||
{item.icon ? <FooterSocialIcon icon={item.icon} /> : null}
|
||||
{item.label}
|
||||
<ArrowUpRight
|
||||
className="footer-col-link-external-icon"
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={item.label}>{item.label}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-v2-eco" aria-label="OpenClaw ecosystem">
|
||||
<p className="footer-v2-eco-label">
|
||||
Built alongside{" "}
|
||||
<span className="footer-v2-eco-label-accent">
|
||||
<img src={OPENCLAW_LOGO_URL} alt="" width={14} height={14} decoding="async" />
|
||||
the OpenClaw ecosystem
|
||||
</span>
|
||||
</p>
|
||||
<div className="footer-v2-eco-marquee">
|
||||
<div className="footer-v2-eco-marks">
|
||||
<span className="footer-v2-eco-sequence">
|
||||
{ecosystemProjects.map((project) => (
|
||||
<FooterEcoMark key={project.label} project={project} />
|
||||
))}
|
||||
<span className="footer-v2-eco-all">
|
||||
<a
|
||||
className="footer-v2-eco-mark footer-v2-eco-mark-all"
|
||||
href={OPENCLAW_ECOSYSTEM_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="footer-v2-eco-mark-label">All projects</span>
|
||||
<ArrowUpRight size={13} aria-hidden="true" />
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="footer-v2-eco-sequence footer-v2-eco-sequence-clone"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ecosystemProjects.map((project) => (
|
||||
<FooterEcoMark key={project.label} project={project} decorative />
|
||||
))}
|
||||
<span className="footer-v2-eco-mark footer-v2-eco-mark-all">
|
||||
<span className="footer-v2-eco-mark-label">All projects</span>
|
||||
<ArrowUpRight size={13} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-v2-bottom">
|
||||
<p className="footer-v2-copy">
|
||||
© {year}{" "}
|
||||
<Link to="/" className="footer-v2-copy-link">
|
||||
ClawHub
|
||||
</Link>
|
||||
{" / "}
|
||||
<a
|
||||
className="footer-v2-copy-link"
|
||||
href={OPENCLAW_SITE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
an OpenClaw project
|
||||
<ArrowUpRight
|
||||
className="footer-col-link-external-icon footer-v2-copy-link-icon"
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p className="footer-v2-meta">
|
||||
{FOOTER_PLATFORM_LINKS.map((link, index) => (
|
||||
<span key={link.label}>
|
||||
{index > 0 ? (
|
||||
<span className="footer-v2-meta-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="footer-col-link-external"
|
||||
>
|
||||
{link.label}
|
||||
<ArrowUpRight
|
||||
className="footer-col-link-external-icon"
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FooterEasterBackdrop />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
FileText,
|
||||
Globe,
|
||||
MessagesSquare,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
HOME_PLUGIN_SHORTCUTS,
|
||||
HOME_SKILL_APPS,
|
||||
homeAppIconUrl,
|
||||
homePluginShortcutIconUrl,
|
||||
SKILLS_BROWSE_SEARCH,
|
||||
type HomePluginShortcut,
|
||||
type HomeSkillApp,
|
||||
} from "../lib/homeApps";
|
||||
import { OPENCLAW_LOGO_URL } from "../lib/nav-items";
|
||||
|
||||
function HomeAppsCompactSkill({ app }: { app: HomeSkillApp }) {
|
||||
return (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{ ...SKILLS_BROWSE_SEARCH, q: app.browseQuery }}
|
||||
className="home-v2-apps-tile"
|
||||
title={app.description}
|
||||
>
|
||||
<span className="home-v2-apps-tile-icon" aria-hidden="true">
|
||||
<img
|
||||
src={homeAppIconUrl(app.iconDomain)}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</span>
|
||||
<span className="home-v2-apps-tile-copy">
|
||||
<span className="home-v2-apps-tile-name">{app.name}</span>
|
||||
<span className="home-v2-apps-tile-meta">{app.description}</span>
|
||||
</span>
|
||||
<ArrowRight className="home-v2-apps-tile-arrow" size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeAppsCompactPlugin({ plugin: shortcut }: { plugin: HomePluginShortcut }) {
|
||||
return (
|
||||
<Link
|
||||
to="/plugins/$name"
|
||||
params={{ name: shortcut.packageName }}
|
||||
className="home-v2-apps-tile"
|
||||
title={shortcut.description}
|
||||
>
|
||||
<span className="home-v2-apps-tile-icon" aria-hidden="true">
|
||||
<img
|
||||
src={homePluginShortcutIconUrl(shortcut)}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</span>
|
||||
<span className="home-v2-apps-tile-copy">
|
||||
<span className="home-v2-apps-tile-name">{shortcut.name}</span>
|
||||
<span className="home-v2-apps-tile-meta">{shortcut.description}</span>
|
||||
</span>
|
||||
<ArrowRight className="home-v2-apps-tile-arrow" size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type HomeAppsItemRef = {
|
||||
kind: "skill" | "plugin";
|
||||
id: string;
|
||||
};
|
||||
|
||||
function skill(id: string): HomeAppsItemRef {
|
||||
return { kind: "skill", id };
|
||||
}
|
||||
|
||||
function plugin(id: string): HomeAppsItemRef {
|
||||
return { kind: "plugin", id };
|
||||
}
|
||||
|
||||
const appCategories = [
|
||||
{
|
||||
id: "popular",
|
||||
label: "Popular",
|
||||
icon: Sparkles as LucideIcon,
|
||||
items: [
|
||||
skill("github"),
|
||||
skill("vscode"),
|
||||
skill("notion"),
|
||||
skill("slack"),
|
||||
skill("gmail"),
|
||||
skill("google-drive"),
|
||||
skill("google-sheets"),
|
||||
skill("google-calendar"),
|
||||
skill("linear"),
|
||||
skill("figma"),
|
||||
skill("trello"),
|
||||
plugin("whatsapp"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chat",
|
||||
label: "Chat",
|
||||
icon: MessagesSquare as LucideIcon,
|
||||
items: [
|
||||
plugin("whatsapp"),
|
||||
skill("slack"),
|
||||
skill("discord"),
|
||||
plugin("msteams"),
|
||||
plugin("googlechat"),
|
||||
plugin("feishu"),
|
||||
plugin("matrix"),
|
||||
plugin("nextcloud-talk"),
|
||||
plugin("voice-call"),
|
||||
plugin("line"),
|
||||
plugin("twitch"),
|
||||
plugin("qqbot"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: "Docs & specs",
|
||||
icon: FileText as LucideIcon,
|
||||
items: [
|
||||
skill("notion"),
|
||||
skill("obsidian"),
|
||||
skill("google-drive"),
|
||||
skill("google-sheets"),
|
||||
skill("airtable"),
|
||||
skill("dropbox"),
|
||||
skill("linear"),
|
||||
skill("jira"),
|
||||
skill("github"),
|
||||
skill("figma"),
|
||||
skill("trello"),
|
||||
plugin("apple-pim"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "web",
|
||||
label: "Web",
|
||||
icon: Globe as LucideIcon,
|
||||
items: [
|
||||
skill("chrome"),
|
||||
plugin("brave"),
|
||||
plugin("parallel"),
|
||||
plugin("perplexity"),
|
||||
plugin("exa"),
|
||||
plugin("firecrawl"),
|
||||
plugin("scraperapi"),
|
||||
plugin("google-meet"),
|
||||
skill("github"),
|
||||
skill("figma"),
|
||||
skill("google-drive"),
|
||||
skill("google-sheets"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cloud",
|
||||
label: "Cloud",
|
||||
icon: Cloud as LucideIcon,
|
||||
items: [
|
||||
skill("aws"),
|
||||
skill("docker"),
|
||||
skill("kubernetes"),
|
||||
skill("gitlab"),
|
||||
plugin("diagnostics-prometheus"),
|
||||
plugin("amazon-bedrock"),
|
||||
plugin("cloudflare-gateway"),
|
||||
plugin("groq"),
|
||||
plugin("deepinfra"),
|
||||
plugin("cerebras"),
|
||||
plugin("qwen"),
|
||||
plugin("llama-cpp"),
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const slackWorkflowPlugin =
|
||||
HOME_PLUGIN_SHORTCUTS.find((shortcut) => shortcut.id === "slack") ?? HOME_PLUGIN_SHORTCUTS[0];
|
||||
|
||||
const workflowHeaderTiles: ReadonlyArray<{
|
||||
label: string;
|
||||
src: string;
|
||||
className: string;
|
||||
badge?: string;
|
||||
}> = [
|
||||
{
|
||||
label: "OpenAI",
|
||||
src: homeAppIconUrl("openai.com"),
|
||||
className: "is-openai",
|
||||
},
|
||||
{
|
||||
label: "Slack",
|
||||
src: homePluginShortcutIconUrl(slackWorkflowPlugin),
|
||||
className: "is-slack",
|
||||
},
|
||||
{
|
||||
label: "OpenClaw",
|
||||
src: OPENCLAW_LOGO_URL,
|
||||
className: "is-openclaw",
|
||||
badge: "Exfoliate!",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeAppsSection() {
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<(typeof appCategories)[number]["id"]>(
|
||||
appCategories[0].id,
|
||||
);
|
||||
const compactItems = useMemo(() => {
|
||||
const activeCategory =
|
||||
appCategories.find((category) => category.id === activeCategoryId) ?? appCategories[0];
|
||||
return activeCategory.items
|
||||
.map((item) => {
|
||||
if (item.kind === "skill") {
|
||||
const app = HOME_SKILL_APPS.find((candidate) => candidate.id === item.id);
|
||||
return app ? { kind: "skill" as const, app } : null;
|
||||
}
|
||||
const matchedPlugin = HOME_PLUGIN_SHORTCUTS.find((candidate) => candidate.id === item.id);
|
||||
return matchedPlugin ? { kind: "plugin" as const, plugin: matchedPlugin } : null;
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is
|
||||
| { kind: "skill"; app: HomeSkillApp }
|
||||
| { kind: "plugin"; plugin: HomePluginShortcut } => Boolean(item),
|
||||
);
|
||||
}, [activeCategoryId]);
|
||||
|
||||
return (
|
||||
<section className="home-v2-apps" aria-labelledby="home-v2-apps-title">
|
||||
<div className="home-v2-apps-stage">
|
||||
<div className="home-v2-apps-workflow-header">
|
||||
<div className="home-v2-apps-workflow-copy">
|
||||
<h2 id="home-v2-apps-title">Skills for the apps you already use</h2>
|
||||
<p>
|
||||
Ready-made skills and gateway plugins that plug OpenClaw straight into your everyday
|
||||
tools.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-v2-apps-workflow-tiles" aria-hidden="true">
|
||||
{workflowHeaderTiles.map((tile) => (
|
||||
<span key={tile.label} className={`home-v2-apps-workflow-tile ${tile.className}`}>
|
||||
{tile.badge ? <span>{tile.badge}</span> : null}
|
||||
<img src={tile.src} alt="" width={46} height={46} loading="lazy" decoding="async" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-v2-apps-categories" role="tablist" aria-label="App categories">
|
||||
{appCategories.map((category) => {
|
||||
const Icon = category.icon;
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category.id === activeCategoryId}
|
||||
className="home-v2-apps-category-tab"
|
||||
onClick={() => setActiveCategoryId(category.id)}
|
||||
>
|
||||
<Icon className="home-v2-apps-category-tab-icon" size={14} aria-hidden="true" />
|
||||
{category.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="home-v2-apps-tile-grid" aria-label="App and plugin shortcuts">
|
||||
{compactItems.map((item) =>
|
||||
item.kind === "skill" ? (
|
||||
<HomeAppsCompactSkill key={`skill-${item.app.id}`} app={item.app} />
|
||||
) : (
|
||||
<HomeAppsCompactPlugin key={`plugin-${item.plugin.id}`} plugin={item.plugin} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="home-v2-apps-see-all-row">
|
||||
<Link to="/skills" search={SKILLS_BROWSE_SEARCH} className="home-v2-apps-see-all">
|
||||
Browse all skills
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
import type { PointerEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { InstallCopyButton } from "./InstallCopyButton";
|
||||
|
||||
const BYOS_ASCII = [
|
||||
"....:: clawhub/openclaw ::.... skills plugins publishers trust signals",
|
||||
">>> install scan publish verify @@ gateway @@ registry @@ agents @@",
|
||||
" 30 skills 12 plugins /api/v1/skills /owners /audit /ship",
|
||||
":::: signed manifests ::::: moderated releases ::::: version history ::::",
|
||||
" hooks runners slash-commands skill.md templates scanners review-bots",
|
||||
"openclaw ecosystem crabbox clickclack crawler packs gateway plugins",
|
||||
"---- downloads installs stars lineage ownership docs package integrity",
|
||||
" safe browse paths official gateways publisher handles org trust",
|
||||
];
|
||||
const BYOS_ASCII_FIELD = Array.from({ length: 56 }, (_, row) => {
|
||||
const a = BYOS_ASCII[row % BYOS_ASCII.length];
|
||||
const b = BYOS_ASCII[(row + 3) % BYOS_ASCII.length];
|
||||
const c = BYOS_ASCII[(row + 5) % BYOS_ASCII.length];
|
||||
const d = BYOS_ASCII[(row + 1) % BYOS_ASCII.length];
|
||||
const e = BYOS_ASCII[(row + 6) % BYOS_ASCII.length];
|
||||
return `${a} ${b} ${c} ${d} ${e}`;
|
||||
}).join("\n");
|
||||
|
||||
// Same composition as the footer easter egg, rendered full-bleed with a static
|
||||
// image stack plus the pointer-tracked ASCII glow that reveals on hover.
|
||||
function ByosRevealBackdrop() {
|
||||
return (
|
||||
<div className="home-v2-byos-reveal" aria-hidden="true">
|
||||
<div className="home-v2-byos-reveal-image home-v2-byos-reveal-image--base" />
|
||||
<div className="home-v2-byos-reveal-scrim" />
|
||||
<pre className="home-v2-byos-reveal-ascii">{BYOS_ASCII_FIELD}</pre>
|
||||
<div className="home-v2-byos-reveal-image home-v2-byos-reveal-image--top" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TerminalLine = { text: string; comment?: boolean };
|
||||
type Audience = "humans" | "agents";
|
||||
|
||||
type TerminalTab = {
|
||||
id: Audience;
|
||||
label: string;
|
||||
mode: "terminal";
|
||||
termLabel: string;
|
||||
lines: TerminalLine[];
|
||||
};
|
||||
|
||||
type PromptTab = {
|
||||
id: Audience;
|
||||
label: string;
|
||||
mode: "prompt";
|
||||
promptLabel: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type AudienceTab = TerminalTab | PromptTab;
|
||||
|
||||
const AGENT_PROMPT =
|
||||
"Read docs.openclaw.ai/clawhub, verify my skills are publish-ready, then publish them to ClawHub and report the published URLs.";
|
||||
|
||||
const TABS: AudienceTab[] = [
|
||||
{
|
||||
id: "agents",
|
||||
label: "For agents",
|
||||
mode: "prompt",
|
||||
promptLabel: "agent prompt",
|
||||
prompt: AGENT_PROMPT,
|
||||
},
|
||||
{
|
||||
id: "humans",
|
||||
label: "For humans",
|
||||
mode: "terminal",
|
||||
termLabel: "clawhub — publish & sync",
|
||||
lines: [
|
||||
{ text: "npm i -g clawhub" },
|
||||
{ text: "clawhub login" },
|
||||
{ text: "clawhub skill publish ./my-skill --slug my-skill --version 1.0.0" },
|
||||
{ text: "clawhub package publish your-org/your-plugin" },
|
||||
{ text: "clawhub sync --all" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function copyTextFor(tab: TerminalTab) {
|
||||
return tab.lines
|
||||
.filter((line) => !line.comment)
|
||||
.map((line) => line.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function GitHubGlyph() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="home-v2-byos-import-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .97-.31 3.16 1.18.92-.26 1.9-.38 2.88-.39.98 0 1.96.13 2.88.39 2.19-1.49 3.15-1.18 3.15-1.18.63 1.58.24 2.75.12 3.04.74.8 1.18 1.83 1.18 3.08 0 4.42-2.69 5.39-5.25 5.67.42.36.78 1.07.78 2.15 0 1.55-.01 2.8-.01 3.18 0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Terminal({ tab }: { tab: TerminalTab }) {
|
||||
return (
|
||||
<div className="home-v2-byos-term">
|
||||
<div className="home-v2-byos-term-bar" aria-hidden="true">
|
||||
<span className="home-v2-byos-term-dot" />
|
||||
<span className="home-v2-byos-term-dot" />
|
||||
<span className="home-v2-byos-term-dot" />
|
||||
<span className="home-v2-byos-term-label">{tab.termLabel}</span>
|
||||
</div>
|
||||
<div className="home-v2-byos-term-body">
|
||||
<pre className="home-v2-byos-code" tabIndex={0}>
|
||||
<code translate="no">
|
||||
{tab.lines.map((line, index) => (
|
||||
<span className="home-v2-byos-line" key={`${index}-${line.text}`}>
|
||||
{line.comment ? (
|
||||
<span className="home-v2-byos-comment">{line.text}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="home-v2-byos-prompt">$ </span>
|
||||
<span className="home-v2-byos-cmd">{line.text}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
<InstallCopyButton
|
||||
text={copyTextFor(tab)}
|
||||
ariaLabel={`Copy ${tab.label} commands`}
|
||||
className="home-v2-byos-copy"
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptCard({ tab }: { tab: PromptTab }) {
|
||||
return (
|
||||
<div className="home-v2-byos-term home-v2-byos-prompt-card">
|
||||
<div className="home-v2-byos-term-bar" aria-hidden="true">
|
||||
<Sparkles size={13} className="home-v2-byos-prompt-spark" />
|
||||
<span className="home-v2-byos-term-label">{tab.promptLabel}</span>
|
||||
</div>
|
||||
<div className="home-v2-byos-term-body">
|
||||
<p className="home-v2-byos-prompt-text">{tab.prompt}</p>
|
||||
<InstallCopyButton
|
||||
text={tab.prompt}
|
||||
ariaLabel={`Copy ${tab.label} prompt`}
|
||||
className="home-v2-byos-copy"
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeBringSkillsSection() {
|
||||
const [audience, setAudience] = useState<Audience>("humans");
|
||||
const activeTab = TABS.find((tab) => tab.id === audience) ?? TABS[0];
|
||||
|
||||
// Drive the backdrop reveal from the whole section so it tracks the cursor
|
||||
// even while hovering the heading, tabs, or command card on top.
|
||||
const handlePointerMove = (event: PointerEvent<HTMLElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
event.currentTarget.style.setProperty("--byos-x", `${event.clientX - rect.left}px`);
|
||||
event.currentTarget.style.setProperty("--byos-y", `${event.clientY - rect.top}px`);
|
||||
event.currentTarget.style.setProperty("--byos-intensity", "1");
|
||||
};
|
||||
|
||||
const handlePointerLeave = (event: PointerEvent<HTMLElement>) => {
|
||||
event.currentTarget.style.setProperty("--byos-intensity", "0");
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="home-v2-byos"
|
||||
aria-labelledby="home-v2-byos-title"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<ByosRevealBackdrop />
|
||||
<div className="home-v2-byos-content">
|
||||
<header className="home-v2-byos-head">
|
||||
<span className="home-v2-byos-eyebrow">ClawHub CLI</span>
|
||||
<h2 id="home-v2-byos-title" className="home-v2-byos-title">
|
||||
Bring your skills to ClawHub
|
||||
</h2>
|
||||
<p className="home-v2-byos-lede">Publish and sync your skills to ClawHub, your way.</p>
|
||||
</header>
|
||||
|
||||
<div className="home-v2-byos-tabs" role="tablist" aria-label="Choose an audience">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`home-v2-byos-tab-${tab.id}`}
|
||||
aria-selected={tab.id === audience}
|
||||
aria-controls="home-v2-byos-panel"
|
||||
className="home-v2-byos-tab"
|
||||
onClick={() => setAudience(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="home-v2-byos-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`home-v2-byos-tab-${activeTab.id}`}
|
||||
className="home-v2-byos-panel"
|
||||
>
|
||||
{activeTab.mode === "terminal" ? (
|
||||
<Terminal tab={activeTab} />
|
||||
) : (
|
||||
<PromptCard tab={activeTab} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="home-v2-byos-foot">
|
||||
<Link to="/import" className="home-v2-byos-import">
|
||||
<GitHubGlyph />
|
||||
or import from your GitHub
|
||||
<ArrowRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
|
||||
import type { BrowseCategory } from "../lib/categories";
|
||||
|
||||
type HomeListingCategorySelectProps = {
|
||||
categories: readonly BrowseCategory[];
|
||||
value: readonly string[];
|
||||
onChange: (slugs: string[]) => void;
|
||||
};
|
||||
|
||||
function CategoryOption({
|
||||
slug,
|
||||
label,
|
||||
icon,
|
||||
selected,
|
||||
onSelect,
|
||||
reset = false,
|
||||
}: {
|
||||
slug: string | null;
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
reset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className={`home-v2-listing-category-option-wrap${reset ? " is-reset" : ""}`}
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`home-v2-listing-category-option${selected ? " is-selected" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="home-v2-listing-category-option-mark" aria-hidden="true">
|
||||
{selected ? <Check size={12} strokeWidth={2.5} /> : null}
|
||||
</span>
|
||||
<BrowseCategoryIcon
|
||||
slug={slug}
|
||||
icon={icon}
|
||||
size={16}
|
||||
className="home-v2-listing-category-option-icon"
|
||||
/>
|
||||
<span className="home-v2-listing-category-option-label">{label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeListingCategorySelect({
|
||||
categories,
|
||||
value,
|
||||
onChange,
|
||||
}: HomeListingCategorySelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const listboxId = useId();
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (value.length === 0) return "All categories";
|
||||
if (value.length === 1) {
|
||||
return categories.find((category) => category.slug === value[0])?.label ?? "All categories";
|
||||
}
|
||||
return `${value.length} categories`;
|
||||
}, [categories, value]);
|
||||
const selectedCategory = useMemo(
|
||||
() => (value.length === 1 ? categories.find((category) => category.slug === value[0]) : null),
|
||||
[categories, value],
|
||||
);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value]);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return categories;
|
||||
return categories.filter(
|
||||
(category) =>
|
||||
category.label.toLowerCase().includes(normalized) || category.slug.includes(normalized),
|
||||
);
|
||||
}, [categories, query]);
|
||||
|
||||
const closeMenu = () => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (rootRef.current?.contains(event.target as Node)) return;
|
||||
closeMenu();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeMenu();
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) searchRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const pick = (slug: string | null) => {
|
||||
if (slug === null) {
|
||||
onChange([]);
|
||||
return;
|
||||
}
|
||||
if (selectedSet.has(slug)) {
|
||||
onChange(value.filter((selectedSlug) => selectedSlug !== slug));
|
||||
return;
|
||||
}
|
||||
onChange([...value, slug]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-v2-listing-category-menu home-v2-listing-category-select" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-listing-category-trigger"
|
||||
role="combobox"
|
||||
aria-label="Category"
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span className="home-v2-listing-category-trigger-main">
|
||||
<BrowseCategoryIcon
|
||||
slug={selectedCategory?.slug ?? null}
|
||||
icon={selectedCategory?.icon}
|
||||
size={15}
|
||||
className="home-v2-listing-category-trigger-category-icon"
|
||||
/>
|
||||
<span className="home-v2-listing-category-trigger-label">{selectedLabel}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`home-v2-listing-category-trigger-icon${open ? " is-open" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="home-v2-listing-category-panel">
|
||||
<div className="home-v2-listing-category-search-wrap">
|
||||
<Search size={16} className="home-v2-listing-category-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
className="home-v2-listing-category-search"
|
||||
placeholder="Search categories…"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label="Search categories"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
id={listboxId}
|
||||
className="home-v2-listing-category-options"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
aria-label="Category"
|
||||
>
|
||||
{!query.trim() ? (
|
||||
<CategoryOption
|
||||
slug={null}
|
||||
label="All categories"
|
||||
selected={value.length === 0}
|
||||
onSelect={() => pick(null)}
|
||||
reset
|
||||
/>
|
||||
) : null}
|
||||
{filteredCategories.map((category) => (
|
||||
<CategoryOption
|
||||
key={category.slug}
|
||||
slug={category.slug}
|
||||
label={category.label}
|
||||
icon={category.icon}
|
||||
selected={selectedSet.has(category.slug)}
|
||||
onSelect={() => pick(category.slug)}
|
||||
/>
|
||||
))}
|
||||
{filteredCategories.length === 0 ? (
|
||||
<li className="home-v2-listing-category-empty" role="presentation">
|
||||
No categories match
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { isPluginCategorySlug, isSkillCategorySlug } from "clawhub-schema";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Binoculars,
|
||||
CloudOff,
|
||||
Download,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Moon,
|
||||
Plus,
|
||||
Rows3,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { isSkillOfficial } from "../lib/badges";
|
||||
import {
|
||||
getSkillCategoriesForSkill,
|
||||
PLUGIN_CATEGORIES,
|
||||
SKILL_CATEGORIES,
|
||||
type BrowseCategory,
|
||||
} from "../lib/categories";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "../lib/packageApi";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import { HomeListingCategorySelect } from "./HomeListingCategorySelect";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { OfficialBadge } from "./OfficialBadge";
|
||||
|
||||
type ListingKind = "skills" | "plugins";
|
||||
type ListingTab = "popular" | "trending" | "officials" | "new";
|
||||
type ListingView = "list" | "grid";
|
||||
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
ownerHandle?: string | null;
|
||||
owner?: PublicUser | null;
|
||||
};
|
||||
|
||||
const SKILL_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [
|
||||
{ id: "popular", label: "Top" },
|
||||
{ id: "trending", label: "Trending" },
|
||||
{ id: "new", label: "New" },
|
||||
];
|
||||
|
||||
const PLUGIN_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [
|
||||
{ id: "officials", label: "Official" },
|
||||
{ id: "popular", label: "Top" },
|
||||
{ id: "new", label: "New" },
|
||||
];
|
||||
|
||||
const LISTING_PAGE_SIZE = 20;
|
||||
const LISTING_SEARCH_DEBOUNCE_MS = 220;
|
||||
const PLUGIN_CATALOG_PAGE_LIMIT = 100;
|
||||
|
||||
const HOME_SKILL_LISTING_CATEGORIES: BrowseCategory[] = SKILL_CATEGORIES.map(
|
||||
({ slug, label, icon }) => ({ slug, label, icon }),
|
||||
);
|
||||
|
||||
function isTypingTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT"
|
||||
);
|
||||
}
|
||||
|
||||
type SkillSearchHit = {
|
||||
skill: PublicSkill;
|
||||
ownerHandle?: string | null;
|
||||
owner?: PublicUser | null;
|
||||
};
|
||||
|
||||
function filterSkillsByTab(entries: SkillPageEntry[], tab: ListingTab) {
|
||||
if (tab === "officials") {
|
||||
return entries.filter((entry) => isSkillOfficial(entry.skill));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function filterPluginsByTab(items: PackageListItem[], tab: ListingTab) {
|
||||
if (tab === "officials") {
|
||||
return items.filter((item) => item.isOfficial);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function itemMatchesAnyCategory(
|
||||
item: { categories?: readonly string[] | null },
|
||||
categorySlugs: readonly string[],
|
||||
) {
|
||||
if (categorySlugs.length === 0) return true;
|
||||
const categories = item.categories ?? [];
|
||||
return categorySlugs.some((slug) => categories.includes(slug));
|
||||
}
|
||||
|
||||
function skillMatchesAnyCategory(skill: PublicSkill, categorySlugs: readonly string[]) {
|
||||
if (categorySlugs.length === 0) return true;
|
||||
const categories = getSkillCategoriesForSkill(skill);
|
||||
return categorySlugs.some((slug) => categories.some((category) => category.slug === slug));
|
||||
}
|
||||
|
||||
function uniqueSkillEntries(entries: SkillPageEntry[]) {
|
||||
const byId = new Map<string, SkillPageEntry>();
|
||||
for (const entry of entries) {
|
||||
byId.set(String(entry.skill._id), entry);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function uniquePlugins(items: PackageListItem[]) {
|
||||
const byName = new Map<string, PackageListItem>();
|
||||
for (const item of items) {
|
||||
byName.set(item.name, item);
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function sortSkillEntries(entries: SkillPageEntry[], tab: ListingTab) {
|
||||
return [...entries].sort((left, right) => {
|
||||
if (tab === "new") {
|
||||
return (
|
||||
(right.skill.updatedAt ?? right.skill.createdAt ?? right.skill._creationTime ?? 0) -
|
||||
(left.skill.updatedAt ?? left.skill.createdAt ?? left.skill._creationTime ?? 0)
|
||||
);
|
||||
}
|
||||
return (right.skill.stats?.installsAllTime ?? 0) - (left.skill.stats?.installsAllTime ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function HomeListingEmptyPanel({
|
||||
variant,
|
||||
query,
|
||||
onClearSearch,
|
||||
}: {
|
||||
variant: "error" | "search" | "filter";
|
||||
query?: string;
|
||||
onClearSearch?: () => void;
|
||||
}) {
|
||||
const Icon = variant === "error" ? CloudOff : variant === "search" ? Binoculars : Moon;
|
||||
const title =
|
||||
variant === "error"
|
||||
? "Listings took a coffee break"
|
||||
: variant === "search"
|
||||
? query
|
||||
? `No claws for “${query}”`
|
||||
: "No claws in this view"
|
||||
: "Quiet shelf";
|
||||
const body =
|
||||
variant === "error"
|
||||
? "We couldn't load this slice of the catalog. Give it another try in a moment."
|
||||
: variant === "search"
|
||||
? "Try another query or clear the search."
|
||||
: "Nothing on this tab right now. Peek at another tab or widen the category.";
|
||||
|
||||
return (
|
||||
<div className="home-v2-listing-empty" role="status">
|
||||
<div className="home-v2-listing-empty-icon" aria-hidden="true">
|
||||
<Icon size={26} strokeWidth={1.6} />
|
||||
</div>
|
||||
<p className="home-v2-listing-empty-title">{title}</p>
|
||||
<p className="home-v2-listing-empty-body">{body}</p>
|
||||
{variant === "search" && onClearSearch ? (
|
||||
<button type="button" className="home-v2-listing-empty-action" onClick={onClearSearch}>
|
||||
<X size={15} aria-hidden="true" />
|
||||
Clear search
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeListingResults({
|
||||
view,
|
||||
showMore,
|
||||
loadingMore,
|
||||
onSeeMore,
|
||||
children,
|
||||
}: {
|
||||
view: ListingView;
|
||||
showMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onSeeMore: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`home-v2-listing-results${showMore ? " is-collapsed" : ""}${view === "grid" ? " is-grid" : " is-list"}`}
|
||||
>
|
||||
{children}
|
||||
{showMore ? (
|
||||
<div className="home-v2-listing-more">
|
||||
<div className="home-v2-listing-more-fade" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-listing-more-btn"
|
||||
onClick={onSeeMore}
|
||||
disabled={loadingMore}
|
||||
data-loading={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<Loader2 size={14} aria-hidden="true" className="home-v2-listing-more-spinner" />
|
||||
) : (
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
)}
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function skillLink(entry: SkillPageEntry) {
|
||||
const owner =
|
||||
entry.ownerHandle?.trim() ||
|
||||
entry.owner?.handle?.trim() ||
|
||||
String(entry.skill.ownerPublisherId ?? entry.skill.ownerUserId);
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(entry.skill.slug)}`;
|
||||
}
|
||||
|
||||
async function fetchSkillListing(
|
||||
tab: ListingTab,
|
||||
categorySlugs: readonly string[],
|
||||
numItems: number,
|
||||
) {
|
||||
if (tab === "trending") {
|
||||
const requestLimit = categorySlugs.length > 0 ? 200 : numItems;
|
||||
const result = await convexHttp.query(api.skills.listPublicTrendingPage, {
|
||||
limit: requestLimit,
|
||||
});
|
||||
const items = ((result as { items?: SkillPageEntry[] }).items ?? []).filter((entry) =>
|
||||
skillMatchesAnyCategory(entry.skill, categorySlugs),
|
||||
);
|
||||
return {
|
||||
page: uniqueSkillEntries(items).slice(0, numItems),
|
||||
hasMore: items.length > numItems || (items.length >= numItems && numItems < 200),
|
||||
};
|
||||
}
|
||||
|
||||
const categoriesToFetch = categorySlugs.length > 0 ? categorySlugs : [null];
|
||||
const results = await Promise.all(
|
||||
categoriesToFetch.map(async (categorySlug) => {
|
||||
const page: SkillPageEntry[] = [];
|
||||
let cursor: string | null | undefined;
|
||||
let hasMore = false;
|
||||
|
||||
while (page.length < numItems) {
|
||||
const result = await convexHttp.query(api.skills.listPublicPageV4, {
|
||||
cursor: cursor ?? undefined,
|
||||
numItems: numItems - page.length,
|
||||
sort: tab === "new" ? "newest" : "installs",
|
||||
dir: "desc",
|
||||
officialFirst: tab === "officials" ? true : undefined,
|
||||
categorySlug: categorySlug ?? undefined,
|
||||
});
|
||||
if (Array.isArray(result)) break;
|
||||
|
||||
const resultPage = ((result as { page?: SkillPageEntry[] }).page ?? []).filter((entry) =>
|
||||
skillMatchesAnyCategory(entry.skill, categorySlugs),
|
||||
);
|
||||
page.push(...resultPage);
|
||||
|
||||
const nextCursor = (result as { nextCursor?: string | null }).nextCursor ?? null;
|
||||
hasMore = Boolean((result as { hasMore?: boolean }).hasMore ?? nextCursor);
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return { page, hasMore };
|
||||
}),
|
||||
);
|
||||
const pages = results.flatMap((result) => result.page);
|
||||
const sorted = sortSkillEntries(filterSkillsByTab(uniqueSkillEntries(pages), tab), tab);
|
||||
const hasMore = sorted.length > numItems || results.some((result) => result.hasMore);
|
||||
const page = sorted.slice(0, numItems);
|
||||
return { page, hasMore };
|
||||
}
|
||||
|
||||
async function fetchPluginListing(
|
||||
tab: ListingTab,
|
||||
categorySlugs: readonly string[],
|
||||
limit: number,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
const openClawOfficials = tab === "officials";
|
||||
const categoriesToFetch = categorySlugs.length > 0 ? categorySlugs : [null];
|
||||
const results = await Promise.all(
|
||||
categoriesToFetch.map(async (categorySlug) => {
|
||||
const items: PackageListItem[] = [];
|
||||
let cursor: string | null | undefined;
|
||||
let hasMore = false;
|
||||
|
||||
while (items.length < limit) {
|
||||
const result = await fetchPluginCatalog({
|
||||
category: categorySlug ?? undefined,
|
||||
cursor: cursor ?? undefined,
|
||||
isOfficial: openClawOfficials ? true : undefined,
|
||||
sort: tab === "new" ? "updated" : "installs",
|
||||
limit: Math.min(limit - items.length, PLUGIN_CATALOG_PAGE_LIMIT),
|
||||
signal,
|
||||
});
|
||||
items.push(...result.items.filter((item) => itemMatchesAnyCategory(item, categorySlugs)));
|
||||
|
||||
hasMore = result.nextCursor != null;
|
||||
if (!result.nextCursor || result.nextCursor === cursor) break;
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
|
||||
return { items, hasMore };
|
||||
}),
|
||||
);
|
||||
let items = uniquePlugins(results.flatMap((result) => result.items));
|
||||
items = filterPluginsByTab(items, tab);
|
||||
if (tab === "new") {
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
} else if (tab === "popular" || openClawOfficials) {
|
||||
items.sort((a, b) => (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0));
|
||||
}
|
||||
const page = items.slice(0, limit);
|
||||
return {
|
||||
items: page,
|
||||
hasMore: items.length > limit || results.some((result) => result.hasMore),
|
||||
};
|
||||
}
|
||||
|
||||
function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) {
|
||||
const handle = entry.ownerHandle || entry.owner?.handle;
|
||||
const name = entry.skill.displayName || entry.skill.slug;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={skillLink(entry)}
|
||||
className={`home-v2-listing-row${showStats ? "" : " has-no-stats"}`}
|
||||
>
|
||||
<span className="home-v2-listing-row-icon" aria-hidden="true">
|
||||
<MarketplaceIcon kind="skill" label={name} skill={entry.skill} size="sm" />
|
||||
</span>
|
||||
<div className="home-v2-listing-row-body">
|
||||
<div className="home-v2-listing-row-title">
|
||||
<span className="home-v2-listing-row-name">{name}</span>
|
||||
{handle ? <span className="home-v2-listing-row-by">@{handle}</span> : null}
|
||||
</div>
|
||||
<p className="home-v2-listing-row-summary">
|
||||
{entry.skill.summary || "Agent-ready skill pack."}
|
||||
</p>
|
||||
</div>
|
||||
{showStats ? (
|
||||
<div className="home-v2-listing-row-stats" aria-label="Popularity">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(entry.skill.stats?.installsAllTime ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
|
||||
const name = plugin.displayName || plugin.name;
|
||||
|
||||
return (
|
||||
<Link to="/plugins/$name" params={{ name: plugin.name }} className="home-v2-listing-row">
|
||||
<span className="home-v2-listing-row-icon" aria-hidden="true">
|
||||
<MarketplaceIcon kind="plugin" label={name} size="sm" />
|
||||
</span>
|
||||
<div className="home-v2-listing-row-body">
|
||||
<div className="home-v2-listing-row-title">
|
||||
<span className="home-v2-listing-row-name">{name}</span>
|
||||
{plugin.ownerHandle ? (
|
||||
<span className="home-v2-listing-row-by">@{plugin.ownerHandle}</span>
|
||||
) : null}
|
||||
{plugin.isOfficial ? <OfficialBadge /> : null}
|
||||
</div>
|
||||
<p className="home-v2-listing-row-summary">
|
||||
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-v2-listing-row-stats" aria-label="Popularity">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(plugin.stats?.installs ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) {
|
||||
const handle = entry.ownerHandle || entry.owner?.handle;
|
||||
const name = entry.skill.displayName || entry.skill.slug;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={skillLink(entry)}
|
||||
className={`home-v2-listing-card${showStats ? "" : " has-no-stats"}`}
|
||||
>
|
||||
<div className="home-v2-listing-card-head">
|
||||
<span className="home-v2-listing-card-icon" aria-hidden="true">
|
||||
<MarketplaceIcon kind="skill" label={name} skill={entry.skill} size="sm" />
|
||||
</span>
|
||||
<div className="home-v2-listing-card-id">
|
||||
<span className="home-v2-listing-card-name">{name}</span>
|
||||
{handle ? <span className="home-v2-listing-card-by">@{handle}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-v2-listing-card-summary">
|
||||
{entry.skill.summary || "Agent-ready skill pack."}
|
||||
</p>
|
||||
{showStats ? (
|
||||
<div className="home-v2-listing-card-stats" aria-label="Popularity">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(entry.skill.stats?.installsAllTime ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
|
||||
const name = plugin.displayName || plugin.name;
|
||||
|
||||
return (
|
||||
<Link to="/plugins/$name" params={{ name: plugin.name }} className="home-v2-listing-card">
|
||||
<div className="home-v2-listing-card-head">
|
||||
<span className="home-v2-listing-card-icon" aria-hidden="true">
|
||||
<MarketplaceIcon kind="plugin" label={name} size="sm" />
|
||||
</span>
|
||||
<div className="home-v2-listing-card-id">
|
||||
<span className="home-v2-listing-card-name">{name}</span>
|
||||
<span className="home-v2-listing-card-by-row">
|
||||
{plugin.ownerHandle ? (
|
||||
<span className="home-v2-listing-card-by">@{plugin.ownerHandle}</span>
|
||||
) : null}
|
||||
{plugin.isOfficial ? <OfficialBadge /> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-v2-listing-card-summary">
|
||||
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
|
||||
</p>
|
||||
<div className="home-v2-listing-card-stats" aria-label="Popularity">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(plugin.stats?.installs ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeListingSection() {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchRequestRef = useRef(0);
|
||||
const [kind, setKind] = useState<ListingKind>("skills");
|
||||
const [tab, setTab] = useState<ListingTab>("popular");
|
||||
const [view, setView] = useState<ListingView>("list");
|
||||
const [categorySlugs, setCategorySlugs] = useState<string[]>([]);
|
||||
const [visibleCount, setVisibleCount] = useState(LISTING_PAGE_SIZE);
|
||||
const [fetchLimit, setFetchLimit] = useState(LISTING_PAGE_SIZE);
|
||||
const [skills, setSkills] = useState<SkillPageEntry[]>([]);
|
||||
const [plugins, setPlugins] = useState<PackageListItem[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "idle" | "error">("loading");
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchSkills, setSearchSkills] = useState<SkillPageEntry[]>([]);
|
||||
const [searchPlugins, setSearchPlugins] = useState<PackageListItem[]>([]);
|
||||
const [searchStatus, setSearchStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [listingHasMore, setListingHasMore] = useState(false);
|
||||
|
||||
const trimmedSearch = searchQuery.trim();
|
||||
const isSearchMode = trimmedSearch.length > 0;
|
||||
const listingCategories = kind === "skills" ? HOME_SKILL_LISTING_CATEGORIES : PLUGIN_CATEGORIES;
|
||||
const selectedCategories = useMemo(
|
||||
() =>
|
||||
categorySlugs.flatMap((slug) => {
|
||||
const category = listingCategories.find((candidate) => candidate.slug === slug);
|
||||
return category ? [category] : [];
|
||||
}),
|
||||
[categorySlugs, listingCategories],
|
||||
);
|
||||
|
||||
const filteredSearchSkills = useMemo(
|
||||
() => filterSkillsByTab(searchSkills, tab),
|
||||
[searchSkills, tab],
|
||||
);
|
||||
const filteredSearchPlugins = useMemo(
|
||||
() => filterPluginsByTab(searchPlugins, tab),
|
||||
[searchPlugins, tab],
|
||||
);
|
||||
const visibleTabs = kind === "skills" ? SKILL_LISTING_TABS : PLUGIN_LISTING_TABS;
|
||||
|
||||
const activeItems = isSearchMode
|
||||
? kind === "skills"
|
||||
? filteredSearchSkills
|
||||
: filteredSearchPlugins
|
||||
: kind === "skills"
|
||||
? skills
|
||||
: plugins;
|
||||
const activeStatus = isSearchMode ? searchStatus : status;
|
||||
const isEmpty = activeStatus === "idle" && activeItems.length === 0;
|
||||
const showSkillStats = !(kind === "skills" && tab === "trending" && !isSearchMode);
|
||||
const showListingMore =
|
||||
activeStatus === "idle" && (activeItems.length > visibleCount || listingHasMore);
|
||||
|
||||
const openListingSearch = useCallback(() => {
|
||||
setSearchOpen(true);
|
||||
window.requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.defaultPrevented) return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
openListingSearch();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [openListingSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchOpen) return undefined;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
if (trimmedSearch) {
|
||||
setSearchQuery("");
|
||||
return;
|
||||
}
|
||||
setSearchOpen(false);
|
||||
searchInputRef.current?.blur();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [searchOpen, trimmedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchMode) return undefined;
|
||||
const controller = new AbortController();
|
||||
// "Load more" only grows fetchLimit: keep the existing rows mounted and
|
||||
// append, instead of swapping in the skeleton (which collapses height and
|
||||
// throws away the scroll position).
|
||||
const isLoadMore = fetchLimit > LISTING_PAGE_SIZE;
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setStatus("loading");
|
||||
setListingHasMore(false);
|
||||
}
|
||||
|
||||
const load =
|
||||
kind === "skills"
|
||||
? fetchSkillListing(tab, categorySlugs, fetchLimit).then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setSkills(result.page);
|
||||
setListingHasMore(result.hasMore);
|
||||
setStatus("idle");
|
||||
})
|
||||
: fetchPluginListing(tab, categorySlugs, fetchLimit, controller.signal).then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPlugins(result.items);
|
||||
setListingHasMore(result.hasMore);
|
||||
setStatus("idle");
|
||||
});
|
||||
|
||||
load
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
// On a load-more failure keep what's already shown instead of wiping it.
|
||||
if (isLoadMore) return;
|
||||
if (kind === "skills") {
|
||||
setSkills([]);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setPlugins([]);
|
||||
setStatus("error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setLoadingMore(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [categorySlugs, fetchLimit, isSearchMode, kind, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearchMode) {
|
||||
setSearchSkills([]);
|
||||
setSearchPlugins([]);
|
||||
setSearchStatus("idle");
|
||||
setListingHasMore(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
searchRequestRef.current += 1;
|
||||
const requestId = searchRequestRef.current;
|
||||
const controller = new AbortController();
|
||||
const isLoadMore = fetchLimit > LISTING_PAGE_SIZE;
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setSearchStatus("loading");
|
||||
setListingHasMore(false);
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
const load =
|
||||
kind === "skills"
|
||||
? Promise.all(
|
||||
(categorySlugs.length > 0 ? categorySlugs : [null]).map((categorySlug) =>
|
||||
convexHttp.action(api.search.searchSkills, {
|
||||
query: trimmedSearch,
|
||||
limit: fetchLimit,
|
||||
...(categorySlug ? { categorySlug } : {}),
|
||||
}),
|
||||
),
|
||||
).then((results) => {
|
||||
if (controller.signal.aborted || requestId !== searchRequestRef.current) return;
|
||||
const rows = uniqueSkillEntries(
|
||||
results.flatMap((hits) =>
|
||||
(hits as SkillSearchHit[])
|
||||
.map((hit) => ({
|
||||
skill: hit.skill,
|
||||
ownerHandle: hit.ownerHandle,
|
||||
owner: hit.owner,
|
||||
}))
|
||||
.filter((entry) => skillMatchesAnyCategory(entry.skill, categorySlugs)),
|
||||
),
|
||||
);
|
||||
const sortedRows = tab === "new" ? sortSkillEntries(rows, tab) : rows;
|
||||
setSearchSkills(sortedRows.slice(0, fetchLimit));
|
||||
setListingHasMore(
|
||||
sortedRows.length > fetchLimit ||
|
||||
results.some((hits) => (hits as SkillSearchHit[]).length >= fetchLimit),
|
||||
);
|
||||
setSearchStatus("idle");
|
||||
})
|
||||
: Promise.all(
|
||||
(categorySlugs.length > 0 ? categorySlugs : [null]).map((categorySlug) =>
|
||||
fetchPluginCatalog({
|
||||
q: trimmedSearch,
|
||||
category: categorySlug ?? undefined,
|
||||
isOfficial: tab === "officials" ? true : undefined,
|
||||
sort: tab === "new" ? "updated" : "installs",
|
||||
limit: fetchLimit,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
),
|
||||
).then((results) => {
|
||||
if (controller.signal.aborted || requestId !== searchRequestRef.current) return;
|
||||
const items = uniquePlugins(
|
||||
results.flatMap((result) =>
|
||||
result.items.filter((item) => itemMatchesAnyCategory(item, categorySlugs)),
|
||||
),
|
||||
);
|
||||
setSearchPlugins(
|
||||
tab === "new" ? [...items].sort((a, b) => b.updatedAt - a.updatedAt) : items,
|
||||
);
|
||||
setListingHasMore(
|
||||
results.some(
|
||||
(result) => result.nextCursor != null || result.items.length >= fetchLimit,
|
||||
),
|
||||
);
|
||||
setSearchStatus("idle");
|
||||
});
|
||||
|
||||
load
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted || requestId !== searchRequestRef.current) return;
|
||||
if (isLoadMore) return;
|
||||
if (kind === "skills") setSearchSkills([]);
|
||||
else setSearchPlugins([]);
|
||||
setSearchStatus("error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted || requestId !== searchRequestRef.current) return;
|
||||
setLoadingMore(false);
|
||||
});
|
||||
}, LISTING_SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [categorySlugs, fetchLimit, isSearchMode, kind, tab, trimmedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categorySlugs.length === 0) return;
|
||||
const isValid = kind === "skills" ? isSkillCategorySlug : isPluginCategorySlug;
|
||||
const validCategorySlugs = categorySlugs.filter((slug) => isValid(slug));
|
||||
if (validCategorySlugs.length !== categorySlugs.length) {
|
||||
setCategorySlugs(validCategorySlugs);
|
||||
}
|
||||
}, [categorySlugs, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(LISTING_PAGE_SIZE);
|
||||
setFetchLimit(LISTING_PAGE_SIZE);
|
||||
}, [categorySlugs, isSearchMode, kind, tab, trimmedSearch, view]);
|
||||
|
||||
const visibleSkills = (isSearchMode ? filteredSearchSkills : skills).slice(0, visibleCount);
|
||||
const visiblePlugins = (isSearchMode ? filteredSearchPlugins : plugins).slice(0, visibleCount);
|
||||
|
||||
const handleSeeMore = () => {
|
||||
setVisibleCount((count) => count + LISTING_PAGE_SIZE);
|
||||
setFetchLimit((limit) => limit + LISTING_PAGE_SIZE);
|
||||
};
|
||||
|
||||
const closeListingSearch = () => {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
searchInputRef.current?.blur();
|
||||
};
|
||||
|
||||
const handleListingSearchSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleKindChange = (nextKind: ListingKind) => {
|
||||
if (nextKind === kind) return;
|
||||
setKind(nextKind);
|
||||
setCategorySlugs([]);
|
||||
if (nextKind === "plugins") setTab("officials");
|
||||
else if (tab === "officials") setTab("popular");
|
||||
};
|
||||
|
||||
const removeCategory = (slug: string) => {
|
||||
setCategorySlugs((current) => current.filter((categorySlug) => categorySlug !== slug));
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="home-v2-listing" className="home-v2-listing" aria-label="Browse catalog">
|
||||
<div className="home-v2-listing-controls">
|
||||
<div className="home-v2-listing-toolbar">
|
||||
<div className="home-v2-listing-kind" role="group" aria-label="Content type">
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-kind-btn${kind === "skills" ? " is-active" : ""}`}
|
||||
aria-pressed={kind === "skills"}
|
||||
onClick={() => handleKindChange("skills")}
|
||||
>
|
||||
Skills
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-kind-btn${kind === "plugins" ? " is-active" : ""}`}
|
||||
aria-pressed={kind === "plugins"}
|
||||
onClick={() => handleKindChange("plugins")}
|
||||
>
|
||||
Plugins
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="home-v2-listing-divider" aria-hidden="true" />
|
||||
|
||||
<div className="home-v2-listing-sort">
|
||||
<div className="home-v2-listing-sort-tabs" role="tablist" aria-label="Sort">
|
||||
{visibleTabs.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === item.id}
|
||||
className={`home-v2-listing-tab${tab === item.id ? " is-active" : ""}`}
|
||||
onClick={() => setTab(item.id)}
|
||||
>
|
||||
{item.id === "officials" ? (
|
||||
<BadgeCheck
|
||||
size={14}
|
||||
strokeWidth={2.25}
|
||||
className="home-v2-listing-tab-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-v2-listing-actions">
|
||||
<div className="home-v2-listing-actions-rail has-category">
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-search-trigger${searchOpen ? " is-active" : ""}`}
|
||||
aria-label="Search catalog"
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="home-v2-listing-search-panel"
|
||||
title="Search catalog (/)"
|
||||
onClick={openListingSearch}
|
||||
>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<HomeListingCategorySelect
|
||||
categories={listingCategories}
|
||||
value={categorySlugs}
|
||||
onChange={setCategorySlugs}
|
||||
/>
|
||||
|
||||
<div className="home-v2-listing-view" role="group" aria-label="Layout">
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-view-btn${view === "list" ? " is-active" : ""}`}
|
||||
aria-pressed={view === "list"}
|
||||
aria-label="List view"
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
<Rows3 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-view-btn${view === "grid" ? " is-active" : ""}`}
|
||||
aria-pressed={view === "grid"}
|
||||
aria-label="Grid view"
|
||||
onClick={() => setView("grid")}
|
||||
>
|
||||
<LayoutGrid size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="home-v2-listing-search-panel"
|
||||
className={`home-v2-listing-search${searchOpen ? " is-open" : ""}`}
|
||||
hidden={!searchOpen}
|
||||
>
|
||||
<form className="home-v2-listing-search-bar" onSubmit={handleListingSearchSubmit}>
|
||||
<Search size={16} className="home-v2-listing-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
className="home-v2-listing-search-input"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={
|
||||
kind === "skills"
|
||||
? "Search skills in this catalog…"
|
||||
: "Search plugins in this catalog…"
|
||||
}
|
||||
aria-label={kind === "skills" ? "Search skills" : "Search plugins"}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-listing-search-close"
|
||||
aria-label="Close search"
|
||||
onClick={closeListingSearch}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{selectedCategories.length > 0 ? (
|
||||
<div className="home-v2-listing-active-filters" aria-label="Active category filters">
|
||||
{selectedCategories.length <= 3 ? (
|
||||
selectedCategories.map((category) => (
|
||||
<button
|
||||
key={category.slug}
|
||||
type="button"
|
||||
className="home-v2-listing-filter-chip"
|
||||
onClick={() => removeCategory(category.slug)}
|
||||
aria-label={`Remove ${category.label} category filter`}
|
||||
>
|
||||
{category.label}
|
||||
<X size={13} aria-hidden="true" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<span className="home-v2-listing-filter-chip is-summary">
|
||||
{selectedCategories.length} categories
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-listing-filter-clear"
|
||||
onClick={() => setCategorySlugs([])}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeStatus === "idle" && view === "list" && activeItems.length > 0 ? (
|
||||
<div
|
||||
className={`home-v2-listing-head${showSkillStats ? "" : " has-no-stats"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="home-v2-listing-head-icon-spacer" />
|
||||
<span className="home-v2-listing-head-label">
|
||||
{kind === "skills" ? "Skill" : "Plugin"}
|
||||
</span>
|
||||
{showSkillStats ? <span className="home-v2-listing-head-stat">Popularity</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeStatus === "loading" ? (
|
||||
<div className="home-v2-listing-list home-v2-listing-list-loading" aria-busy="true">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div key={index} className="home-v2-listing-skeleton" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeStatus === "error" ? <HomeListingEmptyPanel variant="error" /> : null}
|
||||
|
||||
{isEmpty ? (
|
||||
<HomeListingEmptyPanel
|
||||
variant={isSearchMode ? "search" : "filter"}
|
||||
query={isSearchMode ? trimmedSearch : undefined}
|
||||
onClearSearch={isSearchMode ? closeListingSearch : undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeStatus === "idle" && kind === "skills" && visibleSkills.length > 0 ? (
|
||||
<HomeListingResults
|
||||
view={view}
|
||||
showMore={showListingMore}
|
||||
loadingMore={loadingMore}
|
||||
onSeeMore={handleSeeMore}
|
||||
>
|
||||
<div className={view === "grid" ? "home-v2-listing-grid" : "home-v2-listing-list"}>
|
||||
{visibleSkills.map((entry) =>
|
||||
view === "grid" ? (
|
||||
<HomeListingSkillCard
|
||||
key={entry.skill._id}
|
||||
entry={entry}
|
||||
showStats={showSkillStats}
|
||||
/>
|
||||
) : (
|
||||
<HomeListingSkillRow
|
||||
key={entry.skill._id}
|
||||
entry={entry}
|
||||
showStats={showSkillStats}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</HomeListingResults>
|
||||
) : null}
|
||||
|
||||
{activeStatus === "idle" && kind === "plugins" && visiblePlugins.length > 0 ? (
|
||||
<HomeListingResults
|
||||
view={view}
|
||||
showMore={showListingMore}
|
||||
loadingMore={loadingMore}
|
||||
onSeeMore={handleSeeMore}
|
||||
>
|
||||
<div className={view === "grid" ? "home-v2-listing-grid" : "home-v2-listing-list"}>
|
||||
{visiblePlugins.map((plugin) =>
|
||||
view === "grid" ? (
|
||||
<HomeListingPluginCard key={plugin.name} plugin={plugin} />
|
||||
) : (
|
||||
<HomeListingPluginRow key={plugin.name} plugin={plugin} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</HomeListingResults>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { type PointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisherListItem } from "../lib/publicUser";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
|
||||
type PinnedPublisher = {
|
||||
handle: string;
|
||||
name: string;
|
||||
kind: "org" | "user";
|
||||
};
|
||||
|
||||
const PINNED_PUBLISHERS: PinnedPublisher[] = [
|
||||
{ handle: "openclaw", name: "OpenClaw", kind: "org" },
|
||||
{ handle: "nvidia", name: "NVIDIA", kind: "org" },
|
||||
{ handle: "steipete", name: "Peter Steinberger", kind: "user" },
|
||||
{ handle: "mvanhorn", name: "Matt Van Horn", kind: "user" },
|
||||
{ handle: "wscats", name: "enoyao", kind: "user" },
|
||||
{ handle: "ivangdavila", name: "Iván", kind: "user" },
|
||||
{ handle: "byungkyu", name: "byungkyu", kind: "user" },
|
||||
{ handle: "pskoett", name: "pskoett", kind: "user" },
|
||||
{ handle: "1kalin", name: "1kalin", kind: "user" },
|
||||
{ handle: "spclaudehome", name: "spclaudehome", kind: "user" },
|
||||
];
|
||||
|
||||
function PopularPublisherCard({
|
||||
pinned,
|
||||
publisher,
|
||||
}: {
|
||||
pinned: PinnedPublisher;
|
||||
publisher?: PublicPublisherListItem;
|
||||
}) {
|
||||
const name = publisher?.displayName?.trim() || pinned.name;
|
||||
const bio = publisher?.bio?.trim() || "Publisher on ClawHub.";
|
||||
const kind = publisher?.kind ?? pinned.kind;
|
||||
const itemCount = (publisher?.stats?.skills ?? 0) + (publisher?.stats?.packages ?? 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/user/$handle"
|
||||
params={{ handle: pinned.handle }}
|
||||
className="home-v2-popular-publisher-card"
|
||||
aria-label={`${name}, @${pinned.handle}`}
|
||||
role="listitem"
|
||||
draggable={false}
|
||||
>
|
||||
<div className="home-v2-popular-publisher-head">
|
||||
<MarketplaceIcon
|
||||
kind={kind === "org" ? "org" : "user"}
|
||||
label={name}
|
||||
imageUrl={publisher?.image ?? `https://github.com/${pinned.handle}.png`}
|
||||
size="md"
|
||||
/>
|
||||
<span className="home-v2-popular-publisher-name">{name}</span>
|
||||
</div>
|
||||
<div className="home-v2-popular-publisher-copy">
|
||||
<p className="home-v2-popular-publisher-bio">{bio}</p>
|
||||
<span className="home-v2-popular-publisher-stats">
|
||||
Explore {formatCompactStat(itemCount)} {itemCount === 1 ? "item" : "items"}
|
||||
<ArrowRight size={13} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePopularPublishersSection() {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef({ pointerId: -1, startX: 0, scrollLeft: 0, moved: false });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [publishersByHandle, setPublishersByHandle] = useState<
|
||||
Record<string, PublicPublisherListItem>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const hydratePublishers = async () => {
|
||||
// These profile queries compute catalog totals. Keep them serial so the
|
||||
// homepage does not starve auth and navigation queries on smaller deployments.
|
||||
for (const pinned of PINNED_PUBLISHERS) {
|
||||
try {
|
||||
const publisher = (await convexHttp.query(api.publishers.getProfileByHandle, {
|
||||
handle: pinned.handle,
|
||||
})) as PublicPublisherListItem | null;
|
||||
if (cancelled) return;
|
||||
if (publisher) {
|
||||
setPublishersByHandle((current) => ({
|
||||
...current,
|
||||
[pinned.handle]: publisher,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Static card metadata remains usable when a profile cannot be hydrated.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void hydratePublishers();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType !== "mouse" || event.button !== 0) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
scrollLeft: viewport.scrollLeft,
|
||||
moved: false,
|
||||
};
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || dragRef.current.pointerId !== event.pointerId) return;
|
||||
const distance = event.clientX - dragRef.current.startX;
|
||||
if (Math.abs(distance) > 4) dragRef.current.moved = true;
|
||||
viewport.scrollLeft = dragRef.current.scrollLeft - distance;
|
||||
};
|
||||
|
||||
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || dragRef.current.pointerId !== event.pointerId) return;
|
||||
if (viewport.hasPointerCapture(event.pointerId))
|
||||
viewport.releasePointerCapture(event.pointerId);
|
||||
dragRef.current.pointerId = -1;
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="home-v2-popular-publishers" aria-labelledby="popular-publishers-title">
|
||||
<header className="home-v2-popular-publishers-header">
|
||||
<div className="home-v2-popular-publishers-heading">
|
||||
<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">
|
||||
Browse creators <ArrowRight size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={`home-v2-popular-publishers-viewport${dragging ? " is-dragging" : ""}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopDragging}
|
||||
onPointerCancel={stopDragging}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onClickCapture={(event) => {
|
||||
if (!dragRef.current.moved) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragRef.current.moved = false;
|
||||
}}
|
||||
>
|
||||
<div className="home-v2-popular-publishers-track" role="list">
|
||||
{PINNED_PUBLISHERS.map((publisher) => (
|
||||
<PopularPublisherCard
|
||||
key={publisher.handle}
|
||||
pinned={publisher}
|
||||
publisher={publishersByHandle[publisher.handle]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { readHomeViewportHeight, shouldShowHomeV2FoldBottomFade } from "../lib/homeFoldFade";
|
||||
|
||||
type HomeV2FoldBottomFadeProps = {
|
||||
listingId?: string;
|
||||
};
|
||||
|
||||
/** Fixed viewport bottom fade while the home listing section is still in scroll range. */
|
||||
export function HomeV2FoldBottomFade({ listingId = "home-v2-listing" }: HomeV2FoldBottomFadeProps) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const listing = document.getElementById(listingId);
|
||||
if (!listing) return undefined;
|
||||
|
||||
const update = () => {
|
||||
const { bottom } = listing.getBoundingClientRect();
|
||||
setVisible(shouldShowHomeV2FoldBottomFade(bottom, readHomeViewportHeight()));
|
||||
};
|
||||
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(listing);
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
const viewport = window.visualViewport;
|
||||
viewport?.addEventListener("resize", update);
|
||||
viewport?.addEventListener("scroll", update);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
viewport?.removeEventListener("resize", update);
|
||||
viewport?.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [listingId]);
|
||||
|
||||
return <div className={`home-v2-fold-fade${visible ? "" : " is-hidden"}`} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Layers, Package } from "lucide-react";
|
||||
import { PLUGIN_CATEGORIES, SKILL_CATEGORIES } from "./categories";
|
||||
import { getCategoryIconComponent } from "./categoryIcons";
|
||||
|
||||
type BrowseCategoryIconProps = {
|
||||
slug: string | null;
|
||||
icon?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function BrowseCategoryIcon({ slug, icon, size = 16, className }: BrowseCategoryIconProps) {
|
||||
if (!slug) {
|
||||
return <Layers size={size} className={className} aria-hidden="true" />;
|
||||
}
|
||||
const iconKey =
|
||||
icon ??
|
||||
[...SKILL_CATEGORIES, ...PLUGIN_CATEGORIES].find((category) => category.slug === slug)?.icon;
|
||||
const Icon = getCategoryIconComponent(iconKey) ?? Package;
|
||||
return <Icon size={size} className={className} aria-hidden="true" />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BookOpen,
|
||||
Brain,
|
||||
Database,
|
||||
GitBranch,
|
||||
Globe,
|
||||
ListChecks,
|
||||
MessageCircle,
|
||||
@@ -30,6 +31,7 @@ const CATEGORY_ICONS = {
|
||||
"book-open": BookOpen,
|
||||
brain: Brain,
|
||||
database: Database,
|
||||
"git-branch": GitBranch,
|
||||
globe: Globe,
|
||||
"list-checks": ListChecks,
|
||||
"message-circle": MessageCircle,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { fetchPluginCatalog } from "./packageApi";
|
||||
|
||||
export async function fetchFeaturedPlugins(limit: number = 50) {
|
||||
const result = await fetchPluginCatalog({ featured: true, limit });
|
||||
return result.items;
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/** Curated shortcuts for the home apps constellation (design-time). */
|
||||
|
||||
export type HomeSkillApp = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Skills browse search query. */
|
||||
browseQuery: string;
|
||||
/** Brand favicon via Google favicon helper (domain only). */
|
||||
iconDomain: string;
|
||||
};
|
||||
|
||||
export type HomePluginShortcut = {
|
||||
id: string;
|
||||
runtimeId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
packageName: string;
|
||||
/** Brand favicon via Google favicon helper (domain only). */
|
||||
iconDomain: string;
|
||||
};
|
||||
|
||||
/** Left orbit — skills for everyday tools. */
|
||||
export const HOME_SKILL_APPS: HomeSkillApp[] = [
|
||||
{
|
||||
id: "chrome",
|
||||
name: "Google Chrome",
|
||||
description: "Browse, scrape, and automate the web from your agent.",
|
||||
browseQuery: "chrome browser",
|
||||
iconDomain: "google.com",
|
||||
},
|
||||
{
|
||||
id: "vscode",
|
||||
name: "VS Code",
|
||||
description: "Edit repos, run tasks, and ship code from the editor.",
|
||||
browseQuery: "vscode",
|
||||
iconDomain: "code.visualstudio.com",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
description: "Review PRs, manage issues, and automate repo workflows.",
|
||||
browseQuery: "github",
|
||||
iconDomain: "github.com",
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
description: "Read pages, update databases, and draft docs in Notion.",
|
||||
browseQuery: "notion",
|
||||
iconDomain: "notion.so",
|
||||
},
|
||||
{
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
description: "Create issues, sync cycles, and keep product work moving.",
|
||||
browseQuery: "linear",
|
||||
iconDomain: "linear.app",
|
||||
},
|
||||
{
|
||||
id: "figma",
|
||||
name: "Figma",
|
||||
description: "Export assets, comment on files, and sync design context.",
|
||||
browseQuery: "figma",
|
||||
iconDomain: "figma.com",
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
description: "Pair with your editor and run agent workflows in Cursor.",
|
||||
browseQuery: "cursor",
|
||||
iconDomain: "cursor.com",
|
||||
},
|
||||
{
|
||||
id: "raycast",
|
||||
name: "Raycast",
|
||||
description: "Launch commands, scripts, and quick actions on macOS.",
|
||||
browseQuery: "raycast",
|
||||
iconDomain: "raycast.com",
|
||||
},
|
||||
{
|
||||
id: "aws",
|
||||
name: "AWS",
|
||||
description: "Operate cloud resources and deploy from agent playbooks.",
|
||||
browseQuery: "aws",
|
||||
iconDomain: "aws.amazon.com",
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Send messages, search conversations, and manage channels.",
|
||||
browseQuery: "slack",
|
||||
iconDomain: "slack.com",
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
description: "Work with messages, channels, reactions, and communities.",
|
||||
browseQuery: "discord",
|
||||
iconDomain: "discord.com",
|
||||
},
|
||||
{
|
||||
id: "obsidian",
|
||||
name: "Obsidian",
|
||||
description: "Manage Markdown vaults, notes, and knowledge workflows.",
|
||||
browseQuery: "obsidian",
|
||||
iconDomain: "obsidian.md",
|
||||
},
|
||||
{
|
||||
id: "trello",
|
||||
name: "Trello",
|
||||
description: "Manage boards, lists, cards, and project workflows.",
|
||||
browseQuery: "trello",
|
||||
iconDomain: "trello.com",
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
description: "Read, send, search, and organize email.",
|
||||
browseQuery: "gmail",
|
||||
iconDomain: "mail.google.com",
|
||||
},
|
||||
{
|
||||
id: "google-drive",
|
||||
name: "Google Drive",
|
||||
description: "Find, create, and manage files and folders.",
|
||||
browseQuery: "google drive",
|
||||
iconDomain: "drive.google.com",
|
||||
},
|
||||
{
|
||||
id: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
description: "Read, write, and automate spreadsheet data.",
|
||||
browseQuery: "google sheets",
|
||||
iconDomain: "sheets.google.com",
|
||||
},
|
||||
{
|
||||
id: "google-calendar",
|
||||
name: "Google Calendar",
|
||||
description: "Create events, check availability, and manage calendars.",
|
||||
browseQuery: "google calendar",
|
||||
iconDomain: "calendar.google.com",
|
||||
},
|
||||
{
|
||||
id: "jira",
|
||||
name: "Jira",
|
||||
description: "Search, create, update, and transition issues.",
|
||||
browseQuery: "jira",
|
||||
iconDomain: "atlassian.com",
|
||||
},
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
description: "Build bot workflows and automate conversations.",
|
||||
browseQuery: "telegram",
|
||||
iconDomain: "telegram.org",
|
||||
},
|
||||
{
|
||||
id: "airtable",
|
||||
name: "Airtable",
|
||||
description: "Manage bases, tables, records, and fields.",
|
||||
browseQuery: "airtable",
|
||||
iconDomain: "airtable.com",
|
||||
},
|
||||
{
|
||||
id: "dropbox",
|
||||
name: "Dropbox",
|
||||
description: "Browse, search, upload, and manage files.",
|
||||
browseQuery: "dropbox",
|
||||
iconDomain: "dropbox.com",
|
||||
},
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
description: "Operate containers, images, and Compose stacks.",
|
||||
browseQuery: "docker",
|
||||
iconDomain: "docker.com",
|
||||
},
|
||||
{
|
||||
id: "kubernetes",
|
||||
name: "Kubernetes",
|
||||
description: "Deploy, inspect, and troubleshoot clusters.",
|
||||
browseQuery: "kubernetes",
|
||||
iconDomain: "kubernetes.io",
|
||||
},
|
||||
{
|
||||
id: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Manage projects, merge requests, issues, and pipelines.",
|
||||
browseQuery: "gitlab",
|
||||
iconDomain: "gitlab.com",
|
||||
},
|
||||
{
|
||||
id: "salesforce",
|
||||
name: "Salesforce",
|
||||
description: "Query CRM data and manage sales workflows.",
|
||||
browseQuery: "salesforce",
|
||||
iconDomain: "salesforce.com",
|
||||
},
|
||||
{
|
||||
id: "hubspot",
|
||||
name: "HubSpot",
|
||||
description: "Work with contacts, companies, deals, and pipelines.",
|
||||
browseQuery: "hubspot",
|
||||
iconDomain: "hubspot.com",
|
||||
},
|
||||
];
|
||||
|
||||
/** Right orbit — official @openclaw gateway plugins. */
|
||||
export const HOME_PLUGIN_SHORTCUTS: HomePluginShortcut[] = [
|
||||
{
|
||||
id: "whatsapp",
|
||||
runtimeId: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
description: "WhatsApp Web channel plugin for agent chats.",
|
||||
packageName: "@openclaw/whatsapp",
|
||||
iconDomain: "whatsapp.com",
|
||||
},
|
||||
{
|
||||
id: "qqbot",
|
||||
runtimeId: "qqbot",
|
||||
name: "QQ Bot",
|
||||
description: "Group and direct-message workflows for QQ.",
|
||||
packageName: "@openclaw/qqbot",
|
||||
iconDomain: "qq.com",
|
||||
},
|
||||
{
|
||||
id: "matrix",
|
||||
runtimeId: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Rooms and direct messages on Matrix.",
|
||||
packageName: "@openclaw/matrix",
|
||||
iconDomain: "matrix.org",
|
||||
},
|
||||
{
|
||||
id: "nextcloud-talk",
|
||||
runtimeId: "nextcloud-talk",
|
||||
name: "Nextcloud Talk",
|
||||
description: "Self-hosted team conversations and calls.",
|
||||
packageName: "@openclaw/nextcloud-talk",
|
||||
iconDomain: "nextcloud.com",
|
||||
},
|
||||
{
|
||||
id: "voice-call",
|
||||
runtimeId: "voice-call",
|
||||
name: "Voice Call",
|
||||
description: "Phone-call workflows through Twilio, Telnyx, and Plivo.",
|
||||
packageName: "@openclaw/voice-call",
|
||||
iconDomain: "twilio.com",
|
||||
},
|
||||
{
|
||||
id: "line",
|
||||
runtimeId: "line",
|
||||
name: "LINE",
|
||||
description: "LINE Bot API chats from OpenClaw.",
|
||||
packageName: "@openclaw/line",
|
||||
iconDomain: "line.me",
|
||||
},
|
||||
{
|
||||
id: "twitch",
|
||||
runtimeId: "twitch",
|
||||
name: "Twitch",
|
||||
description: "Chat and moderation workflows for streams.",
|
||||
packageName: "@openclaw/twitch",
|
||||
iconDomain: "twitch.tv",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
runtimeId: "codex",
|
||||
name: "Codex",
|
||||
description: "Codex app-server harness and model provider.",
|
||||
packageName: "@openclaw/codex",
|
||||
iconDomain: "openai.com",
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
runtimeId: "discord",
|
||||
name: "Discord",
|
||||
description: "Channels, DMs, commands, and app events.",
|
||||
packageName: "@openclaw/discord",
|
||||
iconDomain: "discord.com",
|
||||
},
|
||||
{
|
||||
id: "feishu",
|
||||
runtimeId: "feishu",
|
||||
name: "Feishu/Lark",
|
||||
description: "Workplace chats and collaboration tools.",
|
||||
packageName: "@openclaw/feishu",
|
||||
iconDomain: "feishu.cn",
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
runtimeId: "slack",
|
||||
name: "Slack",
|
||||
description: "Channels, DMs, commands, and app events.",
|
||||
packageName: "@openclaw/slack",
|
||||
iconDomain: "slack.com",
|
||||
},
|
||||
{
|
||||
id: "msteams",
|
||||
runtimeId: "msteams",
|
||||
name: "Microsoft Teams",
|
||||
description: "Meetings and team chat for agents.",
|
||||
packageName: "@openclaw/msteams",
|
||||
iconDomain: "teams.microsoft.com",
|
||||
},
|
||||
{
|
||||
id: "brave",
|
||||
runtimeId: "brave",
|
||||
name: "Brave Search",
|
||||
description: "Brave Search provider for web lookup.",
|
||||
packageName: "@openclaw/brave-plugin",
|
||||
iconDomain: "brave.com",
|
||||
},
|
||||
{
|
||||
id: "googlechat",
|
||||
runtimeId: "googlechat",
|
||||
name: "Google Chat",
|
||||
description: "Spaces and direct messages on Google Chat.",
|
||||
packageName: "@openclaw/googlechat",
|
||||
iconDomain: "chat.google.com",
|
||||
},
|
||||
{
|
||||
id: "google-meet",
|
||||
runtimeId: "google-meet",
|
||||
name: "Google Meet",
|
||||
description: "Join calls through Chrome or phone transports.",
|
||||
packageName: "@openclaw/google-meet",
|
||||
iconDomain: "meet.google.com",
|
||||
},
|
||||
{
|
||||
id: "parallel",
|
||||
runtimeId: "parallel-plugin",
|
||||
name: "Parallel",
|
||||
description: "Parallel web search for research workflows.",
|
||||
packageName: "@openclaw/parallel-plugin",
|
||||
iconDomain: "parallel.ai",
|
||||
},
|
||||
{
|
||||
id: "perplexity",
|
||||
runtimeId: "perplexity-plugin",
|
||||
name: "Perplexity",
|
||||
description: "Perplexity-powered web answers.",
|
||||
packageName: "@openclaw/perplexity-plugin",
|
||||
iconDomain: "perplexity.ai",
|
||||
},
|
||||
{
|
||||
id: "exa",
|
||||
runtimeId: "exa-plugin",
|
||||
name: "Exa",
|
||||
description: "Neural web search for agent research.",
|
||||
packageName: "@openclaw/exa-plugin",
|
||||
iconDomain: "exa.ai",
|
||||
},
|
||||
{
|
||||
id: "firecrawl",
|
||||
runtimeId: "firecrawl-plugin",
|
||||
name: "Firecrawl",
|
||||
description: "Crawl and extract web pages for agents.",
|
||||
packageName: "@openclaw/firecrawl-plugin",
|
||||
iconDomain: "firecrawl.dev",
|
||||
},
|
||||
{
|
||||
id: "scraperapi",
|
||||
runtimeId: "scraperapi-skills",
|
||||
name: "ScraperAPI",
|
||||
description: "ScraperAPI skills for large-scale extraction.",
|
||||
packageName: "@scraperapitech/scraperapi-skills",
|
||||
iconDomain: "scraperapi.com",
|
||||
},
|
||||
{
|
||||
id: "diagnostics-prometheus",
|
||||
runtimeId: "diagnostics-prometheus",
|
||||
name: "Prometheus",
|
||||
description: "Runtime metrics for observability dashboards.",
|
||||
packageName: "@openclaw/diagnostics-prometheus",
|
||||
iconDomain: "prometheus.io",
|
||||
},
|
||||
{
|
||||
id: "amazon-bedrock",
|
||||
runtimeId: "amazon-bedrock-provider",
|
||||
name: "Amazon Bedrock",
|
||||
description: "Bedrock models, embeddings, and guardrails.",
|
||||
packageName: "@openclaw/amazon-bedrock-provider",
|
||||
iconDomain: "aws.amazon.com",
|
||||
},
|
||||
{
|
||||
id: "cloudflare-gateway",
|
||||
runtimeId: "cloudflare-ai-gateway-provider",
|
||||
name: "Cloudflare AI Gateway",
|
||||
description: "Model routing through Cloudflare AI Gateway.",
|
||||
packageName: "@openclaw/cloudflare-ai-gateway-provider",
|
||||
iconDomain: "cloudflare.com",
|
||||
},
|
||||
{
|
||||
id: "groq",
|
||||
runtimeId: "groq-provider",
|
||||
name: "Groq",
|
||||
description: "Groq media-understanding provider.",
|
||||
packageName: "@openclaw/groq-provider",
|
||||
iconDomain: "groq.com",
|
||||
},
|
||||
{
|
||||
id: "deepinfra",
|
||||
runtimeId: "deepinfra-provider",
|
||||
name: "DeepInfra",
|
||||
description: "DeepInfra model provider for OpenClaw.",
|
||||
packageName: "@openclaw/deepinfra-provider",
|
||||
iconDomain: "deepinfra.com",
|
||||
},
|
||||
{
|
||||
id: "cerebras",
|
||||
runtimeId: "cerebras-provider",
|
||||
name: "Cerebras",
|
||||
description: "Cerebras model provider for OpenClaw.",
|
||||
packageName: "@openclaw/cerebras-provider",
|
||||
iconDomain: "cerebras.ai",
|
||||
},
|
||||
{
|
||||
id: "qwen",
|
||||
runtimeId: "qwen-provider",
|
||||
name: "Qwen Cloud",
|
||||
description: "Qwen Cloud provider for OpenClaw.",
|
||||
packageName: "@openclaw/qwen-provider",
|
||||
iconDomain: "qwen.ai",
|
||||
},
|
||||
{
|
||||
id: "llama-cpp",
|
||||
runtimeId: "llama-cpp-provider",
|
||||
name: "llama.cpp",
|
||||
description: "Local embedding provider through llama.cpp.",
|
||||
packageName: "@openclaw/llama-cpp-provider",
|
||||
iconDomain: "github.com",
|
||||
},
|
||||
{
|
||||
id: "apple-pim",
|
||||
runtimeId: "apple-pim",
|
||||
name: "Apple PIM",
|
||||
description: "Calendar, Reminders, Contacts, and Mail on macOS.",
|
||||
packageName: "apple-pim-cli",
|
||||
iconDomain: "apple.com",
|
||||
},
|
||||
{
|
||||
id: "gmail-plugin",
|
||||
runtimeId: "gmail",
|
||||
name: "Gmail",
|
||||
description: "Search mailboxes, threads, and attachments.",
|
||||
packageName: "@manuelfedele/openclaw-gmail-plugin",
|
||||
iconDomain: "mail.google.com",
|
||||
},
|
||||
];
|
||||
|
||||
export function homeAppIconUrl(iconDomain: string) {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(iconDomain)}&sz=128`;
|
||||
}
|
||||
|
||||
export function homePluginShortcutIconUrl(shortcut: HomePluginShortcut) {
|
||||
return homeAppIconUrl(shortcut.iconDomain);
|
||||
}
|
||||
|
||||
export const SKILLS_BROWSE_SEARCH = {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
} as const;
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Show the fixed fold fade while the listing still extends below the viewport bottom. */
|
||||
export function shouldShowHomeV2FoldBottomFade(
|
||||
listingBottom: number,
|
||||
viewportHeight: number,
|
||||
): boolean {
|
||||
return listingBottom > viewportHeight;
|
||||
}
|
||||
|
||||
export function readHomeViewportHeight(): number {
|
||||
return window.visualViewport?.height ?? window.innerHeight;
|
||||
}
|
||||
+224
-10
@@ -80,6 +80,13 @@ export const SECONDARY_NAV_ITEMS: NavItem[] = [
|
||||
// Footer sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OPENCLAW_SITE_URL = "https://openclaw.ai";
|
||||
export const OPENCLAW_ECOSYSTEM_URL = `${OPENCLAW_SITE_URL}/ecosystem`;
|
||||
const OPENCLAW_BLOG_CLAWHUB_URL = `${OPENCLAW_SITE_URL}/blog#clawhub`;
|
||||
export const OPENCLAW_CLAWHUB_DOCS_URL = "https://docs.openclaw.ai/clawhub/";
|
||||
/** Compact mark for stack avatars (not the full wordmark). */
|
||||
export const OPENCLAW_LOGO_URL = `${OPENCLAW_SITE_URL}/favicon.svg`;
|
||||
|
||||
interface FooterNavSection {
|
||||
title: string;
|
||||
items: FooterNavItem[];
|
||||
@@ -91,8 +98,16 @@ type FooterNavItem =
|
||||
label: string;
|
||||
to: string;
|
||||
search?: Record<string, unknown>;
|
||||
featureFlag?: boolean;
|
||||
}
|
||||
| { kind: "external"; label: string; href: string };
|
||||
| {
|
||||
kind: "external";
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: "github" | "discord";
|
||||
featureFlag?: boolean;
|
||||
}
|
||||
| { kind: "text"; label: string; featureFlag?: boolean };
|
||||
|
||||
export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
|
||||
{
|
||||
@@ -100,6 +115,7 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
|
||||
items: [
|
||||
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
|
||||
{ kind: "link", label: "Plugins", to: "/plugins" },
|
||||
{ kind: "link", label: "Publishers", to: "/publishers" },
|
||||
{ kind: "link", label: "Audits", to: "/audits", search: { type: undefined } },
|
||||
],
|
||||
},
|
||||
@@ -125,20 +141,218 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
|
||||
sourceRepo: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "link",
|
||||
label: "Create org",
|
||||
to: "/settings",
|
||||
search: { view: "organizations" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Ecosystem",
|
||||
items: [
|
||||
{ kind: "external", label: "Overview", href: OPENCLAW_ECOSYSTEM_URL },
|
||||
{ kind: "external", label: "OpenClaw", href: OPENCLAW_SITE_URL },
|
||||
{ kind: "external", label: "Docs", href: "https://docs.openclaw.ai/" },
|
||||
{ kind: "external", label: "Blog", href: OPENCLAW_BLOG_CLAWHUB_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
items: [
|
||||
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
|
||||
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Platform",
|
||||
items: [
|
||||
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
|
||||
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
|
||||
{
|
||||
kind: "external",
|
||||
label: "GitHub",
|
||||
href: "https://github.com/openclaw/clawhub",
|
||||
icon: "github",
|
||||
},
|
||||
{
|
||||
kind: "external",
|
||||
label: "Discord",
|
||||
href: "https://discord.gg/clawd",
|
||||
icon: "discord",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const FOOTER_PLATFORM_LINKS = [
|
||||
{ label: "Deployed on Vercel", href: "https://vercel.com" },
|
||||
{ label: "Powered by Convex", href: "https://www.convex.dev" },
|
||||
] as const;
|
||||
|
||||
export type FooterEcosystemProject = {
|
||||
label: string;
|
||||
href: string;
|
||||
blurb: string;
|
||||
/** Logo URL from https://openclaw.ai/ecosystem assets. */
|
||||
logoUrl: string;
|
||||
internal?: boolean;
|
||||
};
|
||||
|
||||
/** Build a URL for logos/banners published on the OpenClaw ecosystem page. */
|
||||
function openclawEcosystemAsset(path: string) {
|
||||
return `${OPENCLAW_SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** Curated highlights from https://openclaw.ai/ecosystem */
|
||||
export const FOOTER_ECOSYSTEM_PROJECTS: FooterEcosystemProject[] = [
|
||||
{
|
||||
label: "ClawHub",
|
||||
href: "/",
|
||||
blurb: "Skills & plugins",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/clawhub.png"),
|
||||
internal: true,
|
||||
},
|
||||
{
|
||||
label: "Lobster",
|
||||
href: "https://docs.openclaw.ai/tools/lobster",
|
||||
blurb: "Workflow shell",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/lobster.png"),
|
||||
},
|
||||
{
|
||||
label: "Crabbox",
|
||||
href: "https://crabbox.sh",
|
||||
blurb: "Agent sandboxes",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/crabbox.svg"),
|
||||
},
|
||||
{
|
||||
label: "ClickClack",
|
||||
href: "https://clickclack.chat",
|
||||
blurb: "Chat for claws",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/clickclack.svg"),
|
||||
},
|
||||
{
|
||||
label: "Crabfleet",
|
||||
href: "https://crabfleet.ai",
|
||||
blurb: "Fleet control",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/crabfleet.png"),
|
||||
},
|
||||
{
|
||||
label: "Octopool",
|
||||
href: "https://octopool.dev",
|
||||
blurb: "GitHub relay",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/octopool.svg"),
|
||||
},
|
||||
{
|
||||
label: "ClawSweeper",
|
||||
href: "https://clawsweeper.bot",
|
||||
blurb: "Issue triage",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/clawsweeper.svg"),
|
||||
},
|
||||
{
|
||||
label: "agent-skills",
|
||||
href: "https://github.com/openclaw/agent-skills",
|
||||
blurb: "Shared skills",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/agent-skills.png"),
|
||||
},
|
||||
{
|
||||
label: "discrawl",
|
||||
href: "https://github.com/openclaw/discrawl",
|
||||
blurb: "Discord archive",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/discrawl.png"),
|
||||
},
|
||||
{
|
||||
label: "gitcrawl",
|
||||
href: "https://github.com/openclaw/gitcrawl",
|
||||
blurb: "GitHub crawler",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/gitcrawl.png"),
|
||||
},
|
||||
{
|
||||
label: "slacrawl",
|
||||
href: "https://github.com/openclaw/slacrawl",
|
||||
blurb: "Slack archive",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/slacrawl.png"),
|
||||
},
|
||||
{
|
||||
label: "notcrawl",
|
||||
href: "https://github.com/openclaw/notcrawl",
|
||||
blurb: "Notion archive",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/notcrawl.png"),
|
||||
},
|
||||
{
|
||||
label: "telecrawl",
|
||||
href: "https://github.com/openclaw/telecrawl",
|
||||
blurb: "Telegram archive",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/telecrawl.png"),
|
||||
},
|
||||
{
|
||||
label: "graincrawl",
|
||||
href: "https://github.com/openclaw/graincrawl",
|
||||
blurb: "Granola notes",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/graincrawl.png"),
|
||||
},
|
||||
{
|
||||
label: "crawlkit",
|
||||
href: "https://github.com/openclaw/crawlkit",
|
||||
blurb: "Crawler toolkit",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/crawlkit.png"),
|
||||
},
|
||||
{
|
||||
label: "crawlbar",
|
||||
href: "https://github.com/openclaw/crawlbar",
|
||||
blurb: "Crawl menu bar",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/crawlbar.png"),
|
||||
},
|
||||
{
|
||||
label: "acpx",
|
||||
href: "https://github.com/openclaw/acpx",
|
||||
blurb: "ACP sessions",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/acpx.png"),
|
||||
},
|
||||
{
|
||||
label: "mcporter",
|
||||
href: "https://github.com/openclaw/mcporter",
|
||||
blurb: "MCP tooling",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/mcporter.png"),
|
||||
},
|
||||
{
|
||||
label: "Tachikoma",
|
||||
href: "https://github.com/openclaw/Tachikoma",
|
||||
blurb: "Swift model SDK",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/tachikoma.png"),
|
||||
},
|
||||
{
|
||||
label: "clawpatch",
|
||||
href: "https://github.com/openclaw/clawpatch",
|
||||
blurb: "Review & patch",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/clawpatch.svg"),
|
||||
},
|
||||
{
|
||||
label: "clawbench",
|
||||
href: "https://github.com/openclaw/clawbench",
|
||||
blurb: "Agent benchmark",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/clawbench.png"),
|
||||
},
|
||||
{
|
||||
label: "Peekaboo",
|
||||
href: "https://github.com/openclaw/Peekaboo",
|
||||
blurb: "macOS capture",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/logos/peekaboo.png"),
|
||||
},
|
||||
{
|
||||
label: "cookbook",
|
||||
href: "https://github.com/openclaw/cookbook",
|
||||
blurb: "SDK examples",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/cookbook.png"),
|
||||
},
|
||||
{
|
||||
label: "plugin-inspector",
|
||||
href: "https://github.com/openclaw/plugin-inspector",
|
||||
blurb: "Plugin testing",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/plugin-inspector.png"),
|
||||
},
|
||||
{
|
||||
label: "wacrawl",
|
||||
href: `${OPENCLAW_ECOSYSTEM_URL}#wacrawl`,
|
||||
blurb: "WhatsApp archive",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/wacrawl.png"),
|
||||
},
|
||||
{
|
||||
label: "crabpot",
|
||||
href: "https://github.com/openclaw/crabpot",
|
||||
blurb: "Plugin testbed",
|
||||
logoUrl: openclawEcosystemAsset("/ecosystem/banners/crabpot.svg"),
|
||||
},
|
||||
];
|
||||
|
||||
+15
-414
@@ -1,21 +1,10 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Download,
|
||||
Package,
|
||||
Search,
|
||||
Star,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchFeaturedPlugins } from "../lib/featuredCatalog";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { HomeAppsSection } from "../components/HomeAppsSection";
|
||||
import { HomeBringSkillsSection } from "../components/HomeBringSkillsSection";
|
||||
import { HomeListingSection } from "../components/HomeListingSection";
|
||||
import { HomePopularPublishersSection } from "../components/HomePopularPublishersSection";
|
||||
import { HomeV2FoldBottomFade } from "../components/HomeV2FoldBottomFade";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: SkillsHome,
|
||||
@@ -39,78 +28,6 @@ const SLOT_WORDS = [
|
||||
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
|
||||
|
||||
function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
ownerHandle?: string | null;
|
||||
owner?: PublicUser | null;
|
||||
latestVersion?: unknown;
|
||||
};
|
||||
|
||||
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
|
||||
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
|
||||
const [featuredPlugins, setFeaturedPlugins] = useState<PackageListItem[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
convexHttp
|
||||
.query(api.skills.listHighlightedPublic, { limit: 6 })
|
||||
.then((r) => {
|
||||
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
|
||||
})
|
||||
.catch(() => {});
|
||||
convexHttp
|
||||
.query(api.skills.listPublicPageV4, {
|
||||
numItems: 6,
|
||||
dir: "desc",
|
||||
})
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
const page = Array.isArray(r) ? [] : ((r as { page?: SkillPageEntry[] }).page ?? []);
|
||||
setPopular(page);
|
||||
})
|
||||
.catch(() => {});
|
||||
fetchFeaturedPlugins(6)
|
||||
.then((items) => {
|
||||
if (!cancelled) setFeaturedPlugins(items);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const trimmedQuery = useMemo(() => query.trim(), [query]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: trimmedQuery || undefined },
|
||||
});
|
||||
};
|
||||
|
||||
// Format stat numbers
|
||||
const formatStat = (n: number | undefined): string => {
|
||||
if (!n) return "0";
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
};
|
||||
|
||||
// Build skill detail link
|
||||
const skillLink = (entry: SkillPageEntry) =>
|
||||
`/${encodeURIComponent(entry.ownerHandle || entry.owner?.handle || entry.skill.ownerUserId)}/${entry.skill.slug}`;
|
||||
|
||||
// Build carousel cards from highlighted data, then fall back to the public skill feed.
|
||||
const highlightedCarouselCards = highlighted.slice(0, 6);
|
||||
const fallbackCarouselCards = popular.slice(0, 6);
|
||||
const carouselCards =
|
||||
highlightedCarouselCards.length > 0 ? highlightedCarouselCards : fallbackCarouselCards;
|
||||
const carouselUsesHighlighted = highlightedCarouselCards.length > 0;
|
||||
const trendingCards = popular.slice(0, 6);
|
||||
|
||||
const clickTimesRef = useRef<number[]>([]);
|
||||
const [slotState, setSlotState] = useState<
|
||||
| null
|
||||
@@ -123,21 +40,6 @@ function SkillsHome() {
|
||||
const confettiRef = useRef<HTMLCanvasElement>(null);
|
||||
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cooldownUntilRef = useRef<number>(0);
|
||||
const carouselWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollCarousel = (direction: -1 | 1) => {
|
||||
const carousel = carouselWrapRef.current;
|
||||
if (!carousel) return;
|
||||
|
||||
const firstCard = carousel.querySelector<HTMLElement>(".home-v2-c-card");
|
||||
const scrollAmount = (firstCard?.offsetWidth ?? 320) + 16;
|
||||
if (typeof carousel.scrollBy === "function") {
|
||||
carousel.scrollBy({ left: direction * scrollAmount, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
carousel.scrollLeft += direction * scrollAmount;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -392,6 +294,7 @@ function SkillsHome() {
|
||||
|
||||
return (
|
||||
<main className="home-v2-main">
|
||||
<HomeV2FoldBottomFade />
|
||||
<canvas ref={confettiRef} className="home-v2-confetti" style={{ display: "none" }} />
|
||||
|
||||
{/* ═══ HERO ═══ */}
|
||||
@@ -409,7 +312,7 @@ function SkillsHome() {
|
||||
type="button"
|
||||
onClick={handleLabelClick}
|
||||
>
|
||||
BUILT BY THE COMMUNITY.
|
||||
BUILT BY THE COMMUNITY
|
||||
</button>
|
||||
|
||||
{slotState ? (
|
||||
@@ -424,7 +327,7 @@ function SkillsHome() {
|
||||
>
|
||||
{slotState.phase === "stopped" && slotState.isHackJackpot ? (
|
||||
<img
|
||||
src="/clawd-mark.png"
|
||||
src="/og-clawhub-watermark.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="home-v2-hack-lobster"
|
||||
@@ -458,315 +361,13 @@ function SkillsHome() {
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
|
||||
|
||||
<div className="home-v2-search-container">
|
||||
<form className="home-v2-search-bar" onSubmit={handleSearch}>
|
||||
<Search className="home-v2-search-icon" size={20} />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="What are you looking for?"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="home-v2-search-go" aria-label="Search">
|
||||
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p className="home-v2-sub">Discover skills and plugins from top creators</p>
|
||||
</section>
|
||||
|
||||
{/* ═══ FEATURED CAROUSEL ═══ */}
|
||||
{carouselCards.length > 0 && (
|
||||
<section
|
||||
className="home-v2-carousel-section"
|
||||
data-source={carouselUsesHighlighted ? "highlighted" : "popular"}
|
||||
>
|
||||
<div className="home-v2-carousel-header">
|
||||
<h2>Featured skills</h2>
|
||||
<div className="home-v2-carousel-controls">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={
|
||||
carouselUsesHighlighted
|
||||
? {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
featured: true,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}
|
||||
}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-carousel-btn"
|
||||
aria-label="Previous"
|
||||
onClick={() => scrollCarousel(-1)}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-carousel-btn"
|
||||
aria-label="Next"
|
||||
onClick={() => scrollCarousel(1)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-v2-carousel-wrap" ref={carouselWrapRef}>
|
||||
<div className="home-v2-carousel-track">
|
||||
{/* First pass */}
|
||||
{carouselCards.map((entry) => (
|
||||
<Link
|
||||
key={`c1-${entry.skill._id}`}
|
||||
to={skillLink(entry)}
|
||||
className="home-v2-c-card"
|
||||
>
|
||||
<div className="home-v2-c-head">
|
||||
<div className="home-v2-c-meta">
|
||||
<div className="home-v2-c-name">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
</div>
|
||||
<div className="home-v2-c-by">
|
||||
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="home-v2-c-tag">Skill</span>
|
||||
<div className="home-v2-c-desc">
|
||||
{entry.skill.summary || "A fresh skill bundle."}
|
||||
</div>
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
<Download size={13} /> Install
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{/* Duplicate for seamless loop */}
|
||||
{carouselCards.map((entry) => (
|
||||
<Link
|
||||
key={`c2-${entry.skill._id}`}
|
||||
to={skillLink(entry)}
|
||||
className="home-v2-c-card"
|
||||
>
|
||||
<div className="home-v2-c-head">
|
||||
<div className="home-v2-c-meta">
|
||||
<div className="home-v2-c-name">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
</div>
|
||||
<div className="home-v2-c-by">
|
||||
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="home-v2-c-tag">Skill</span>
|
||||
<div className="home-v2-c-desc">
|
||||
{entry.skill.summary || "A fresh skill bundle."}
|
||||
</div>
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
<Download size={13} /> Install
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ═══ CATEGORIES ═══ */}
|
||||
<section className="home-v2-categories">
|
||||
<div className="home-v2-categories-grid">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-v2-cat-item"
|
||||
>
|
||||
<div className="home-v2-cat-icon">
|
||||
<Package size={20} />
|
||||
</div>
|
||||
<div className="home-v2-cat-text">
|
||||
<div className="home-v2-cat-name">Skills</div>
|
||||
<div className="home-v2-cat-desc">Agent skill bundles</div>
|
||||
</div>
|
||||
<span className="home-v2-cat-arrow">
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
</Link>
|
||||
<Link to="/plugins" className="home-v2-cat-item">
|
||||
<div className="home-v2-cat-icon">
|
||||
<Code2 size={20} />
|
||||
</div>
|
||||
<div className="home-v2-cat-text">
|
||||
<div className="home-v2-cat-name">Plugins</div>
|
||||
<div className="home-v2-cat-desc">Gateway plugins</div>
|
||||
</div>
|
||||
<span className="home-v2-cat-arrow">
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
</Link>
|
||||
<Link to="/publishers" className="home-v2-cat-item">
|
||||
<div className="home-v2-cat-icon">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="home-v2-cat-text">
|
||||
<div className="home-v2-cat-name">Publishers</div>
|
||||
<div className="home-v2-cat-desc">People and organizations</div>
|
||||
</div>
|
||||
<span className="home-v2-cat-arrow">
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ TRENDING ═══ */}
|
||||
{trendingCards.length > 0 && (
|
||||
<section className="home-v2-trending-section">
|
||||
<div className="home-v2-section-header">
|
||||
<h2>Trending Now</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-v2-trending-grid">
|
||||
{trendingCards.map((entry) => (
|
||||
<Link key={entry.skill._id} to={skillLink(entry)} className="home-v2-trend-card">
|
||||
<div className="home-v2-trend-head">
|
||||
<div className="home-v2-trend-title">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
</div>
|
||||
<div className="home-v2-trend-creator">
|
||||
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-v2-trend-desc">
|
||||
{entry.skill.summary || "Agent-ready skill pack."}
|
||||
</div>
|
||||
<div className="home-v2-trend-bottom">
|
||||
<div className="home-v2-trend-signals">
|
||||
<span>
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-trend-install">
|
||||
<Download size={13} /> Install
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ═══ FEATURED PLUGINS ═══ */}
|
||||
{featuredPlugins.length > 0 && (
|
||||
<section className="home-v2-trending-section">
|
||||
<div className="home-v2-section-header">
|
||||
<h2>Featured plugins</h2>
|
||||
<Link
|
||||
to="/plugins"
|
||||
search={{
|
||||
q: undefined,
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
featured: true,
|
||||
official: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-v2-trending-grid">
|
||||
{featuredPlugins.slice(0, 6).map((plugin) => (
|
||||
<Link
|
||||
key={plugin.name}
|
||||
to="/plugins/$name"
|
||||
params={{ name: plugin.name }}
|
||||
className="home-v2-trend-card"
|
||||
>
|
||||
<div className="home-v2-trend-head">
|
||||
<div className="home-v2-trend-title">{plugin.displayName || plugin.name}</div>
|
||||
<div className="home-v2-trend-creator">
|
||||
{plugin.ownerHandle ? `by @${plugin.ownerHandle}` : "community plugin"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-v2-trend-desc">
|
||||
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
|
||||
</div>
|
||||
<div className="home-v2-trend-bottom">
|
||||
<div className="home-v2-trend-signals">
|
||||
{plugin.isOfficial ? <span>Official</span> : null}
|
||||
{plugin.latestVersion ? <span>v{plugin.latestVersion}</span> : null}
|
||||
</div>
|
||||
<span className="home-v2-trend-install">
|
||||
<Download size={13} /> Install
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<HomeListingSection />
|
||||
<HomePopularPublishersSection />
|
||||
<HomeAppsSection />
|
||||
<HomeBringSkillsSection />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+6884
-69
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user