mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
refactor: simplify homepage catalog controls (#3364)
This commit is contained in:
@@ -122,8 +122,7 @@ describe("HomeListingSection", () => {
|
||||
expect(screen.queryByText("8K")).toBeNull();
|
||||
expect(screen.queryByText("skills.sh")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Grid view" }));
|
||||
expect(screen.getAllByLabelText("24-hour downloads")).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides unavailable Trending and falls back to the Featured feed", async () => {
|
||||
@@ -222,7 +221,7 @@ describe("HomeListingSection", () => {
|
||||
expect.objectContaining({ sort: "newest", createdAfter: expect.any(Number) }),
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole("combobox", { name: "Category" })).toBeTruthy();
|
||||
expect(screen.queryByRole("combobox", { name: "Category" })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Featured" }));
|
||||
await waitFor(() => {
|
||||
@@ -271,42 +270,6 @@ describe("HomeListingSection", () => {
|
||||
expect(screen.getByTitle("First Skill")).toBeTruthy();
|
||||
expect(screen.queryByText("24-hour Trending unavailable")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps search as a separate relevance-first interaction", async () => {
|
||||
convexActionMock.mockResolvedValue([
|
||||
{
|
||||
skill: makeNativeSkill("search-hit", "Search Hit"),
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
]);
|
||||
render(<HomeListingSection initialListing={initialTrending([])} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: "Search skills" }), {
|
||||
target: { value: "search" },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Search Hit")).toBeTruthy();
|
||||
expect(convexActionMock).toHaveBeenCalledWith(
|
||||
"search:searchNativeSkills",
|
||||
expect.objectContaining({ query: "search" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports list and grid presentation without changing feed order", () => {
|
||||
const first = makeTrending("first", "First Skill", 17, 9000);
|
||||
const second = makeTrending("second", "Second Skill", 3, 8000);
|
||||
render(<HomeListingSection initialListing={initialTrending([first, second])} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Grid view" }));
|
||||
expect(document.querySelector(".home-v2-listing-grid")).toBeTruthy();
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll(".home-v2-listing-card-name"),
|
||||
(node) => node.textContent,
|
||||
),
|
||||
).toEqual(["First Skill", "Second Skill"]);
|
||||
});
|
||||
});
|
||||
|
||||
function initialTrending(
|
||||
@@ -372,16 +335,3 @@ function makeTrending(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeNativeSkill(slug: string, displayName: string) {
|
||||
return {
|
||||
_id: `skills:${slug}`,
|
||||
slug,
|
||||
displayName,
|
||||
summary: `${displayName} summary`,
|
||||
stats: { comments: 0, downloads: 0, installs: 0, stars: 0, versions: 1 },
|
||||
tags: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,12 +137,15 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a provided Featured plugin listing as a list", async () => {
|
||||
it("renders listing tabs left, content tabs right, and only the list presentation", async () => {
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
|
||||
const contentTypeButtons = screen
|
||||
.getByRole("group", { name: "Content type" })
|
||||
.querySelectorAll("button");
|
||||
const toolbar = document.querySelector(".home-v2-listing-toolbar");
|
||||
const sortTabs = screen.getByRole("tablist", { name: "Sort" });
|
||||
const contentType = screen.getByRole("group", { name: "Content type" });
|
||||
const contentTypeButtons = contentType.querySelectorAll("button");
|
||||
expect(toolbar?.firstElementChild?.contains(sortTabs)).toBe(true);
|
||||
expect(toolbar?.lastElementChild).toBe(contentType);
|
||||
expect(Array.from(contentTypeButtons, (button) => button.textContent)).toEqual([
|
||||
"Skills",
|
||||
"Plugins",
|
||||
@@ -153,14 +156,15 @@ describe("HomeListingSection", () => {
|
||||
expect(screen.getByRole("tab", { name: "Featured" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "List view" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
|
||||
"Featured",
|
||||
"Official",
|
||||
"New",
|
||||
]);
|
||||
expect(screen.queryByRole("button", { name: "Search catalog" })).toBeNull();
|
||||
expect(screen.queryByRole("combobox", { name: "Category" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "List view" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-listing-list")).toBeTruthy();
|
||||
expect(document.querySelector(".marketplace-icon-image")?.getAttribute("src")).toBe(
|
||||
@@ -263,148 +267,6 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
]);
|
||||
|
||||
renderSkillsListing();
|
||||
|
||||
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:searchNativeSkills", {
|
||||
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",
|
||||
},
|
||||
]);
|
||||
|
||||
renderSkillsListing();
|
||||
|
||||
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:searchNativeSkills", {
|
||||
query: "alpha",
|
||||
limit: 20,
|
||||
categorySlug: "development",
|
||||
});
|
||||
expect(screen.getByText("Dev Alpha")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps listing search fetch-on-query instead of serving repeated queries from tab cache", async () => {
|
||||
convexActionMock.mockResolvedValue([
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:alpha",
|
||||
slug: "alpha-skill",
|
||||
displayName: "Alpha Skill",
|
||||
summary: "Alpha",
|
||||
stats: { stars: 1, downloads: 1 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
]);
|
||||
|
||||
renderSkillsListing();
|
||||
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Alpha Skill")).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "alpha" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(convexActionMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the canonical skill and plugin category definitions", async () => {
|
||||
renderSkillsListing();
|
||||
|
||||
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: {
|
||||
@@ -482,50 +344,6 @@ describe("HomeListingSection", () => {
|
||||
expect(convexQueryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps Featured active for skill search", async () => {
|
||||
convexActionMock.mockResolvedValue([
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:featured-search",
|
||||
slug: "featured-search",
|
||||
displayName: "Featured Search Skill",
|
||||
stats: { installs: 0 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Featured" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(convexActionMock).toHaveBeenCalledWith("search:searchNativeSkills", {
|
||||
query: "search",
|
||||
limit: 20,
|
||||
highlightedOnly: true,
|
||||
});
|
||||
expect(screen.getByText("Featured Search Skill")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Featured active for plugin search", async () => {
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
featured: true,
|
||||
q: "search",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses cached plugin tabs instead of refetching when switching back", async () => {
|
||||
fetchPluginCatalogMock.mockImplementation((args: { isOfficial?: boolean }) =>
|
||||
Promise.resolve({
|
||||
@@ -628,138 +446,4 @@ describe("HomeListingSection", () => {
|
||||
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: { installs: 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 });
|
||||
});
|
||||
|
||||
renderSkillsListing();
|
||||
|
||||
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: { installs: 10 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
renderSkillsListing();
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,13 +103,6 @@ describe("restored UI design contract", () => {
|
||||
it("uses semantic design-system geometry across landing controls and surfaces", () => {
|
||||
const css = styles();
|
||||
|
||||
for (const selector of [
|
||||
".home-v2-listing-search-bar",
|
||||
".home-v2-listing-search-close",
|
||||
".home-v2-listing-category-trigger",
|
||||
]) {
|
||||
expect(cssRule(css, selector)).toContain("border-radius: var(--oc-radius-control)");
|
||||
}
|
||||
expect(cssRule(css, ".promotion-bar-icon")).toContain("border-radius: var(--oc-radius-inset)");
|
||||
expect(cssRule(css, ".home-v2-apps-workflow-tile")).toContain(
|
||||
"border-radius: var(--oc-radius-surface)",
|
||||
@@ -354,14 +347,16 @@ describe("restored UI design contract", () => {
|
||||
expect(css.lastIndexOf(".home-v2-static-headline")).toBeGreaterThan(
|
||||
css.lastIndexOf(".home-v2-action-word"),
|
||||
);
|
||||
expect(listingSource).toContain("home-v2-listing-card oc-card oc-card-interactive");
|
||||
expect(listingSource).toContain('className="home-v2-listing-list"');
|
||||
expect(listingSource).toContain("home-v2-listing-kind clawhub-segmented oc-segmented");
|
||||
expect(listingSource).toContain(
|
||||
"home-v2-listing-kind-btn clawhub-segmented-btn oc-segmented-item",
|
||||
);
|
||||
expect(listingSource).toContain("home-v2-listing-view clawhub-segmented oc-segmented");
|
||||
expect(listingSource).toContain(
|
||||
"home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item",
|
||||
expect(listingSource).not.toContain("home-v2-listing-view");
|
||||
expect(listingSource).not.toContain("HomeListingCategorySelect");
|
||||
expect(listingSource).not.toContain("Search catalog");
|
||||
expect(listingSource.indexOf('className="home-v2-listing-sort"')).toBeLessThan(
|
||||
listingSource.indexOf('className="home-v2-listing-kind'),
|
||||
);
|
||||
expect(appsSource).toContain('className="home-v2-apps-tile"');
|
||||
expect(appsSource).toContain('className="home-v2-apps-workflow-header"');
|
||||
@@ -385,13 +380,8 @@ describe("restored UI design contract", () => {
|
||||
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(cssRule(css, ".home-v2-listing-kind")).toContain("margin-left: auto");
|
||||
});
|
||||
|
||||
it("requires the restored footer columns and mobile section toggles", () => {
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,12 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { isPluginCategorySlug, isSkillCategorySlug } from "clawhub-schema";
|
||||
import {
|
||||
Binoculars,
|
||||
Bookmark,
|
||||
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 { PLUGIN_CATEGORIES, SKILL_CATEGORIES, type BrowseCategory } from "../lib/categories";
|
||||
import { Bookmark, CloudOff, Download, Loader2, Moon, Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchHomePluginListing as fetchPluginListing,
|
||||
fetchHomeSkillListing as fetchSkillListing,
|
||||
HOME_LISTING_PAGE_SIZE,
|
||||
homeListingCacheKey as listingCacheKey,
|
||||
isHomeTrendingSkillEntry,
|
||||
itemMatchesAnyHomeCategory as itemMatchesAnyCategory,
|
||||
skillMatchesAnyHomeCategory as skillMatchesAnyCategory,
|
||||
uniqueHomePlugins as uniquePlugins,
|
||||
uniqueHomeSkillEntries as uniqueSkillEntries,
|
||||
type HomeListingCacheEntry,
|
||||
type HomeListingInitialData,
|
||||
type HomeListingKind as ListingKind,
|
||||
@@ -44,18 +16,14 @@ import {
|
||||
type TrendingFeedState,
|
||||
} from "../lib/homeListingData";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "../lib/packageApi";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
import { buildPluginDetailHref } from "../lib/pluginRoutes";
|
||||
import { presentationTitle } from "../lib/presentationTitle";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText";
|
||||
import { HomeListingCategorySelect } from "./HomeListingCategorySelect";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { OfficialBadge } from "./OfficialBadge";
|
||||
import { BrowseResultsSkeleton } from "./skeletons/BrowseResultsSkeleton";
|
||||
|
||||
type ListingView = "list" | "grid";
|
||||
|
||||
const SKILL_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [
|
||||
{ id: "trending", label: "Trending" },
|
||||
{ id: "featured", label: "Featured" },
|
||||
@@ -73,43 +41,14 @@ const PLUGIN_LISTING_TABS: Array<{
|
||||
];
|
||||
|
||||
const LISTING_PAGE_SIZE = HOME_LISTING_PAGE_SIZE;
|
||||
const LISTING_SEARCH_DEBOUNCE_MS = 220;
|
||||
|
||||
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;
|
||||
};
|
||||
const EMPTY_CATEGORY_SLUGS: string[] = [];
|
||||
|
||||
function HomeListingEmptyPanel({
|
||||
variant,
|
||||
query,
|
||||
onClearSearch,
|
||||
}: {
|
||||
variant: "error" | "search" | "filter" | "trendingEmpty" | "trendingUnavailable";
|
||||
query?: string;
|
||||
onClearSearch?: () => void;
|
||||
variant: "error" | "empty" | "trendingEmpty" | "trendingUnavailable";
|
||||
}) {
|
||||
const Icon =
|
||||
variant === "error" || variant === "trendingUnavailable"
|
||||
? CloudOff
|
||||
: variant === "search"
|
||||
? Binoculars
|
||||
: Moon;
|
||||
const Icon = variant === "error" || variant === "trendingUnavailable" ? CloudOff : Moon;
|
||||
const title =
|
||||
variant === "trendingUnavailable"
|
||||
? "24-hour Trending unavailable"
|
||||
@@ -117,11 +56,7 @@ function HomeListingEmptyPanel({
|
||||
? "No 24-hour activity yet"
|
||||
: variant === "error"
|
||||
? "Listings took a coffee break"
|
||||
: variant === "search"
|
||||
? query
|
||||
? `No claws for “${query}”`
|
||||
: "No claws in this view"
|
||||
: "Quiet shelf";
|
||||
: "Quiet shelf";
|
||||
const body =
|
||||
variant === "trendingUnavailable"
|
||||
? "The canonical 24-hour feed isn't available right now. Try another tab."
|
||||
@@ -129,9 +64,7 @@ function HomeListingEmptyPanel({
|
||||
? "No skills have eligible activity in the current 24-hour window."
|
||||
: 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.";
|
||||
: "Nothing on this tab right now. Peek at another tab.";
|
||||
|
||||
return (
|
||||
<div className="home-v2-listing-empty" role="status">
|
||||
@@ -140,33 +73,23 @@ function HomeListingEmptyPanel({
|
||||
</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"}`}
|
||||
>
|
||||
<div className={`home-v2-listing-results${showMore ? " is-collapsed" : ""} is-list`}>
|
||||
{children}
|
||||
{showMore ? (
|
||||
<div className="home-v2-listing-more">
|
||||
@@ -313,124 +236,6 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) {
|
||||
if (isHomeTrendingSkillEntry(entry)) {
|
||||
const item = entry.trending;
|
||||
const owner = item.publisher?.handle;
|
||||
return (
|
||||
<Link to={item.canonicalUrl} className="home-v2-listing-card oc-card oc-card-interactive">
|
||||
<div className="home-v2-listing-card-head">
|
||||
<span className="home-v2-listing-card-icon" aria-hidden="true">
|
||||
<MarketplaceIcon kind="skill" label={item.displayName} size="sm" />
|
||||
</span>
|
||||
<div className="home-v2-listing-card-id">
|
||||
<span className="home-v2-listing-card-name" title={item.displayName}>
|
||||
{truncateText(item.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
|
||||
</span>
|
||||
{owner ? <span className="home-v2-listing-card-by">@{owner}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-v2-listing-card-summary">
|
||||
{truncateText(item.summary || "Agent-ready skill pack.", 80)}
|
||||
</p>
|
||||
{typeof item.metrics.trending24hDownloads === "number" ? (
|
||||
<div className="home-v2-listing-card-stats" aria-label="24-hour downloads">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(item.metrics.trending24hDownloads)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
const handle = entry.ownerHandle || entry.owner?.handle;
|
||||
const name = presentationTitle(entry.skill.displayName, entry.skill.slug);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={skillLink(entry)}
|
||||
className={`home-v2-listing-card oc-card oc-card-interactive${
|
||||
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}
|
||||
imageUrl={entry.skill.icon}
|
||||
skill={entry.skill}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<div className="home-v2-listing-card-id">
|
||||
<span className="home-v2-listing-card-name" title={name}>
|
||||
{truncateText(name, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
|
||||
</span>
|
||||
{handle ? <span className="home-v2-listing-card-by">@{handle}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="home-v2-listing-card-summary">
|
||||
{truncateText(entry.skill.summary || "Agent-ready skill pack.", 80)}
|
||||
</p>
|
||||
{showStats ? (
|
||||
<div className="home-v2-listing-card-stats" aria-label="Popularity">
|
||||
<span>
|
||||
<Bookmark size={13} aria-hidden="true" />
|
||||
{formatCompactStat(entry.skill.stats?.stars ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(entry.skill.stats?.downloads ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
|
||||
const name = presentationTitle(plugin.displayName, plugin.name);
|
||||
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle });
|
||||
|
||||
return (
|
||||
<Link to={pluginHref} className="home-v2-listing-card oc-card oc-card-interactive">
|
||||
<div className="home-v2-listing-card-head">
|
||||
<span className="home-v2-listing-card-icon" aria-hidden="true">
|
||||
<MarketplaceIcon
|
||||
kind="plugin"
|
||||
label={name}
|
||||
imageUrl={plugin.icon}
|
||||
categorySlug={plugin.categories?.[0]}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<div className="home-v2-listing-card-id">
|
||||
<span className="home-v2-listing-card-name" title={name}>
|
||||
{truncateText(name, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
|
||||
</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">
|
||||
{truncateText(plugin.summary || "Gateway plugin for OpenClaw workflows.", 80)}
|
||||
</p>
|
||||
<div className="home-v2-listing-card-stats" aria-label="Downloads">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(plugin.stats?.downloads ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type HomeListingSectionProps = {
|
||||
initialListing?: HomeListingInitialData | null;
|
||||
};
|
||||
@@ -462,8 +267,6 @@ function createInitialListingCache(initialListing: HomeListingInitialData | null
|
||||
}
|
||||
|
||||
export function HomeListingSection({ initialListing = null }: HomeListingSectionProps = {}) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchRequestRef = useRef(0);
|
||||
const listingCacheRef = useRef<Map<string, HomeListingCacheEntry> | null>(null);
|
||||
listingCacheRef.current ??= createInitialListingCache(initialListing);
|
||||
const listingCache = listingCacheRef.current;
|
||||
@@ -474,8 +277,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
? "featured"
|
||||
: (initialListing?.tab ?? "trending");
|
||||
const [tab, setTab] = useState<ListingTab>(initialTab);
|
||||
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[]>(
|
||||
@@ -488,11 +289,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
initialListing ? "idle" : "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(initialListing?.hasMore ?? false);
|
||||
const [trendingState, setTrendingState] = useState<TrendingFeedState | undefined>(
|
||||
initialListing?.kind === "skills" ? initialListing.trendingState : undefined,
|
||||
@@ -501,18 +297,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
initialListing?.kind === "skills" && initialListing.trendingState === "unavailable",
|
||||
);
|
||||
|
||||
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 visibleTabs =
|
||||
kind === "skills"
|
||||
? SKILL_LISTING_TABS.filter(
|
||||
@@ -520,55 +304,20 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
)
|
||||
: PLUGIN_LISTING_TABS;
|
||||
|
||||
const activeItems = isSearchMode
|
||||
? kind === "skills"
|
||||
? searchSkills
|
||||
: searchPlugins
|
||||
: kind === "skills"
|
||||
? skills
|
||||
: plugins;
|
||||
const activeStatus = isSearchMode ? searchStatus : status;
|
||||
const activeItems = kind === "skills" ? skills : plugins;
|
||||
const activeStatus = status;
|
||||
const isEmpty = activeStatus === "idle" && activeItems.length === 0;
|
||||
const showSkillStats = true;
|
||||
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 cacheKey = listingCacheKey({ kind, tab, categorySlugs, fetchLimit });
|
||||
const cacheKey = listingCacheKey({
|
||||
kind,
|
||||
tab,
|
||||
categorySlugs: EMPTY_CATEGORY_SLUGS,
|
||||
fetchLimit,
|
||||
});
|
||||
const cached = listingCache.get(cacheKey);
|
||||
if (cached) {
|
||||
if (cached.kind === "skills") {
|
||||
@@ -603,27 +352,29 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
|
||||
const load =
|
||||
kind === "skills"
|
||||
? fetchSkillListing(tab, categorySlugs, fetchLimit, controller.signal).then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
listingCache.set(cacheKey, {
|
||||
kind: "skills",
|
||||
items: result.page,
|
||||
hasMore: result.hasMore,
|
||||
trendingState: result.trendingState,
|
||||
});
|
||||
setSkills(result.page);
|
||||
setTrendingState(result.trendingState);
|
||||
if (tab === "trending") {
|
||||
const unavailable = result.trendingState === "unavailable";
|
||||
setCanonicalTrendingUnavailable(unavailable);
|
||||
if (unavailable) setTab("featured");
|
||||
}
|
||||
setListingHasMore(result.hasMore);
|
||||
setStatus("idle");
|
||||
})
|
||||
? fetchSkillListing(tab, EMPTY_CATEGORY_SLUGS, fetchLimit, controller.signal).then(
|
||||
(result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
listingCache.set(cacheKey, {
|
||||
kind: "skills",
|
||||
items: result.page,
|
||||
hasMore: result.hasMore,
|
||||
trendingState: result.trendingState,
|
||||
});
|
||||
setSkills(result.page);
|
||||
setTrendingState(result.trendingState);
|
||||
if (tab === "trending") {
|
||||
const unavailable = result.trendingState === "unavailable";
|
||||
setCanonicalTrendingUnavailable(unavailable);
|
||||
if (unavailable) setTab("featured");
|
||||
}
|
||||
setListingHasMore(result.hasMore);
|
||||
setStatus("idle");
|
||||
},
|
||||
)
|
||||
: fetchPluginListing(
|
||||
tab === "trending" ? "new" : tab,
|
||||
categorySlugs,
|
||||
EMPTY_CATEGORY_SLUGS,
|
||||
fetchLimit,
|
||||
controller.signal,
|
||||
).then((result) => {
|
||||
@@ -658,152 +409,29 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [categorySlugs, fetchLimit, isSearchMode, kind, listingCache, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearchMode) {
|
||||
setSearchSkills([]);
|
||||
setSearchPlugins([]);
|
||||
setSearchStatus("idle");
|
||||
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.searchNativeSkills, {
|
||||
query: trimmedSearch,
|
||||
limit: fetchLimit,
|
||||
highlightedOnly: tab === "featured" ? true : undefined,
|
||||
...(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 items = rows.slice(0, fetchLimit);
|
||||
const hasMore =
|
||||
rows.length > fetchLimit ||
|
||||
results.some((hits) => (hits as SkillSearchHit[]).length >= fetchLimit);
|
||||
setSearchSkills(items);
|
||||
setListingHasMore(hasMore);
|
||||
setSearchStatus("idle");
|
||||
})
|
||||
: Promise.all(
|
||||
(categorySlugs.length > 0 ? categorySlugs : [null]).map((categorySlug) =>
|
||||
fetchPluginCatalog({
|
||||
q: trimmedSearch,
|
||||
category: categorySlug ?? undefined,
|
||||
featured: tab === "featured" ? true : undefined,
|
||||
sort: "updated",
|
||||
isOfficial: tab === "official" ? true : undefined,
|
||||
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)),
|
||||
),
|
||||
);
|
||||
const hasMore = results.some(
|
||||
(result) => result.nextCursor != null || result.items.length >= fetchLimit,
|
||||
);
|
||||
setSearchPlugins(items);
|
||||
setListingHasMore(hasMore);
|
||||
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]);
|
||||
}, [fetchLimit, kind, listingCache, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(LISTING_PAGE_SIZE);
|
||||
setFetchLimit(LISTING_PAGE_SIZE);
|
||||
}, [categorySlugs, isSearchMode, kind, tab, trimmedSearch, view]);
|
||||
}, [kind, tab]);
|
||||
|
||||
const visibleSkills = (isSearchMode ? searchSkills : skills).slice(0, visibleCount);
|
||||
const visiblePlugins = (isSearchMode ? searchPlugins : plugins).slice(0, visibleCount);
|
||||
const visibleSkills = skills.slice(0, visibleCount);
|
||||
const visiblePlugins = 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([]);
|
||||
setTab(
|
||||
nextKind === "skills" ? (canonicalTrendingUnavailable ? "featured" : "trending") : "new",
|
||||
);
|
||||
};
|
||||
|
||||
const removeCategory = (slug: string) => {
|
||||
setCategorySlugs((current) => current.filter((categorySlug) => categorySlug !== slug));
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="home-v2-listing"
|
||||
@@ -812,6 +440,23 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
>
|
||||
<div className="home-v2-listing-controls">
|
||||
<div className="home-v2-listing-toolbar">
|
||||
<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.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="home-v2-listing-kind clawhub-segmented oc-segmented"
|
||||
role="group"
|
||||
@@ -838,149 +483,10 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
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);
|
||||
if (item.id === "trending") setCategorySlugs([]);
|
||||
}}
|
||||
>
|
||||
{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 oc-action oc-action-ghost oc-action-icon${
|
||||
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>
|
||||
|
||||
{kind === "skills" && tab === "trending" ? null : (
|
||||
<HomeListingCategorySelect
|
||||
categories={listingCategories}
|
||||
value={categorySlugs}
|
||||
onChange={setCategorySlugs}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="home-v2-listing-view clawhub-segmented oc-segmented"
|
||||
role="group"
|
||||
aria-label="Layout"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item${
|
||||
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 clawhub-segmented-btn oc-segmented-item${
|
||||
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 on ClawHub" : "Search plugins on ClawHub"
|
||||
}
|
||||
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 oc-pill"
|
||||
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 oc-pill 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 ? (
|
||||
{activeStatus === "idle" && activeItems.length > 0 ? (
|
||||
<div
|
||||
className={`home-v2-listing-head${showSkillStats ? "" : " has-no-stats"}`}
|
||||
aria-hidden="true"
|
||||
@@ -1006,65 +512,43 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
{isEmpty ? (
|
||||
<HomeListingEmptyPanel
|
||||
variant={
|
||||
isSearchMode
|
||||
? "search"
|
||||
: kind === "skills" && tab === "trending"
|
||||
? trendingState === "unavailable"
|
||||
? "trendingUnavailable"
|
||||
: "trendingEmpty"
|
||||
: "filter"
|
||||
kind === "skills" && tab === "trending"
|
||||
? trendingState === "unavailable"
|
||||
? "trendingUnavailable"
|
||||
: "trendingEmpty"
|
||||
: "empty"
|
||||
}
|
||||
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={
|
||||
isHomeTrendingSkillEntry(entry) ? entry.trending.id : String(entry.skill._id)
|
||||
}
|
||||
entry={entry}
|
||||
showStats={showSkillStats}
|
||||
/>
|
||||
) : (
|
||||
<HomeListingSkillRow
|
||||
key={
|
||||
isHomeTrendingSkillEntry(entry) ? entry.trending.id : String(entry.skill._id)
|
||||
}
|
||||
entry={entry}
|
||||
showStats={showSkillStats}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<div className="home-v2-listing-list">
|
||||
{visibleSkills.map((entry) => (
|
||||
<HomeListingSkillRow
|
||||
key={isHomeTrendingSkillEntry(entry) ? entry.trending.id : String(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 className="home-v2-listing-list">
|
||||
{visiblePlugins.map((plugin) => (
|
||||
<HomeListingPluginRow key={plugin.name} plugin={plugin} />
|
||||
))}
|
||||
</div>
|
||||
</HomeListingResults>
|
||||
) : null}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function homeListingCacheKey({
|
||||
return ["listing", kind, tab, categoryCacheKey(categorySlugs), fetchLimit].join(":");
|
||||
}
|
||||
|
||||
export function itemMatchesAnyHomeCategory(
|
||||
function itemMatchesAnyHomeCategory(
|
||||
item: { categories?: readonly string[] | null },
|
||||
categorySlugs: readonly string[],
|
||||
) {
|
||||
@@ -90,19 +90,19 @@ export function itemMatchesAnyHomeCategory(
|
||||
return categorySlugs.some((slug) => categories.includes(slug));
|
||||
}
|
||||
|
||||
export function skillMatchesAnyHomeCategory(skill: PublicSkill, categorySlugs: readonly string[]) {
|
||||
function skillMatchesAnyHomeCategory(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));
|
||||
}
|
||||
|
||||
export function uniqueHomeSkillEntries(entries: HomeNativeSkillListingEntry[]) {
|
||||
function uniqueHomeSkillEntries(entries: HomeNativeSkillListingEntry[]) {
|
||||
const byId = new Map<string, HomeNativeSkillListingEntry>();
|
||||
for (const entry of entries) byId.set(String(entry.skill._id), entry);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
export function uniqueHomePlugins(items: PackageListItem[]) {
|
||||
function uniqueHomePlugins(items: PackageListItem[]) {
|
||||
const byName = new Map<string, PackageListItem>();
|
||||
for (const item of items) byName.set(item.name, item);
|
||||
return [...byName.values()];
|
||||
|
||||
+7
-46
@@ -22192,6 +22192,10 @@ a.search-empty-action {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home-v2-listing-kind {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.home-v2-listing-divider {
|
||||
width: 1px;
|
||||
align-self: center;
|
||||
@@ -28475,14 +28479,13 @@ a.home-v2-byos-import:focus-visible svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
.home-v2-listing-kind {
|
||||
grid-column: 1;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-self: start;
|
||||
justify-self: end;
|
||||
}
|
||||
.home-v2-listing-sort {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
order: 3;
|
||||
grid-row: 2;
|
||||
align-self: auto;
|
||||
gap: 20px;
|
||||
min-height: 36px;
|
||||
@@ -28497,48 +28500,6 @@ a.home-v2-byos-import:focus-visible svg:last-child {
|
||||
justify-content: center;
|
||||
min-width: max-content;
|
||||
}
|
||||
.home-v2-listing-actions {
|
||||
display: contents;
|
||||
}
|
||||
.home-v2-listing-actions-rail {
|
||||
display: contents;
|
||||
}
|
||||
.home-v2-listing-search-trigger {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
.home-v2-listing-actions-rail .home-v2-listing-category-menu {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.home-v2-listing-category-trigger {
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--hv2-border);
|
||||
border-radius: var(--oc-radius-control);
|
||||
background: color-mix(in srgb, var(--hv2-surface) 76%, transparent);
|
||||
}
|
||||
.home-v2-listing-category-panel {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
max-height: min(520px, 72vh);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.home-v2-listing-actions-rail .home-v2-listing-view {
|
||||
display: none;
|
||||
}
|
||||
.home-v2-listing-tab {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user