refactor: simplify homepage catalog controls (#3364)

This commit is contained in:
Patrick Erichsen
2026-07-31 16:11:32 -07:00
committed by GitHub
parent a9d04bb009
commit d76c965480
7 changed files with 108 additions and 1244 deletions
@@ -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,
};
}
+11 -327
View File
@@ -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",
);
});
});
});
+7 -17
View File
@@ -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>
);
}
+77 -593
View File
@@ -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}
+4 -4
View File
@@ -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
View File
@@ -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;
}