mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: make Featured the default ClawHub catalog (#3096)
* feat: make home catalog featured-first * fix: order plugins before skills on home * feat: refine featured catalog landing page * fix: seed featured catalog previews * fix: reduce official creator shelf
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
backfillExistingPublicCorpusBatchRows,
|
||||
currentUserSeedPackageName,
|
||||
currentUserSeedSkillSlug,
|
||||
seedCatalogPresentationFixtures,
|
||||
seedFeaturedPluginPackagesMutation,
|
||||
seedGitHubBackedSkillSourceMutation,
|
||||
seedLocalFixtures,
|
||||
@@ -23,6 +24,9 @@ const seedSkillMutationHandler = (
|
||||
const seedFeaturedPluginPackagesHandler = (
|
||||
seedFeaturedPluginPackagesMutation as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const seedCatalogPresentationFixturesHandler = (
|
||||
seedCatalogPresentationFixtures as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const seedGitHubBackedSkillSourceHandler = (
|
||||
seedGitHubBackedSkillSourceMutation as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
@@ -1503,4 +1507,129 @@ describe("devSeed local fixtures", () => {
|
||||
expect(oldPackageDeleteIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(oldReleaseDeleteIndex).toBeGreaterThan(oldPackageDeleteIndex);
|
||||
});
|
||||
|
||||
it("seeds repeatable official creator fixtures with featured skills and plugins", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const userId = (await db.insert("users", {
|
||||
handle: "local-corpus-owner",
|
||||
displayName: "Corpus Owner",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})) as Id<"users">;
|
||||
const sourcePublisherId = (await db.insert("publishers", {
|
||||
kind: "user",
|
||||
handle: "local-corpus-owner",
|
||||
displayName: "Corpus Owner",
|
||||
linkedUserId: userId,
|
||||
publishedSkills: 1,
|
||||
publishedPackages: 1,
|
||||
totalInstalls: 12,
|
||||
totalDownloads: 24,
|
||||
totalStars: 6,
|
||||
skillTotalInstalls: 5,
|
||||
skillTotalDownloads: 10,
|
||||
skillTotalStars: 2,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})) as Id<"publishers">;
|
||||
const skillId = (await db.insert("skills", {
|
||||
slug: "presentation-skill",
|
||||
displayName: "Presentation Skill",
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: sourcePublisherId,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
badges: { highlighted: undefined, redactionApproved: undefined },
|
||||
softDeletedAt: undefined,
|
||||
statsDownloads: 10,
|
||||
statsStars: 2,
|
||||
statsInstallsCurrent: 3,
|
||||
statsInstallsAllTime: 5,
|
||||
stats: {
|
||||
downloads: 10,
|
||||
installsCurrent: 3,
|
||||
installsAllTime: 5,
|
||||
stars: 2,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})) as Id<"skills">;
|
||||
const skillVersionId = (await db.insert("skillVersions", {
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
changelog: "Presentation fixture.",
|
||||
files: [],
|
||||
parsed: { frontmatter: {}, metadata: {} },
|
||||
createdBy: userId,
|
||||
createdAt: 1,
|
||||
softDeletedAt: undefined,
|
||||
})) as Id<"skillVersions">;
|
||||
await db.patch(skillId, {
|
||||
latestVersionId: skillVersionId,
|
||||
tags: { latest: skillVersionId },
|
||||
stats: {
|
||||
downloads: 10,
|
||||
installsCurrent: 3,
|
||||
installsAllTime: 5,
|
||||
stars: 2,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
});
|
||||
const packageId = (await db.insert("packages", {
|
||||
name: "presentation-plugin",
|
||||
normalizedName: "presentation-plugin",
|
||||
displayName: "Presentation Plugin",
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: sourcePublisherId,
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
runtimeId: "presentation-plugin",
|
||||
tags: {},
|
||||
stats: { downloads: 14, installs: 7, stars: 4, versions: 1 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})) as Id<"packages">;
|
||||
const args = {
|
||||
orgs: [
|
||||
{
|
||||
sourceOwnerHandle: "local-corpus-owner",
|
||||
handle: "catalog-atlas",
|
||||
displayName: "Atlas Automation",
|
||||
bio: "Synthetic official creator.",
|
||||
image: "https://example.invalid/atlas.svg",
|
||||
skillSlug: "presentation-skill",
|
||||
packageName: "presentation-plugin",
|
||||
featured: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await seedCatalogPresentationFixturesHandler(createMutationCtx(db) as never, args as never);
|
||||
await seedCatalogPresentationFixturesHandler(createMutationCtx(db) as never, args as never);
|
||||
|
||||
const org = tables.publishers?.find((publisher) => publisher.handle === "catalog-atlas");
|
||||
expect(org).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "org",
|
||||
displayName: "Atlas Automation",
|
||||
publishedSkills: 1,
|
||||
publishedPackages: 1,
|
||||
totalInstalls: 12,
|
||||
}),
|
||||
);
|
||||
expect(tables.officialPublishers).toHaveLength(1);
|
||||
expect(tables.publisherMembers).toHaveLength(1);
|
||||
expect(tables.skills?.find((skill) => skill._id === skillId)?.ownerPublisherId).toBe(org?._id);
|
||||
expect(tables.packages?.find((pkg) => pkg._id === packageId)?.ownerPublisherId).toBe(org?._id);
|
||||
expect(tables.skillBadges).toEqual([expect.objectContaining({ skillId, kind: "highlighted" })]);
|
||||
expect(tables.packageBadges).toEqual([
|
||||
expect.objectContaining({ packageId, kind: "highlighted" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
upsertPackageSearchDigest,
|
||||
} from "./lib/packageSearchDigest";
|
||||
import { ensurePersonalPublisherForUser } from "./lib/publishers";
|
||||
import { recomputePublisherStats } from "./lib/publisherStats";
|
||||
import {
|
||||
computeRecommendationScore,
|
||||
RECOMMENDATION_SCORE_VERSION,
|
||||
@@ -1369,6 +1370,148 @@ export const seedPublicCorpusBatchMutation = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const seedCatalogPresentationFixtures = internalMutation({
|
||||
args: {
|
||||
orgs: v.array(
|
||||
v.object({
|
||||
sourceOwnerHandle: v.string(),
|
||||
handle: v.string(),
|
||||
displayName: v.string(),
|
||||
bio: v.string(),
|
||||
image: v.string(),
|
||||
skillSlug: v.string(),
|
||||
packageName: v.string(),
|
||||
featured: v.boolean(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const affectedPublisherIds = new Set<Id<"publishers">>();
|
||||
const seeded: string[] = [];
|
||||
|
||||
for (const spec of args.orgs) {
|
||||
const sourcePublisher = await ctx.db
|
||||
.query("publishers")
|
||||
.withIndex("by_handle", (q) => q.eq("handle", spec.sourceOwnerHandle))
|
||||
.unique();
|
||||
if (!sourcePublisher || sourcePublisher.kind !== "user" || !sourcePublisher.linkedUserId) {
|
||||
throw new Error(`Catalog presentation source owner not found: ${spec.sourceOwnerHandle}`);
|
||||
}
|
||||
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", spec.skillSlug))
|
||||
.unique();
|
||||
const pkg = await findSeedPluginFixtureByName(ctx, spec.packageName);
|
||||
if (!skill || !pkg) {
|
||||
throw new Error(`Catalog presentation content not found for ${spec.handle}`);
|
||||
}
|
||||
|
||||
const existingPublisher = await ctx.db
|
||||
.query("publishers")
|
||||
.withIndex("by_handle", (q) => q.eq("handle", spec.handle))
|
||||
.unique();
|
||||
if (existingPublisher && existingPublisher.kind !== "org") {
|
||||
throw new Error(`Catalog presentation handle is not an organization: ${spec.handle}`);
|
||||
}
|
||||
|
||||
const publisherPatch = {
|
||||
kind: "org" as const,
|
||||
handle: spec.handle,
|
||||
displayName: spec.displayName,
|
||||
bio: spec.bio,
|
||||
image: spec.image,
|
||||
linkedUserId: undefined,
|
||||
trustedPublisher: false,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
const publisherId =
|
||||
existingPublisher?._id ??
|
||||
(await ctx.db.insert("publishers", {
|
||||
...publisherPatch,
|
||||
publishedSkills: 0,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 0,
|
||||
totalDownloads: 0,
|
||||
totalStars: 0,
|
||||
skillTotalInstalls: 0,
|
||||
skillTotalDownloads: 0,
|
||||
skillTotalStars: 0,
|
||||
createdAt: now,
|
||||
}));
|
||||
if (existingPublisher) {
|
||||
await ctx.db.patch(existingPublisher._id, publisherPatch);
|
||||
}
|
||||
|
||||
const official = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
||||
.unique();
|
||||
const officialPatch = {
|
||||
reason: "Synthetic local and preview catalog presentation fixture.",
|
||||
createdByUserId: sourcePublisher.linkedUserId,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (official) {
|
||||
await ctx.db.patch(official._id, officialPatch);
|
||||
} else {
|
||||
await ctx.db.insert("officialPublishers", {
|
||||
publisherId,
|
||||
...officialPatch,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", publisherId).eq("userId", sourcePublisher.linkedUserId!),
|
||||
)
|
||||
.unique();
|
||||
if (membership) {
|
||||
await ctx.db.patch(membership._id, { role: "owner", updatedAt: now });
|
||||
} else {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId,
|
||||
userId: sourcePublisher.linkedUserId,
|
||||
role: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
if (skill.ownerPublisherId !== publisherId) {
|
||||
if (skill.ownerPublisherId) affectedPublisherIds.add(skill.ownerPublisherId);
|
||||
await ctx.db.patch(skill._id, { ownerPublisherId: publisherId, updatedAt: now });
|
||||
}
|
||||
if (pkg.ownerPublisherId !== publisherId) {
|
||||
if (pkg.ownerPublisherId) affectedPublisherIds.add(pkg.ownerPublisherId);
|
||||
await ctx.db.patch(pkg._id, { ownerPublisherId: publisherId, updatedAt: now });
|
||||
}
|
||||
if (spec.featured) {
|
||||
await ensureHighlightedSkillBadge(ctx, skill._id, sourcePublisher.linkedUserId, now);
|
||||
await ensureHighlightedPackageBadge(ctx, pkg._id, sourcePublisher.linkedUserId, now);
|
||||
}
|
||||
|
||||
affectedPublisherIds.add(publisherId);
|
||||
seeded.push(spec.handle);
|
||||
}
|
||||
|
||||
for (const publisherId of affectedPublisherIds) {
|
||||
if (!(await ctx.db.get(publisherId))) continue;
|
||||
await ctx.db.patch(publisherId, {
|
||||
...(await recomputePublisherStats(ctx, publisherId)),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, seeded };
|
||||
},
|
||||
});
|
||||
|
||||
function publicCorpusSummaryFromFrontmatter(frontmatter: Record<string, unknown>) {
|
||||
if (typeof frontmatter.description === "string" && frontmatter.description.trim()) {
|
||||
return frontmatter.description.trim();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { PACKAGE_TRENDING_LEADERBOARD_LIMIT } from "clawhub-schema";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { getTrendingRange, topN, TRENDING_DAYS } from "./lib/leaderboards";
|
||||
|
||||
const MAX_TRENDING_LIMIT = 200;
|
||||
const DAILY_STATS_PAGE_SIZE = 1_000;
|
||||
const KEEP_LEADERBOARD_ENTRIES = 3;
|
||||
export const PACKAGE_TRENDING_LEADERBOARD_KIND = "package_trending";
|
||||
@@ -73,7 +73,10 @@ export const writeTrendingLeaderboard = internalMutation({
|
||||
export const rebuildTrendingLeaderboardAction = internalAction({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args): Promise<{ ok: true; count: number }> => {
|
||||
const limit = Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT);
|
||||
const limit = Math.min(
|
||||
Math.max(args.limit ?? PACKAGE_TRENDING_LEADERBOARD_LIMIT, 1),
|
||||
PACKAGE_TRENDING_LEADERBOARD_LIMIT,
|
||||
);
|
||||
const now = Date.now();
|
||||
const { startDay, endDay } = getTrendingRange(now);
|
||||
const totals = new Map<Id<"packages">, { installs: number; downloads: number }>();
|
||||
@@ -126,7 +129,10 @@ export const rebuildTrendingLeaderboardInternal = internalMutation({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.scheduler.runAfter(0, internal.packageLeaderboards.rebuildTrendingLeaderboardAction, {
|
||||
limit: Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT),
|
||||
limit: Math.min(
|
||||
Math.max(args.limit ?? PACKAGE_TRENDING_LEADERBOARD_LIMIT, 1),
|
||||
PACKAGE_TRENDING_LEADERBOARD_LIMIT,
|
||||
),
|
||||
});
|
||||
return { ok: true as const, count: 0, scheduled: true as const, days: TRENDING_DAYS };
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
listMine,
|
||||
getDeletionInventory,
|
||||
getMyProfileHandle,
|
||||
getHomeOfficialCreatorSummaries,
|
||||
getHomeOfficialCreatorSummariesPageInternal,
|
||||
getHomePublisherSummaries,
|
||||
getProfileByHandle,
|
||||
createMemberInvite,
|
||||
@@ -320,6 +322,16 @@ const getHomePublisherSummariesHandler = (
|
||||
getHomePublisherSummaries as unknown as WrappedHandler<{ handles: string[] }>
|
||||
)._handler;
|
||||
|
||||
const getHomeOfficialCreatorSummariesHandler = (
|
||||
getHomeOfficialCreatorSummaries as unknown as WrappedHandler<{ limit?: number }>
|
||||
)._handler;
|
||||
|
||||
const getHomeOfficialCreatorSummariesPageInternalHandler = (
|
||||
getHomeOfficialCreatorSummariesPageInternal as unknown as WrappedHandler<{
|
||||
cursor: string | null;
|
||||
}>
|
||||
)._handler;
|
||||
|
||||
const getOgMetaByHandleHandler = (
|
||||
getOgMetaByHandle as unknown as WrappedHandler<
|
||||
{ handle: string },
|
||||
@@ -936,6 +948,109 @@ describe("home publisher summaries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("home official creator summaries", () => {
|
||||
it("pages the official publisher index and returns only active organizations with content", async () => {
|
||||
const publishers = [
|
||||
makeHomeSummaryPublisher("high", { totalInstalls: 100 }),
|
||||
makeHomeSummaryPublisher("person", {
|
||||
kind: "user",
|
||||
linkedUserId: "users:person",
|
||||
totalInstalls: 80,
|
||||
}),
|
||||
makeHomeSummaryPublisher("empty", {
|
||||
publishedSkills: 0,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 70,
|
||||
}),
|
||||
makeHomeSummaryPublisher("inactive", { deactivatedAt: 2, totalInstalls: 50 }),
|
||||
];
|
||||
const officialRows = publishers.map((publisher, index) => ({
|
||||
_id: `officialPublishers:${index}`,
|
||||
publisherId: publisher._id,
|
||||
createdAt: index,
|
||||
updatedAt: index,
|
||||
}));
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
expect(indexName).toBe("by_created");
|
||||
return indexedRows(officialRows);
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
});
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "users:person") return { _id: id, displayName: "Person" };
|
||||
return publishers.find((publisher) => publisher._id === id) ?? null;
|
||||
});
|
||||
|
||||
const result = (await getHomeOfficialCreatorSummariesPageInternalHandler(
|
||||
{ db: { query, get } } as never,
|
||||
{ cursor: null },
|
||||
)) as {
|
||||
summaries: Array<{ handle: string; kind: string }>;
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
};
|
||||
|
||||
expect(result.summaries.map((summary) => summary.handle)).toEqual(["high"]);
|
||||
expect(result.summaries.every((summary) => summary.kind === "org")).toBe(true);
|
||||
expect(result.isDone).toBe(true);
|
||||
});
|
||||
|
||||
it("collects every official page, orders by installs, and clamps the result to sixteen", async () => {
|
||||
const makeSummary = (index: number, installs: number) => ({
|
||||
...makeHomeSummaryPublisher(`org-${index}`, { totalInstalls: installs }),
|
||||
stats: { skills: 2, packages: 1, installs, downloads: installs, stars: 5 },
|
||||
});
|
||||
const firstPage = Array.from({ length: 12 }, (_, index) => makeSummary(index, 20 - index));
|
||||
const secondPage = Array.from({ length: 8 }, (_, index) =>
|
||||
makeSummary(index + 12, 100 - index),
|
||||
);
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
summaries: firstPage,
|
||||
continueCursor: "page-2",
|
||||
isDone: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
summaries: secondPage,
|
||||
continueCursor: "",
|
||||
isDone: true,
|
||||
});
|
||||
|
||||
const summaries = (await getHomeOfficialCreatorSummariesHandler({ runQuery } as never, {
|
||||
limit: 99,
|
||||
})) as Array<{ handle: string; stats: { installs: number } }>;
|
||||
|
||||
expect(summaries).toHaveLength(16);
|
||||
expect(summaries.map((summary) => summary.stats.installs)).toEqual([
|
||||
100, 99, 98, 97, 96, 95, 94, 93, 20, 19, 18, 17, 16, 15, 14, 13,
|
||||
]);
|
||||
expect(runQuery).toHaveBeenNthCalledWith(1, expect.anything(), { cursor: null });
|
||||
expect(runQuery).toHaveBeenNthCalledWith(2, expect.anything(), { cursor: "page-2" });
|
||||
});
|
||||
|
||||
it("fails closed when legacy official rows exceed the bounded curation limit", async () => {
|
||||
const runQuery = vi.fn();
|
||||
for (let page = 0; page < 4; page += 1) {
|
||||
runQuery.mockResolvedValueOnce({
|
||||
summaries: [],
|
||||
continueCursor: `page-${page + 1}`,
|
||||
isDone: false,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(getHomeOfficialCreatorSummariesHandler({ runQuery } as never, {})).rejects.toThrow(
|
||||
"Official publisher limit exceeded",
|
||||
);
|
||||
expect(runQuery).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishers membership controls", () => {
|
||||
it("lets an org owner delete an org and cascade owned resources", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
@@ -6600,6 +6715,9 @@ describe("official publisher administration", () => {
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_created") {
|
||||
return { take: vi.fn(async () => []) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
},
|
||||
),
|
||||
|
||||
+79
-1
@@ -3,7 +3,7 @@ import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { action, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { isPublicSkillDoc } from "./lib/globalStats";
|
||||
import { isOfficialPublisher, toPublicPublisherWithOfficial } from "./lib/officialPublishers";
|
||||
@@ -61,6 +61,10 @@ const PUBLISHER_OG_AFFILIATION_LIMIT = 5;
|
||||
const PUBLISHER_OG_MEMBERSHIP_PAGE_SIZE = 64;
|
||||
const PUBLISHER_OG_MEMBERSHIP_SCAN_LIMIT = 512;
|
||||
const MAX_HOME_PUBLISHER_SUMMARIES = 10;
|
||||
const MAX_HOME_OFFICIAL_CREATOR_SUMMARIES = 16;
|
||||
const HOME_OFFICIAL_CREATOR_PAGE_SIZE = 32;
|
||||
// Official status is staff-curated. Keep the set bounded so public home/browse reads stay predictable.
|
||||
const MAX_OFFICIAL_PUBLISHER_COUNT = 128;
|
||||
const publisherRoleValidator = v.union(
|
||||
v.literal("owner"),
|
||||
v.literal("admin"),
|
||||
@@ -2428,6 +2432,72 @@ export const getHomePublisherSummaries = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getHomeOfficialCreatorSummariesPageInternal = internalQuery({
|
||||
args: { cursor: v.union(v.string(), v.null()) },
|
||||
handler: async (ctx, args) => {
|
||||
const page = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_created", (q) => q)
|
||||
.paginate({ cursor: args.cursor, numItems: HOME_OFFICIAL_CREATOR_PAGE_SIZE });
|
||||
const summaries = (
|
||||
await Promise.all(
|
||||
page.page.map(async (row) =>
|
||||
toHomePublisherSummary(ctx, await ctx.db.get(row.publisherId)),
|
||||
),
|
||||
)
|
||||
).filter(
|
||||
(summary): summary is NonNullable<Awaited<ReturnType<typeof toHomePublisherSummary>>> =>
|
||||
Boolean(
|
||||
summary && summary.kind === "org" && summary.stats.skills + summary.stats.packages > 0,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
summaries,
|
||||
continueCursor: page.continueCursor,
|
||||
isDone: page.isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getHomeOfficialCreatorSummaries: ReturnType<typeof action> = action({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(
|
||||
args.limit ?? MAX_HOME_OFFICIAL_CREATOR_SUMMARIES,
|
||||
1,
|
||||
MAX_HOME_OFFICIAL_CREATOR_SUMMARIES,
|
||||
);
|
||||
const summaries: Array<NonNullable<Awaited<ReturnType<typeof toHomePublisherSummary>>>> = [];
|
||||
let cursor: string | null = null;
|
||||
let scanned = 0;
|
||||
|
||||
while (scanned < MAX_OFFICIAL_PUBLISHER_COUNT) {
|
||||
const page: {
|
||||
summaries: Array<NonNullable<Awaited<ReturnType<typeof toHomePublisherSummary>>>>;
|
||||
continueCursor: string;
|
||||
isDone: boolean;
|
||||
} = await ctx.runQuery(internal.publishers.getHomeOfficialCreatorSummariesPageInternal, {
|
||||
cursor,
|
||||
});
|
||||
summaries.push(...page.summaries);
|
||||
scanned += HOME_OFFICIAL_CREATOR_PAGE_SIZE;
|
||||
if (page.isDone || page.continueCursor === cursor) break;
|
||||
if (scanned >= MAX_OFFICIAL_PUBLISHER_COUNT) {
|
||||
throw new ConvexError("Official publisher limit exceeded");
|
||||
}
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
|
||||
return summaries
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.stats.installs - left.stats.installs || left.handle.localeCompare(right.handle),
|
||||
)
|
||||
.slice(0, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const getOgMetaByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -3532,6 +3602,14 @@ export const addOfficialPublisherInternal = internalMutation({
|
||||
};
|
||||
}
|
||||
|
||||
const officialPublisherLimitProbe = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_created", (q) => q)
|
||||
.take(MAX_OFFICIAL_PUBLISHER_COUNT);
|
||||
if (officialPublisherLimitProbe.length >= MAX_OFFICIAL_PUBLISHER_COUNT) {
|
||||
throw new ConvexError(`Official publisher limit reached (${MAX_OFFICIAL_PUBLISHER_COUNT})`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const officialPublisherId = await ctx.db.insert("officialPublishers", {
|
||||
publisherId: publisher._id,
|
||||
|
||||
Vendored
+11
-10
@@ -1,4 +1,5 @@
|
||||
import { type inferred } from "arktype";
|
||||
export declare const PACKAGE_TRENDING_LEADERBOARD_LIMIT = 200;
|
||||
export declare function normalizePackageOwnerHandle(handle: string | null | undefined): string | undefined;
|
||||
export declare function inferPackageNameScope(name: string): string | undefined;
|
||||
export declare function getPackageScopeOwnerMismatch(name: string, ownerHandle: string | null | undefined): {
|
||||
@@ -77,11 +78,11 @@ export declare const PackageStatsSchema: import("arktype/internal/variants/objec
|
||||
export type PackageStats = (typeof PackageStatsSchema)[inferred];
|
||||
export declare const PackageArtifactKindSchema: import("arktype/internal/variants/string.ts").StringType<"legacy-zip" | "npm-pack", {}>;
|
||||
export type PackageArtifactKind = (typeof PackageArtifactKindSchema)[inferred];
|
||||
export declare const PackageReleaseModerationStateSchema: import("arktype/internal/variants/string.ts").StringType<"approved" | "quarantined" | "revoked", {}>;
|
||||
export declare const PackageReleaseModerationStateSchema: import("arktype/internal/variants/string.ts").StringType<"approved" | "revoked" | "quarantined", {}>;
|
||||
export type PackageReleaseModerationState = (typeof PackageReleaseModerationStateSchema)[inferred];
|
||||
export declare const PackageReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed", {}>;
|
||||
export type PackageReportStatus = (typeof PackageReportStatusSchema)[inferred];
|
||||
export declare const PackageReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "quarantine" | "revoke", {}>;
|
||||
export declare const PackageReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"revoke" | "none" | "quarantine", {}>;
|
||||
export type PackageReportFinalAction = (typeof PackageReportFinalActionSchema)[inferred];
|
||||
export declare const PackageReportListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed" | "all", {}>;
|
||||
export type PackageReportListStatus = (typeof PackageReportListStatusSchema)[inferred];
|
||||
@@ -717,12 +718,12 @@ export declare const ApiV1PackageSecurityResponseSchema: import("arktype/interna
|
||||
reasons: string[];
|
||||
pending: boolean;
|
||||
stale: boolean;
|
||||
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
|
||||
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
|
||||
};
|
||||
}, {}>;
|
||||
export type ApiV1PackageSecurityResponse = (typeof ApiV1PackageSecurityResponseSchema)[inferred];
|
||||
export declare const PackageReleaseModerationRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
state: "approved" | "quarantined" | "revoked";
|
||||
state: "approved" | "revoked" | "quarantined";
|
||||
reason: string;
|
||||
}, {}>;
|
||||
export type PackageReleaseModerationRequest = (typeof PackageReleaseModerationRequestSchema)[inferred];
|
||||
@@ -743,7 +744,7 @@ export type ApiV1PackageReportResponse = (typeof ApiV1PackageReportResponseSchem
|
||||
export declare const PackageReportTriageRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: "open" | "confirmed" | "dismissed";
|
||||
note?: string | undefined;
|
||||
finalAction?: "none" | "quarantine" | "revoke" | undefined;
|
||||
finalAction?: "revoke" | "none" | "quarantine" | undefined;
|
||||
}, {}>;
|
||||
export type PackageReportTriageRequest = (typeof PackageReportTriageRequestSchema)[inferred];
|
||||
export declare const PackageAppealRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
@@ -822,7 +823,7 @@ export declare const ApiV1PackageReportListResponseSchema: import("arktype/inter
|
||||
triagedAt?: number | null | undefined;
|
||||
triagedBy?: string | null | undefined;
|
||||
triageNote?: string | null | undefined;
|
||||
actionTaken?: "none" | "quarantine" | "revoke" | null | undefined;
|
||||
actionTaken?: "revoke" | "none" | "quarantine" | null | undefined;
|
||||
}[];
|
||||
nextCursor: string | null;
|
||||
done: boolean;
|
||||
@@ -834,7 +835,7 @@ export declare const ApiV1PackageReportTriageResponseSchema: import("arktype/int
|
||||
packageId: string;
|
||||
status: "open" | "confirmed" | "dismissed";
|
||||
reportCount: number;
|
||||
actionTaken?: "none" | "quarantine" | "revoke" | undefined;
|
||||
actionTaken?: "revoke" | "none" | "quarantine" | undefined;
|
||||
}, {}>;
|
||||
export type ApiV1PackageReportTriageResponse = (typeof ApiV1PackageReportTriageResponseSchema)[inferred];
|
||||
export declare const ApiV1PackageModerationStatusResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
@@ -857,7 +858,7 @@ export declare const ApiV1PackageModerationStatusResponseSchema: import("arktype
|
||||
reasons: string[];
|
||||
createdAt: number;
|
||||
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
|
||||
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
|
||||
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
|
||||
moderationReason?: string | null | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
@@ -1092,7 +1093,7 @@ export declare const ApiV1PackageModerationQueueResponseSchema: import("arktype/
|
||||
reportCount: number;
|
||||
reasons: string[];
|
||||
artifactKind?: "legacy-zip" | "npm-pack" | null | undefined;
|
||||
moderationState?: "approved" | "quarantined" | "revoked" | null | undefined;
|
||||
moderationState?: "approved" | "revoked" | "quarantined" | null | undefined;
|
||||
moderationReason?: string | null | undefined;
|
||||
sourceRepo?: string | null | undefined;
|
||||
sourceCommit?: string | null | undefined;
|
||||
@@ -1106,7 +1107,7 @@ export declare const ApiV1PackageReleaseModerationResponseSchema: import("arktyp
|
||||
ok: true;
|
||||
packageId: string;
|
||||
releaseId: string;
|
||||
state: "approved" | "quarantined" | "revoked";
|
||||
state: "approved" | "revoked" | "quarantined";
|
||||
scanStatus: "clean" | "malicious";
|
||||
}, {}>;
|
||||
export type ApiV1PackageReleaseModerationResponse = (typeof ApiV1PackageReleaseModerationResponseSchema)[inferred];
|
||||
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
import { type } from "arktype";
|
||||
import { DocsLinks } from "./docsLinks.js";
|
||||
import { CliPublishFileSchema, PublishSourceSchema } from "./schemas.js";
|
||||
export const PACKAGE_TRENDING_LEADERBOARD_LIMIT = 200;
|
||||
export function normalizePackageOwnerHandle(handle) {
|
||||
const normalized = handle?.trim().replace(/^@+/, "").toLowerCase();
|
||||
return normalized || undefined;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -2,6 +2,8 @@ import { type inferred, type } from "arktype";
|
||||
import { DocsLinks } from "./docsLinks.js";
|
||||
import { CliPublishFileSchema, PublishSourceSchema } from "./schemas.js";
|
||||
|
||||
export const PACKAGE_TRENDING_LEADERBOARD_LIMIT = 200;
|
||||
|
||||
export function normalizePackageOwnerHandle(handle: string | null | undefined) {
|
||||
const normalized = handle?.trim().replace(/^@+/, "").toLowerCase();
|
||||
return normalized || undefined;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCatalogPresentationFixtures } from "./seed-catalog-presentation";
|
||||
import { DEFAULT_PUBLIC_CORPUS_FIXTURE, parseCorpusJsonl } from "./validate";
|
||||
|
||||
describe("catalog presentation seed", () => {
|
||||
it("builds sixteen official org fixtures with featured skills and plugins", () => {
|
||||
const rows = parseCorpusJsonl(readFileSync(DEFAULT_PUBLIC_CORPUS_FIXTURE, "utf8"));
|
||||
const fixtures = buildCatalogPresentationFixtures(rows);
|
||||
|
||||
expect(fixtures).toHaveLength(16);
|
||||
expect(new Set(fixtures.map((fixture) => fixture.sourceOwnerHandle)).size).toBe(16);
|
||||
expect(new Set(fixtures.map((fixture) => fixture.handle)).size).toBe(16);
|
||||
expect(fixtures.filter((fixture) => fixture.featured)).toHaveLength(8);
|
||||
expect(fixtures.every((fixture) => fixture.skillSlug && fixture.packageName)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { assertSeedTargetAllowed } from "../seed";
|
||||
import { buildDummyOwnerPool, ownerForCorpusKey } from "./dummyOwners";
|
||||
import { DEFAULT_PUBLIC_CORPUS_FIXTURE, parseCorpusJsonl, type PublicCorpusRow } from "./validate";
|
||||
|
||||
type Options = {
|
||||
previewName: string | null;
|
||||
};
|
||||
|
||||
const ORG_IDENTITIES = [
|
||||
["catalog-atlas", "Atlas Automation"],
|
||||
["catalog-northstar", "Northstar Systems"],
|
||||
["catalog-relay", "Relay Labs"],
|
||||
["catalog-signal-forge", "Signal Forge"],
|
||||
["catalog-harbor", "Harbor AI"],
|
||||
["catalog-juniper", "Juniper Works"],
|
||||
["catalog-orbit", "Orbit Tools"],
|
||||
["catalog-prism", "Prism Stack"],
|
||||
["catalog-canvas", "Canvas Labs"],
|
||||
["catalog-threadline", "Threadline"],
|
||||
["catalog-waypoint", "Waypoint Systems"],
|
||||
["catalog-beacon", "Beacon Works"],
|
||||
["catalog-mosaic", "Mosaic AI"],
|
||||
["catalog-summit", "Summit Tools"],
|
||||
["catalog-fieldnote", "Fieldnote Labs"],
|
||||
["catalog-lattice", "Lattice Systems"],
|
||||
] as const;
|
||||
|
||||
export function buildCatalogPresentationFixtures(rows: PublicCorpusRow[]) {
|
||||
const owners = buildDummyOwnerPool();
|
||||
const rowsByOwner = new Map<
|
||||
string,
|
||||
{
|
||||
skills: Extract<PublicCorpusRow, { kind: "skill" }>[];
|
||||
plugins: Extract<PublicCorpusRow, { kind: "plugin" }>[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.kind === "skill" ? `skill:${row.slug}` : `plugin:${row.name}`;
|
||||
const owner = ownerForCorpusKey(key, owners);
|
||||
const owned = rowsByOwner.get(owner.handle) ?? { skills: [], plugins: [] };
|
||||
if (row.kind === "skill") owned.skills.push(row);
|
||||
else owned.plugins.push(row);
|
||||
rowsByOwner.set(owner.handle, owned);
|
||||
}
|
||||
|
||||
return ORG_IDENTITIES.map(([handle, displayName], index) => {
|
||||
const sourceOwner = owners[index];
|
||||
const owned = sourceOwner ? rowsByOwner.get(sourceOwner.handle) : undefined;
|
||||
const skill = owned?.skills[0];
|
||||
const plugin = owned?.plugins[0];
|
||||
if (!sourceOwner || !skill || !plugin) {
|
||||
throw new Error(`Public corpus cannot supply catalog presentation fixture ${displayName}`);
|
||||
}
|
||||
return {
|
||||
sourceOwnerHandle: sourceOwner.handle,
|
||||
handle,
|
||||
displayName,
|
||||
bio: "Synthetic official creator for local and pull request previews.",
|
||||
image: `https://api.dicebear.com/9.x/shapes/svg?seed=${encodeURIComponent(handle)}`,
|
||||
skillSlug: skill.slug,
|
||||
packageName: plugin.name,
|
||||
featured: index < 8,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Options {
|
||||
const options: Options = { previewName: null };
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--preview-name") {
|
||||
options.previewName = readValue(args, ++index, arg);
|
||||
} else if (arg.startsWith("--preview-name=")) {
|
||||
options.previewName = arg.slice("--preview-name=".length);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function readValue(args: string[], index: number, flag: string) {
|
||||
const value = args[index]?.trim();
|
||||
if (!value) throw new Error(`Missing value for ${flag}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
assertSeedTargetAllowed(options);
|
||||
|
||||
const rows = parseCorpusJsonl(readFileSync(DEFAULT_PUBLIC_CORPUS_FIXTURE, "utf8"));
|
||||
const orgs = buildCatalogPresentationFixtures(rows);
|
||||
const targetArgs = options.previewName ? ["--preview-name", options.previewName] : ["--no-push"];
|
||||
const result = spawnSync(
|
||||
"bunx",
|
||||
[
|
||||
"convex",
|
||||
"run",
|
||||
...targetArgs,
|
||||
"devSeed:seedCatalogPresentationFixtures",
|
||||
JSON.stringify({ orgs }),
|
||||
],
|
||||
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ describe("shared seed runner", () => {
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-public-corpus.ts"],
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-catalog-presentation.ts"],
|
||||
},
|
||||
{
|
||||
command: "bunx",
|
||||
args: ["convex", "run", "--no-push", "statsMaintenance:updateGlobalStatsAction"],
|
||||
@@ -29,6 +33,14 @@ describe("shared seed runner", () => {
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"],
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: [
|
||||
"scripts/public-corpus/seed-catalog-presentation.ts",
|
||||
"--preview-name",
|
||||
"feature/demo",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "bunx",
|
||||
args: [
|
||||
|
||||
@@ -46,6 +46,10 @@ export function buildSeedSteps(options: SeedOptions): SeedStep[] {
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs],
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-catalog-presentation.ts", ...corpusTargetArgs],
|
||||
},
|
||||
{
|
||||
command: "bunx",
|
||||
args: ["convex", "run", ...convexTargetArgs, "statsMaintenance:updateGlobalStatsAction"],
|
||||
|
||||
@@ -19,7 +19,10 @@ Local fixture seeding is command-driven by default:
|
||||
- CLI seeding (`bun run seed:dev`) runs the same seed path manually without starting the preview and
|
||||
bypasses the first-run sentinel.
|
||||
- `bun run seed` is the shared seed pipeline used after local setup and by disposable PR previews.
|
||||
It installs the same moderation fixtures and committed public corpus, then refreshes global stats.
|
||||
It installs the same moderation fixtures and committed public corpus, creates deterministic
|
||||
catalog presentation fixtures, then refreshes global stats. The presentation pass creates 16
|
||||
synthetic official organizations with real corpus-backed skills and plugins; the first eight of
|
||||
each type are highlighted so Featured and Official creators render in local and PR previews.
|
||||
Without `--preview-name` it accepts only a local Convex deployment; remote use requires an
|
||||
explicit preview name plus a Convex Preview deploy key. Vercel recreates that preview deployment
|
||||
before invoking the shared seed, so the corpus import does not perform a destructive reset.
|
||||
|
||||
@@ -51,6 +51,51 @@ vi.mock("../lib/packageApi", () => ({
|
||||
|
||||
import { HomeListingSection } from "../components/HomeListingSection";
|
||||
|
||||
const featuredPlugin = {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin" as const,
|
||||
channel: "community" as const,
|
||||
isOfficial: false,
|
||||
summary: "Runs workflows.",
|
||||
icon: "https://example.com/demo-plugin.png",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestVersion: "1.0.0",
|
||||
stats: { stars: 8, downloads: 120, installs: 120, versions: 1 },
|
||||
};
|
||||
|
||||
function initialPluginListing({
|
||||
items = [featuredPlugin],
|
||||
pluginsFeatured = true,
|
||||
skillsFeatured = true,
|
||||
}: {
|
||||
items?: (typeof featuredPlugin)[];
|
||||
pluginsFeatured?: boolean;
|
||||
skillsFeatured?: boolean;
|
||||
} = {}) {
|
||||
return {
|
||||
kind: "plugins" as const,
|
||||
tab: pluginsFeatured ? ("featured" as const) : ("popular" as const),
|
||||
categorySlugs: [] as [],
|
||||
fetchLimit: 20 as const,
|
||||
items,
|
||||
hasMore: false,
|
||||
featuredAvailability: {
|
||||
plugins: pluginsFeatured,
|
||||
skills: skillsFeatured,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderSkillsListing() {
|
||||
const result = render(
|
||||
<HomeListingSection initialListing={initialPluginListing({ skillsFeatured: false })} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("HomeListingSection", () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
@@ -90,16 +135,80 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the listing toolbar and skill cards by default", async () => {
|
||||
render(<HomeListingSection />);
|
||||
it("renders Featured plugins as cards by default", async () => {
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
|
||||
expect(screen.getByRole("group", { name: "Content type" })).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Trending" })).toBeTruthy();
|
||||
const contentTypeButtons = screen
|
||||
.getByRole("group", { name: "Content type" })
|
||||
.querySelectorAll("button");
|
||||
expect(Array.from(contentTypeButtons, (button) => button.textContent)).toEqual([
|
||||
"Plugins",
|
||||
"Skills",
|
||||
]);
|
||||
expect(screen.getByRole("button", { name: "Plugins" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Featured" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Grid view" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
|
||||
"Featured",
|
||||
"Top",
|
||||
"Trending",
|
||||
]);
|
||||
expect(screen.queryByRole("tab", { name: "New" })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "Verified" })).toBeNull();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-listing-grid")).toBeTruthy();
|
||||
expect(document.querySelector(".marketplace-icon-image")?.getAttribute("src")).toBe(
|
||||
featuredPlugin.icon,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides Featured and selects Top when plugins have no Featured results", () => {
|
||||
render(
|
||||
<HomeListingSection
|
||||
initialListing={initialPluginListing({ items: [], pluginsFeatured: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("tab", { name: "Featured" })).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "Top" }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Top", "Trending"]);
|
||||
});
|
||||
|
||||
it("selects Featured when switching to skills that have Featured results", async () => {
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Featured" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
|
||||
"Featured",
|
||||
"Top",
|
||||
"Trending",
|
||||
]);
|
||||
expect(screen.queryByRole("tab", { name: "New" })).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Skill")).toBeTruthy();
|
||||
expect(convexQueryMock).toHaveBeenCalledWith(
|
||||
"skills:listPublicPageV4",
|
||||
expect.objectContaining({ highlightedOnly: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Featured and selects Top when skills have no Featured results", () => {
|
||||
render(<HomeListingSection initialListing={initialPluginListing({ skillsFeatured: false })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
|
||||
|
||||
expect(screen.queryByRole("tab", { name: "Featured" })).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "Top" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("previews long skill and plugin names while retaining their full labels", async () => {
|
||||
const skillName = "S".repeat(71);
|
||||
const pluginName = "P".repeat(71);
|
||||
@@ -135,7 +244,7 @@ describe("HomeListingSection", () => {
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`${"S".repeat(69)}…`).getAttribute("title")).toBe(skillName);
|
||||
@@ -149,7 +258,7 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the initial Skills Top listing without refetching on mount", async () => {
|
||||
it("renders an initial Skills Top listing without refetching on mount", async () => {
|
||||
render(
|
||||
<HomeListingSection
|
||||
initialListing={{
|
||||
@@ -176,6 +285,10 @@ describe("HomeListingSection", () => {
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
featuredAvailability: {
|
||||
plugins: true,
|
||||
skills: true,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -187,10 +300,8 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to plugins and loads plugin cards", async () => {
|
||||
render(<HomeListingSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
it("loads the plugin Top tab", async () => {
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Top" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -214,7 +325,7 @@ describe("HomeListingSection", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
expect(document.querySelector(".home-v2-listing-search.is-open")).toBeTruthy();
|
||||
@@ -253,7 +364,7 @@ describe("HomeListingSection", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Category" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Development" }));
|
||||
@@ -292,7 +403,7 @@ describe("HomeListingSection", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
const searchInput = await screen.findByRole("searchbox", { name: "Search skills" });
|
||||
@@ -315,7 +426,7 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
|
||||
it("renders the canonical skill and plugin category definitions", async () => {
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Skill").textContent).toBe("Demo Skill");
|
||||
@@ -360,7 +471,7 @@ describe("HomeListingSection", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Skill 0")).toBeTruthy();
|
||||
@@ -410,7 +521,7 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Demo Skill")).toBeTruthy();
|
||||
@@ -463,7 +574,7 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Top Skill")).toBeTruthy();
|
||||
@@ -485,118 +596,21 @@ describe("HomeListingSection", () => {
|
||||
expect(convexQueryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps pending and suspicious audits out of skill New", async () => {
|
||||
convexQueryMock.mockResolvedValue({
|
||||
page: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:pending",
|
||||
slug: "pending-skill",
|
||||
displayName: "Pending Skill",
|
||||
githubScanStatus: "pending",
|
||||
createdAt: 30,
|
||||
updatedAt: 30,
|
||||
stats: { installs: 0 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:moderated-suspicious",
|
||||
slug: "moderated-suspicious-skill",
|
||||
displayName: "Moderated Suspicious Skill",
|
||||
isSuspicious: true,
|
||||
createdAt: 25,
|
||||
updatedAt: 25,
|
||||
stats: { installs: 0 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:suspicious",
|
||||
slug: "suspicious-skill",
|
||||
displayName: "Suspicious Skill",
|
||||
githubScanStatus: "suspicious",
|
||||
createdAt: 20,
|
||||
updatedAt: 20,
|
||||
stats: { installs: 0 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:clean",
|
||||
slug: "clean-skill",
|
||||
displayName: "Clean Skill",
|
||||
githubScanStatus: "clean",
|
||||
createdAt: 10,
|
||||
updatedAt: 10,
|
||||
stats: { installs: 0 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "New" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Clean Skill")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("Pending Skill")).toBeNull();
|
||||
expect(screen.queryByText("Suspicious Skill")).toBeNull();
|
||||
expect(screen.queryByText("Moderated Suspicious Skill")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks the plugin catalog to exclude pending and suspicious audits from New", async () => {
|
||||
render(<HomeListingSection />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "New" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
excludedScanStatuses: ["pending", "suspicious"],
|
||||
sort: "updated",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pending and suspicious audits out of New search", async () => {
|
||||
it("keeps Featured active for skill search", async () => {
|
||||
convexActionMock.mockResolvedValue([
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:pending-search",
|
||||
slug: "pending-search",
|
||||
displayName: "Pending Search Skill",
|
||||
githubScanStatus: "pending",
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
stats: { installs: 0 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:clean-search",
|
||||
slug: "clean-search",
|
||||
displayName: "Clean Search Skill",
|
||||
githubScanStatus: "clean",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
_id: "skills:featured-search",
|
||||
slug: "featured-search",
|
||||
displayName: "Featured Search Skill",
|
||||
stats: { installs: 0 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "New" }));
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search catalog" }));
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } });
|
||||
|
||||
@@ -604,90 +618,85 @@ describe("HomeListingSection", () => {
|
||||
expect(convexActionMock).toHaveBeenCalledWith("search:searchSkills", {
|
||||
query: "search",
|
||||
limit: 20,
|
||||
nonSuspiciousOnly: true,
|
||||
excludePendingScan: true,
|
||||
highlightedOnly: true,
|
||||
});
|
||||
expect(screen.getByText("Clean Search Skill")).toBeTruthy();
|
||||
expect(screen.getByText("Featured Search Skill")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("Pending Search Skill")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "New" }));
|
||||
await waitFor(() =>
|
||||
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
excludedScanStatuses: ["pending", "suspicious"],
|
||||
q: "search",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("requests official plugins from the catalog API", async () => {
|
||||
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("requests trending plugins from the catalog API", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
name: "community-plugin",
|
||||
displayName: "Community Plugin",
|
||||
name: "trending-plugin",
|
||||
displayName: "Trending Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
stats: { stars: 1, downloads: 2, installs: 0, versions: 1 },
|
||||
},
|
||||
{
|
||||
name: "official-plugin",
|
||||
displayName: "Official Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
stats: { stars: 4, downloads: 8, installs: 0, versions: 1 },
|
||||
stats: { stars: 4, downloads: 8, installs: 9, versions: 1 },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Verified" }));
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Trending" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Official Plugin").textContent).toBe("Official Plugin");
|
||||
expect(screen.getByText("Trending Plugin").textContent).toBe("Trending Plugin");
|
||||
});
|
||||
expect(screen.queryByText("Community Plugin")).toBeNull();
|
||||
const latestRequest = fetchPluginCatalogMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(latestRequest).toEqual(expect.objectContaining({ isOfficial: true, limit: 20 }));
|
||||
expect(latestRequest).toEqual(expect.objectContaining({ sort: "trending", limit: 20 }));
|
||||
});
|
||||
|
||||
it("reuses cached plugin tabs instead of refetching when switching back", async () => {
|
||||
fetchPluginCatalogMock.mockImplementation((args: { isOfficial?: boolean }) =>
|
||||
fetchPluginCatalogMock.mockImplementation((args: { sort?: string }) =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
name: args.isOfficial ? "official-plugin" : "top-plugin",
|
||||
displayName: args.isOfficial ? "Official Plugin" : "Top Plugin",
|
||||
name: args.sort === "trending" ? "trending-plugin" : "top-plugin",
|
||||
displayName: args.sort === "trending" ? "Trending Plugin" : "Top Plugin",
|
||||
family: "code-plugin",
|
||||
channel: args.isOfficial ? "official" : "community",
|
||||
isOfficial: Boolean(args.isOfficial),
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Cached plugin.",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestVersion: "1.0.0",
|
||||
stats: { stars: 1, downloads: 2, installs: args.isOfficial ? 50 : 75, versions: 1 },
|
||||
stats: {
|
||||
stars: 1,
|
||||
downloads: 2,
|
||||
installs: args.sort === "trending" ? 50 : 75,
|
||||
versions: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<HomeListingSection />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugins" }));
|
||||
render(<HomeListingSection initialListing={initialPluginListing()} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Trending" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Official Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Trending Plugin")).toBeTruthy();
|
||||
});
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -698,10 +707,10 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Verified" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Trending" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Official Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Trending Plugin")).toBeTruthy();
|
||||
});
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -744,7 +753,7 @@ describe("HomeListingSection", () => {
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Skill")).toBeTruthy();
|
||||
@@ -788,7 +797,7 @@ describe("HomeListingSection", () => {
|
||||
return Promise.resolve({ page: development, hasMore: false, nextCursor: null });
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("development Skill 0")).toBeTruthy();
|
||||
@@ -841,7 +850,7 @@ describe("HomeListingSection", () => {
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(<HomeListingSection />);
|
||||
renderSkillsListing();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Inferred Skill")).toBeTruthy();
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const convexQueryMock = vi.fn();
|
||||
const convexActionMock = vi.fn();
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: { query: (...args: unknown[]) => convexQueryMock(...args) },
|
||||
convexHttp: { action: (...args: unknown[]) => convexActionMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: { publishers: { getHomePublisherSummaries: "publishers:getHomePublisherSummaries" } },
|
||||
api: {
|
||||
publishers: {
|
||||
getHomeOfficialCreatorSummaries: "publishers:getHomeOfficialCreatorSummaries",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
@@ -18,6 +22,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
children,
|
||||
className,
|
||||
params,
|
||||
search,
|
||||
to: _to,
|
||||
...props
|
||||
}: {
|
||||
@@ -25,11 +30,13 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
className?: string;
|
||||
params?: { handle?: string; slug?: string };
|
||||
to?: string;
|
||||
search?: unknown;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<a
|
||||
{...props}
|
||||
className={className}
|
||||
data-search={search ? JSON.stringify(search) : undefined}
|
||||
href={
|
||||
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/creators"
|
||||
}
|
||||
@@ -45,8 +52,8 @@ describe("HomePopularPublishersSection", () => {
|
||||
let intersectionCallback: IntersectionObserverCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
convexQueryMock.mockReset();
|
||||
convexQueryMock.mockResolvedValue(null);
|
||||
convexActionMock.mockReset();
|
||||
convexActionMock.mockResolvedValue([]);
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
@@ -75,65 +82,95 @@ describe("HomePopularPublishersSection", () => {
|
||||
});
|
||||
};
|
||||
|
||||
it("loads all pinned publisher summaries once when the section nears the viewport", async () => {
|
||||
convexQueryMock.mockResolvedValue([
|
||||
it("loads the top twelve official creators once when the section nears the viewport", async () => {
|
||||
convexActionMock.mockResolvedValue(
|
||||
Array.from({ length: 12 }, (_, index) => ({
|
||||
_id: `publishers:org-${index}`,
|
||||
_creationTime: index,
|
||||
handle: `org-${index}`,
|
||||
displayName: `Official Org ${index}`,
|
||||
kind: "org",
|
||||
stats: {
|
||||
skills: 2,
|
||||
packages: 1,
|
||||
installs: 12 - index,
|
||||
downloads: 4,
|
||||
stars: 5,
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
render(<HomePopularPublishersSection />);
|
||||
|
||||
expect(convexActionMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("heading", { name: "Official creators" })).toBeTruthy();
|
||||
expect(screen.getByText("Explore skills and plugins from official creators.")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Browse creators" }).dataset.search).toBe(
|
||||
'{"official":true,"kind":"orgs"}',
|
||||
);
|
||||
|
||||
await enterPublisherSection();
|
||||
await waitFor(() => expect(convexActionMock).toHaveBeenCalledTimes(1));
|
||||
expect(convexActionMock).toHaveBeenCalledWith("publishers:getHomeOfficialCreatorSummaries", {
|
||||
limit: 12,
|
||||
});
|
||||
expect(screen.getAllByRole("link", { name: /Official Org/ })).toHaveLength(12);
|
||||
expect(screen.getByText("12 installs")).toBeTruthy();
|
||||
expect(screen.getByText("1 install")).toBeTruthy();
|
||||
expect(document.querySelectorAll(".home-v2-popular-publisher-card")).toHaveLength(12);
|
||||
expect(
|
||||
Array.from(document.querySelectorAll(".home-v2-popular-publisher-card"), (card) =>
|
||||
card.getAttribute("aria-label"),
|
||||
),
|
||||
).toEqual(Array.from({ length: 12 }, (_, index) => `Official Org ${index}, @org-${index}`));
|
||||
|
||||
await enterPublisherSection();
|
||||
expect(convexActionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries a failed official creator request", async () => {
|
||||
convexActionMock.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce([
|
||||
{
|
||||
_id: "publishers:openclaw",
|
||||
_creationTime: 1,
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw Registry",
|
||||
displayName: "OpenClaw",
|
||||
kind: "org",
|
||||
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
|
||||
},
|
||||
]);
|
||||
|
||||
render(<HomePopularPublishersSection />);
|
||||
|
||||
expect(convexQueryMock).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByText("Explore creator")).toHaveLength(10);
|
||||
|
||||
await enterPublisherSection();
|
||||
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
|
||||
expect(convexQueryMock).toHaveBeenCalledWith("publishers:getHomePublisherSummaries", {
|
||||
handles: [
|
||||
"openclaw",
|
||||
"nvidia",
|
||||
"steipete",
|
||||
"mvanhorn",
|
||||
"wscats",
|
||||
"ivangdavila",
|
||||
"byungkyu",
|
||||
"pskoett",
|
||||
"1kalin",
|
||||
"spclaudehome",
|
||||
],
|
||||
});
|
||||
expect(screen.getByRole("link", { name: "OpenClaw Registry, @openclaw" })).toBeTruthy();
|
||||
expect(screen.getByText("Explore 3 items")).toBeTruthy();
|
||||
|
||||
await enterPublisherSection();
|
||||
expect(convexQueryMock).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(convexActionMock).toHaveBeenCalledTimes(1));
|
||||
expect(document.querySelectorAll(".home-v2-popular-publisher-card")).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => expect(convexActionMock).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByRole("link", { name: "OpenClaw, @openclaw" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps static publisher cards when summary loading fails", async () => {
|
||||
convexQueryMock.mockRejectedValue(new Error("offline"));
|
||||
|
||||
render(<HomePopularPublishersSection />);
|
||||
await enterPublisherSection();
|
||||
|
||||
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByRole("link", { name: "OpenClaw, @openclaw" })).toBeTruthy();
|
||||
expect(screen.getAllByText("Explore creator")).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("keeps creator cards clickable until the pointer actually drags", () => {
|
||||
it("keeps creator cards clickable until the pointer actually drags", async () => {
|
||||
convexActionMock.mockResolvedValue([
|
||||
{
|
||||
_id: "publishers:openclaw",
|
||||
_creationTime: 1,
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
kind: "org",
|
||||
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
|
||||
},
|
||||
]);
|
||||
const setPointerCapture = vi.fn();
|
||||
const hasPointerCapture = vi.fn(() => false);
|
||||
const releasePointerCapture = vi.fn();
|
||||
|
||||
render(<HomePopularPublishersSection />);
|
||||
await enterPublisherSection();
|
||||
|
||||
const card = screen.getByRole("link", { name: "OpenClaw, @openclaw" });
|
||||
const card = await screen.findByRole("link", { name: "OpenClaw, @openclaw" });
|
||||
expect(card.getAttribute("href")).toBe("/openclaw");
|
||||
const viewport = document.querySelector(".home-v2-popular-publishers-viewport");
|
||||
expect(viewport).toBeTruthy();
|
||||
|
||||
@@ -5,22 +5,26 @@ import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const initialListingFixture = {
|
||||
kind: "skills",
|
||||
tab: "popular",
|
||||
kind: "plugins",
|
||||
tab: "featured",
|
||||
categorySlugs: [],
|
||||
fetchLimit: 20,
|
||||
items: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:initial",
|
||||
slug: "initial-skill",
|
||||
displayName: "Initial Skill",
|
||||
stats: { installs: 10 },
|
||||
},
|
||||
ownerHandle: "builder",
|
||||
name: "initial-plugin",
|
||||
displayName: "Initial Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
featuredAvailability: {
|
||||
plugins: true,
|
||||
skills: true,
|
||||
},
|
||||
};
|
||||
|
||||
const homeListingSectionMock = vi.fn();
|
||||
|
||||
@@ -347,13 +347,25 @@ describe("restored UI design contract", () => {
|
||||
expect(homeSource).toContain('className="home-v2-main oc-app-surface"');
|
||||
expect(homeSource).toContain("home-v2-headline oc-hero-title");
|
||||
expect(listingSource).toContain("home-v2-listing-card oc-card oc-card-interactive");
|
||||
expect(listingSource).toContain("home-v2-listing-kind clawhub-segmented");
|
||||
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(appsSource).toContain('className="home-v2-apps-tile"');
|
||||
expect(appsSource).toContain('className="home-v2-apps-workflow-header"');
|
||||
expect(appsSource).not.toContain('className="home-v2-apps-workflow-header oc-card"');
|
||||
expect(publishersSource).toContain(
|
||||
"home-v2-popular-publisher-card oc-card oc-card-interactive",
|
||||
);
|
||||
expect(publishersSource).toContain("Official creators");
|
||||
expect(publishersSource).toContain("Explore skills and plugins from official creators.");
|
||||
expect(cssRule(css, ".home-v2-popular-publishers-track")).toContain(
|
||||
"grid-template-columns: repeat(6, minmax(0, 1fr))",
|
||||
);
|
||||
expect(homeSource).not.toContain("BUILT BY THE COMMUNITY");
|
||||
expect(homeSource).not.toContain("Unleash.");
|
||||
expect(homeSource).not.toContain("Ship.");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { isPluginCategorySlug, isSkillCategorySlug } from "clawhub-schema";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Binoculars,
|
||||
CloudOff,
|
||||
Download,
|
||||
@@ -27,16 +26,13 @@ import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { PLUGIN_CATEGORIES, SKILL_CATEGORIES, type BrowseCategory } from "../lib/categories";
|
||||
import {
|
||||
filterHomePluginsByTab as filterPluginsByTab,
|
||||
filterHomeSkillsByTab as filterSkillsByTab,
|
||||
fetchHomeFeaturedAvailability as fetchFeaturedAvailability,
|
||||
fetchHomePluginListing as fetchPluginListing,
|
||||
fetchHomeSkillListing as fetchSkillListing,
|
||||
HOME_LISTING_PAGE_SIZE,
|
||||
homeListingCacheKey as listingCacheKey,
|
||||
isNewHomeSkillEligible as isNewSkillEligible,
|
||||
itemMatchesAnyHomeCategory as itemMatchesAnyCategory,
|
||||
skillMatchesAnyHomeCategory as skillMatchesAnyCategory,
|
||||
sortHomeSkillEntries as sortSkillEntries,
|
||||
uniqueHomePlugins as uniquePlugins,
|
||||
uniqueHomeSkillEntries as uniqueSkillEntries,
|
||||
type HomeListingCacheEntry,
|
||||
@@ -58,15 +54,15 @@ import { BrowseResultsSkeleton } from "./skeletons/BrowseResultsSkeleton";
|
||||
type ListingView = "list" | "grid";
|
||||
|
||||
const SKILL_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [
|
||||
{ id: "featured", label: "Featured" },
|
||||
{ id: "popular", label: "Top" },
|
||||
{ id: "trending", label: "Trending" },
|
||||
{ id: "new", label: "New" },
|
||||
];
|
||||
|
||||
const PLUGIN_LISTING_TABS: Array<{ id: ListingTab; label: string }> = [
|
||||
{ id: "officials", label: "Verified" },
|
||||
{ id: "featured", label: "Featured" },
|
||||
{ id: "popular", label: "Top" },
|
||||
{ id: "new", label: "New" },
|
||||
{ id: "trending", label: "Trending" },
|
||||
];
|
||||
|
||||
const LISTING_PAGE_SIZE = HOME_LISTING_PAGE_SIZE;
|
||||
@@ -229,7 +225,13 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
|
||||
return (
|
||||
<Link to={pluginHref} className="home-v2-listing-row">
|
||||
<span className="home-v2-listing-row-icon" aria-hidden="true">
|
||||
<MarketplaceIcon kind="plugin" label={name} size="sm" />
|
||||
<MarketplaceIcon
|
||||
kind="plugin"
|
||||
label={name}
|
||||
imageUrl={plugin.icon}
|
||||
categorySlug={plugin.categories?.[0]}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<div className="home-v2-listing-row-body">
|
||||
<div className="home-v2-listing-row-title">
|
||||
@@ -304,7 +306,13 @@ function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
|
||||
<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} size="sm" />
|
||||
<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}>
|
||||
@@ -345,11 +353,17 @@ function createInitialListingCache(initialListing: HomeListingInitialData | null
|
||||
categorySlugs: initialListing.categorySlugs,
|
||||
fetchLimit: initialListing.fetchLimit,
|
||||
}),
|
||||
{
|
||||
kind: "skills",
|
||||
items: initialListing.items,
|
||||
hasMore: initialListing.hasMore,
|
||||
},
|
||||
initialListing.kind === "skills"
|
||||
? {
|
||||
kind: "skills",
|
||||
items: initialListing.items,
|
||||
hasMore: initialListing.hasMore,
|
||||
}
|
||||
: {
|
||||
kind: "plugins",
|
||||
items: initialListing.items,
|
||||
hasMore: initialListing.hasMore,
|
||||
},
|
||||
);
|
||||
return cache;
|
||||
}
|
||||
@@ -361,14 +375,26 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
listingCacheRef.current ??= createInitialListingCache(initialListing);
|
||||
const listingCache = listingCacheRef.current;
|
||||
|
||||
const [kind, setKind] = useState<ListingKind>("skills");
|
||||
const [tab, setTab] = useState<ListingTab>("popular");
|
||||
const [view, setView] = useState<ListingView>("list");
|
||||
const [kind, setKind] = useState<ListingKind>(initialListing?.kind ?? "plugins");
|
||||
const [tab, setTab] = useState<ListingTab>(initialListing?.tab ?? "featured");
|
||||
const [view, setView] = useState<ListingView>("grid");
|
||||
const [categorySlugs, setCategorySlugs] = useState<string[]>([]);
|
||||
const [visibleCount, setVisibleCount] = useState(LISTING_PAGE_SIZE);
|
||||
const [fetchLimit, setFetchLimit] = useState(LISTING_PAGE_SIZE);
|
||||
const [skills, setSkills] = useState<SkillPageEntry[]>(initialListing?.items ?? []);
|
||||
const [plugins, setPlugins] = useState<PackageListItem[]>([]);
|
||||
const [skills, setSkills] = useState<SkillPageEntry[]>(
|
||||
initialListing?.kind === "skills" ? initialListing.items : [],
|
||||
);
|
||||
const [plugins, setPlugins] = useState<PackageListItem[]>(
|
||||
initialListing?.kind === "plugins" ? initialListing.items : [],
|
||||
);
|
||||
const [featuredAvailability, setFeaturedAvailability] = useState<
|
||||
Record<ListingKind, boolean | null>
|
||||
>(
|
||||
initialListing?.featuredAvailability ?? {
|
||||
plugins: null,
|
||||
skills: null,
|
||||
},
|
||||
);
|
||||
const [status, setStatus] = useState<"loading" | "idle" | "error">(
|
||||
initialListing ? "idle" : "loading",
|
||||
);
|
||||
@@ -392,20 +418,14 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
[categorySlugs, listingCategories],
|
||||
);
|
||||
|
||||
const filteredSearchSkills = useMemo(
|
||||
() => filterSkillsByTab(searchSkills, tab),
|
||||
[searchSkills, tab],
|
||||
const visibleTabs = (kind === "skills" ? SKILL_LISTING_TABS : PLUGIN_LISTING_TABS).filter(
|
||||
(item) => item.id !== "featured" || featuredAvailability[kind] === true,
|
||||
);
|
||||
const filteredSearchPlugins = useMemo(
|
||||
() => filterPluginsByTab(searchPlugins, tab),
|
||||
[searchPlugins, tab],
|
||||
);
|
||||
const visibleTabs = kind === "skills" ? SKILL_LISTING_TABS : PLUGIN_LISTING_TABS;
|
||||
|
||||
const activeItems = isSearchMode
|
||||
? kind === "skills"
|
||||
? filteredSearchSkills
|
||||
: filteredSearchPlugins
|
||||
? searchSkills
|
||||
: searchPlugins
|
||||
: kind === "skills"
|
||||
? skills
|
||||
: plugins;
|
||||
@@ -448,6 +468,26 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [searchOpen, trimmedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (featuredAvailability[kind] !== null) return undefined;
|
||||
const controller = new AbortController();
|
||||
fetchFeaturedAvailability(kind, controller.signal)
|
||||
.then((available) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFeaturedAvailability((current) => ({ ...current, [kind]: available }));
|
||||
if (!available) {
|
||||
setTab((current) => (current === "featured" ? "popular" : current));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFeaturedAvailability((current) => ({ ...current, [kind]: false }));
|
||||
setTab((current) => (current === "featured" ? "popular" : current));
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [featuredAvailability, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchMode) return undefined;
|
||||
const cacheKey = listingCacheKey({ kind, tab, categorySlugs, fetchLimit });
|
||||
@@ -546,7 +586,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
convexHttp.action(api.search.searchSkills, {
|
||||
query: trimmedSearch,
|
||||
limit: fetchLimit,
|
||||
...(tab === "new" ? { nonSuspiciousOnly: true, excludePendingScan: true } : {}),
|
||||
highlightedOnly: tab === "featured" ? true : undefined,
|
||||
...(categorySlug ? { categorySlug } : {}),
|
||||
}),
|
||||
),
|
||||
@@ -560,17 +600,12 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
ownerHandle: hit.ownerHandle,
|
||||
owner: hit.owner,
|
||||
}))
|
||||
.filter(
|
||||
(entry) =>
|
||||
skillMatchesAnyCategory(entry.skill, categorySlugs) &&
|
||||
(tab !== "new" || isNewSkillEligible(entry.skill)),
|
||||
),
|
||||
.filter((entry) => skillMatchesAnyCategory(entry.skill, categorySlugs)),
|
||||
),
|
||||
);
|
||||
const sortedRows = tab === "new" ? sortSkillEntries(rows, tab) : rows;
|
||||
const items = sortedRows.slice(0, fetchLimit);
|
||||
const items = rows.slice(0, fetchLimit);
|
||||
const hasMore =
|
||||
sortedRows.length > fetchLimit ||
|
||||
rows.length > fetchLimit ||
|
||||
results.some((hits) => (hits as SkillSearchHit[]).length >= fetchLimit);
|
||||
setSearchSkills(items);
|
||||
setListingHasMore(hasMore);
|
||||
@@ -581,9 +616,8 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
fetchPluginCatalog({
|
||||
q: trimmedSearch,
|
||||
category: categorySlug ?? undefined,
|
||||
isOfficial: tab === "officials" ? true : undefined,
|
||||
excludedScanStatuses: tab === "new" ? ["pending", "suspicious"] : undefined,
|
||||
sort: tab === "new" ? "updated" : "downloads",
|
||||
featured: tab === "featured" ? true : undefined,
|
||||
sort: tab === "trending" ? "trending" : "downloads",
|
||||
limit: fetchLimit,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
@@ -595,12 +629,10 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
result.items.filter((item) => itemMatchesAnyCategory(item, categorySlugs)),
|
||||
),
|
||||
);
|
||||
const sortedItems =
|
||||
tab === "new" ? [...items].sort((a, b) => b.updatedAt - a.updatedAt) : items;
|
||||
const hasMore = results.some(
|
||||
(result) => result.nextCursor != null || result.items.length >= fetchLimit,
|
||||
);
|
||||
setSearchPlugins(sortedItems);
|
||||
setSearchPlugins(items);
|
||||
setListingHasMore(hasMore);
|
||||
setSearchStatus("idle");
|
||||
});
|
||||
@@ -639,8 +671,8 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
setFetchLimit(LISTING_PAGE_SIZE);
|
||||
}, [categorySlugs, isSearchMode, kind, tab, trimmedSearch, view]);
|
||||
|
||||
const visibleSkills = (isSearchMode ? filteredSearchSkills : skills).slice(0, visibleCount);
|
||||
const visiblePlugins = (isSearchMode ? filteredSearchPlugins : plugins).slice(0, visibleCount);
|
||||
const visibleSkills = (isSearchMode ? searchSkills : skills).slice(0, visibleCount);
|
||||
const visiblePlugins = (isSearchMode ? searchPlugins : plugins).slice(0, visibleCount);
|
||||
|
||||
const handleSeeMore = () => {
|
||||
setVisibleCount((count) => count + LISTING_PAGE_SIZE);
|
||||
@@ -661,8 +693,8 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
if (nextKind === kind) return;
|
||||
setKind(nextKind);
|
||||
setCategorySlugs([]);
|
||||
if (nextKind === "plugins") setTab("officials");
|
||||
else if (tab === "officials") setTab("popular");
|
||||
const nextFeaturedAvailability = featuredAvailability[nextKind];
|
||||
setTab(nextFeaturedAvailability === false ? "popular" : "featured");
|
||||
};
|
||||
|
||||
const removeCategory = (slug: string) => {
|
||||
@@ -678,23 +710,13 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
<div className="home-v2-listing-controls">
|
||||
<div className="home-v2-listing-toolbar">
|
||||
<div
|
||||
className="home-v2-listing-kind clawhub-segmented"
|
||||
className="home-v2-listing-kind clawhub-segmented oc-segmented"
|
||||
role="group"
|
||||
aria-label="Content type"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-kind-btn clawhub-segmented-btn${
|
||||
kind === "skills" ? " is-active" : ""
|
||||
}`}
|
||||
aria-pressed={kind === "skills"}
|
||||
onClick={() => handleKindChange("skills")}
|
||||
>
|
||||
Skills
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-kind-btn clawhub-segmented-btn${
|
||||
className={`home-v2-listing-kind-btn clawhub-segmented-btn oc-segmented-item${
|
||||
kind === "plugins" ? " is-active" : ""
|
||||
}`}
|
||||
aria-pressed={kind === "plugins"}
|
||||
@@ -702,6 +724,16 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
>
|
||||
Plugins
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-kind-btn clawhub-segmented-btn oc-segmented-item${
|
||||
kind === "skills" ? " is-active" : ""
|
||||
}`}
|
||||
aria-pressed={kind === "skills"}
|
||||
onClick={() => handleKindChange("skills")}
|
||||
>
|
||||
Skills
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="home-v2-listing-divider" aria-hidden="true" />
|
||||
@@ -717,14 +749,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
className={`home-v2-listing-tab${tab === item.id ? " is-active" : ""}`}
|
||||
onClick={() => setTab(item.id)}
|
||||
>
|
||||
{item.id === "officials" ? (
|
||||
<BadgeCheck
|
||||
size={14}
|
||||
strokeWidth={2.25}
|
||||
className="home-v2-listing-tab-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -754,13 +778,13 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
/>
|
||||
|
||||
<div
|
||||
className="home-v2-listing-view clawhub-segmented"
|
||||
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${
|
||||
className={`home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item${
|
||||
view === "list" ? " is-active" : ""
|
||||
}`}
|
||||
aria-pressed={view === "list"}
|
||||
@@ -771,7 +795,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`home-v2-listing-view-btn clawhub-segmented-btn${
|
||||
className={`home-v2-listing-view-btn clawhub-segmented-btn oc-segmented-item${
|
||||
view === "grid" ? " is-active" : ""
|
||||
}`}
|
||||
aria-pressed={view === "grid"}
|
||||
|
||||
@@ -1,56 +1,32 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { type PointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, RefreshCw } from "lucide-react";
|
||||
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisherSummary } from "../lib/publicUser";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
|
||||
type PinnedPublisher = {
|
||||
handle: string;
|
||||
name: string;
|
||||
kind: "org" | "user";
|
||||
};
|
||||
const HOME_OFFICIAL_CREATOR_LIMIT = 12;
|
||||
|
||||
const PINNED_PUBLISHERS: PinnedPublisher[] = [
|
||||
{ handle: "openclaw", name: "OpenClaw", kind: "org" },
|
||||
{ handle: "nvidia", name: "NVIDIA", kind: "org" },
|
||||
{ handle: "steipete", name: "Peter Steinberger", kind: "user" },
|
||||
{ handle: "mvanhorn", name: "Matt Van Horn", kind: "user" },
|
||||
{ handle: "wscats", name: "enoyao", kind: "user" },
|
||||
{ handle: "ivangdavila", name: "Iván", kind: "user" },
|
||||
{ handle: "byungkyu", name: "byungkyu", kind: "user" },
|
||||
{ handle: "pskoett", name: "pskoett", kind: "user" },
|
||||
{ handle: "1kalin", name: "1kalin", kind: "user" },
|
||||
{ handle: "spclaudehome", name: "spclaudehome", kind: "user" },
|
||||
];
|
||||
|
||||
function PopularPublisherCard({
|
||||
pinned,
|
||||
publisher,
|
||||
}: {
|
||||
pinned: PinnedPublisher;
|
||||
publisher?: PublicPublisherSummary;
|
||||
}) {
|
||||
const name = publisher?.displayName?.trim() || pinned.name;
|
||||
const bio = publisher?.bio?.trim() || "Publisher on ClawHub.";
|
||||
const kind = publisher?.kind ?? pinned.kind;
|
||||
const itemCount = publisher ? publisher.stats.skills + publisher.stats.packages : null;
|
||||
function OfficialCreatorCard({ publisher }: { publisher: PublicPublisherSummary }) {
|
||||
const name = publisher.displayName.trim() || publisher.handle;
|
||||
const bio = publisher.bio?.trim() || "Official creator on ClawHub.";
|
||||
const installs = publisher.stats.installs;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/$slug"
|
||||
params={{ slug: pinned.handle }}
|
||||
params={{ slug: publisher.handle }}
|
||||
className="home-v2-popular-publisher-card oc-card oc-card-interactive"
|
||||
aria-label={`${name}, @${pinned.handle}`}
|
||||
aria-label={`${name}, @${publisher.handle}`}
|
||||
draggable={false}
|
||||
>
|
||||
<div className="home-v2-popular-publisher-head">
|
||||
<MarketplaceIcon
|
||||
kind={kind === "org" ? "org" : "user"}
|
||||
kind="org"
|
||||
label={name}
|
||||
imageUrl={publisher?.image ?? `https://github.com/${pinned.handle}.png`}
|
||||
imageUrl={publisher.image ?? `https://github.com/${publisher.handle}.png`}
|
||||
size="md"
|
||||
/>
|
||||
<span className="home-v2-popular-publisher-name">{name}</span>
|
||||
@@ -58,9 +34,7 @@ function PopularPublisherCard({
|
||||
<div className="home-v2-popular-publisher-copy">
|
||||
<p className="home-v2-popular-publisher-bio">{bio}</p>
|
||||
<span className="home-v2-popular-publisher-stats">
|
||||
{itemCount === null
|
||||
? "Explore creator"
|
||||
: `Explore ${formatCompactStat(itemCount)} ${itemCount === 1 ? "item" : "items"}`}
|
||||
{formatCompactStat(installs)} {installs === 1 ? "install" : "installs"}
|
||||
<ArrowRight size={13} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -73,35 +47,40 @@ export function HomePopularPublishersSection() {
|
||||
const dragRef = useRef({ pointerId: -1, startX: 0, scrollLeft: 0, moved: false });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const requestedPublishersRef = useRef(false);
|
||||
const [publishersByHandle, setPublishersByHandle] = useState<
|
||||
Record<string, PublicPublisherSummary>
|
||||
>({});
|
||||
const mountedRef = useRef(true);
|
||||
const [publishers, setPublishers] = useState<PublicPublisherSummary[]>([]);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const hydratePublishers = async () => {
|
||||
if (requestedPublishersRef.current) return;
|
||||
requestedPublishersRef.current = true;
|
||||
try {
|
||||
const publishers = (await convexHttp.query(api.publishers.getHomePublisherSummaries, {
|
||||
handles: PINNED_PUBLISHERS.map((publisher) => publisher.handle),
|
||||
})) as PublicPublisherSummary[];
|
||||
if (cancelled) return;
|
||||
setPublishersByHandle(
|
||||
Object.fromEntries(publishers.map((publisher) => [publisher.handle, publisher])),
|
||||
);
|
||||
} catch {
|
||||
// Static card metadata remains usable when summaries cannot be loaded.
|
||||
}
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hydratePublishers = useCallback(async () => {
|
||||
if (requestedPublishersRef.current) return;
|
||||
requestedPublishersRef.current = true;
|
||||
setLoadFailed(false);
|
||||
try {
|
||||
const result = (await convexHttp.action(api.publishers.getHomeOfficialCreatorSummaries, {
|
||||
limit: HOME_OFFICIAL_CREATOR_LIMIT,
|
||||
})) as PublicPublisherSummary[];
|
||||
if (!mountedRef.current) return;
|
||||
setPublishers(result);
|
||||
} catch {
|
||||
requestedPublishersRef.current = false;
|
||||
if (!mountedRef.current) return;
|
||||
setPublishers([]);
|
||||
setLoadFailed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || typeof IntersectionObserver === "undefined") {
|
||||
void hydratePublishers();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -113,12 +92,10 @@ export function HomePopularPublishersSection() {
|
||||
{ rootMargin: "600px 0px" },
|
||||
);
|
||||
observer.observe(viewport);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [hydratePublishers]);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType !== "mouse" || event.button !== 0) return;
|
||||
@@ -158,16 +135,20 @@ export function HomePopularPublishersSection() {
|
||||
return (
|
||||
<section
|
||||
className="home-v2-popular-publishers oc-section"
|
||||
aria-labelledby="popular-publishers-title"
|
||||
aria-labelledby="official-creators-title"
|
||||
>
|
||||
<header className="home-v2-popular-publishers-header oc-section-header">
|
||||
<div className="home-v2-popular-publishers-heading oc-section-heading">
|
||||
<h2 id="popular-publishers-title" className="oc-section-title">
|
||||
Popular creators
|
||||
<h2 id="official-creators-title" className="oc-section-title">
|
||||
Official creators
|
||||
</h2>
|
||||
<p className="oc-section-copy">Explore skills and plugins from standout builders.</p>
|
||||
<p className="oc-section-copy">Explore skills and plugins from official creators.</p>
|
||||
</div>
|
||||
<Link to="/creators" className="home-v2-popular-publishers-link oc-action oc-action-ghost">
|
||||
<Link
|
||||
to="/creators"
|
||||
search={{ official: true, kind: "orgs" }}
|
||||
className="home-v2-popular-publishers-link oc-action oc-action-ghost"
|
||||
>
|
||||
Browse creators <ArrowRight size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
@@ -187,14 +168,20 @@ export function HomePopularPublishersSection() {
|
||||
}}
|
||||
>
|
||||
<div className="home-v2-popular-publishers-track">
|
||||
{PINNED_PUBLISHERS.map((publisher) => (
|
||||
<PopularPublisherCard
|
||||
key={publisher.handle}
|
||||
pinned={publisher}
|
||||
publisher={publishersByHandle[publisher.handle]}
|
||||
/>
|
||||
{publishers.map((publisher) => (
|
||||
<OfficialCreatorCard key={publisher._id} publisher={publisher} />
|
||||
))}
|
||||
</div>
|
||||
{loadFailed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-popular-publishers-retry oc-action oc-action-ghost"
|
||||
onClick={() => void hydratePublishers()}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const convexQueryMock = vi.fn();
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: {
|
||||
query: (...args: unknown[]) => convexQueryMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
skills: {
|
||||
listPublicPageV4: "skills:listPublicPageV4",
|
||||
listPublicTrendingPage: "skills:listPublicTrendingPage",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./packageApi", () => ({
|
||||
fetchPluginCatalog: (...args: unknown[]) => fetchPluginCatalogMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchHomeFeaturedAvailability,
|
||||
fetchHomePluginListing,
|
||||
fetchHomeSkillListing,
|
||||
fetchInitialHomeListing,
|
||||
HOME_LISTING_PAGE_SIZE,
|
||||
} from "./homeListingData";
|
||||
|
||||
const featuredPlugin = {
|
||||
name: "featured-plugin",
|
||||
displayName: "Featured Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
describe("homeListingData", () => {
|
||||
beforeEach(() => {
|
||||
convexQueryMock.mockReset();
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
convexQueryMock.mockResolvedValue({
|
||||
page: [
|
||||
{
|
||||
skill: {
|
||||
_id: "skills:featured",
|
||||
slug: "featured-skill",
|
||||
displayName: "Featured Skill",
|
||||
stats: { downloads: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads Featured plugins as the initial catalog when they exist", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [featuredPlugin],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await expect(fetchInitialHomeListing()).resolves.toEqual({
|
||||
kind: "plugins",
|
||||
tab: "featured",
|
||||
categorySlugs: [],
|
||||
fetchLimit: HOME_LISTING_PAGE_SIZE,
|
||||
items: [featuredPlugin],
|
||||
hasMore: false,
|
||||
featuredAvailability: {
|
||||
plugins: true,
|
||||
skills: true,
|
||||
},
|
||||
});
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ featured: true, limit: HOME_LISTING_PAGE_SIZE }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to Top plugins when no Featured plugins exist", async () => {
|
||||
fetchPluginCatalogMock
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: null })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...featuredPlugin, name: "top-plugin" }],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await fetchInitialHomeListing();
|
||||
|
||||
expect(result.kind).toBe("plugins");
|
||||
expect(result.tab).toBe("popular");
|
||||
expect(result.featuredAvailability.plugins).toBe(false);
|
||||
expect(fetchPluginCatalogMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sort: "downloads", featured: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the highlighted browse path for Featured skills", async () => {
|
||||
await fetchHomeSkillListing("featured", [], HOME_LISTING_PAGE_SIZE);
|
||||
|
||||
expect(convexQueryMock).toHaveBeenCalledWith(
|
||||
"skills:listPublicPageV4",
|
||||
expect.objectContaining({
|
||||
highlightedOnly: true,
|
||||
numItems: 200,
|
||||
sort: "downloads",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a one-item request when probing Featured skill availability", async () => {
|
||||
await expect(fetchHomeFeaturedAvailability("skills")).resolves.toBe(true);
|
||||
|
||||
expect(convexQueryMock).toHaveBeenCalledWith(
|
||||
"skills:listPublicPageV4",
|
||||
expect.objectContaining({
|
||||
highlightedOnly: true,
|
||||
numItems: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves global Trending order while filtering multiple plugin categories", async () => {
|
||||
fetchPluginCatalogMock
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ ...featuredPlugin, name: "unmatched-first", categories: ["productivity"] },
|
||||
{ ...featuredPlugin, name: "security-second", categories: ["security"] },
|
||||
{ ...featuredPlugin, name: "development-third", categories: ["development"] },
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ ...featuredPlugin, name: "security-fourth", categories: ["security"] },
|
||||
{ ...featuredPlugin, name: "unmatched-fifth", categories: ["productivity"] },
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await fetchHomePluginListing("trending", ["development", "security"], 3);
|
||||
|
||||
expect(result.items.map((item) => item.name)).toEqual([
|
||||
"security-second",
|
||||
"development-third",
|
||||
"security-fourth",
|
||||
]);
|
||||
expect(fetchPluginCatalogMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
category: undefined,
|
||||
sort: "trending",
|
||||
limit: 100,
|
||||
}),
|
||||
);
|
||||
expect(fetchPluginCatalogMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
category: undefined,
|
||||
cursor: "page-2",
|
||||
sort: "trending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the API category filter for a single Trending plugin category", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [{ ...featuredPlugin, name: "security-plugin", categories: ["security"] }],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await fetchHomePluginListing("trending", ["security"], 3);
|
||||
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
category: "security",
|
||||
sort: "trending",
|
||||
limit: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters the complete bounded Trending leaderboard for multiple categories", async () => {
|
||||
fetchPluginCatalogMock
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 100 }, (_, index) => ({
|
||||
...featuredPlugin,
|
||||
name: `unmatched-${index}`,
|
||||
categories: ["other"],
|
||||
})),
|
||||
nextCursor: "page-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
...Array.from({ length: 99 }, (_, index) => ({
|
||||
...featuredPlugin,
|
||||
name: `unmatched-${index + 100}`,
|
||||
categories: ["other"],
|
||||
})),
|
||||
{ ...featuredPlugin, name: "security-last", categories: ["security"] },
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await fetchHomePluginListing("trending", ["development", "security"], 20);
|
||||
|
||||
expect(result.items.map((item) => item.name)).toEqual(["security-last"]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails closed if the Trending API exceeds its shared leaderboard contract", async () => {
|
||||
fetchPluginCatalogMock
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 100 }, (_, index) => ({
|
||||
...featuredPlugin,
|
||||
name: `unmatched-${index}`,
|
||||
categories: ["other"],
|
||||
})),
|
||||
nextCursor: "page-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 100 }, (_, index) => ({
|
||||
...featuredPlugin,
|
||||
name: `unmatched-${index + 100}`,
|
||||
categories: ["other"],
|
||||
})),
|
||||
nextCursor: "unexpected-page-3",
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchHomePluginListing("trending", ["development", "security"], 20),
|
||||
).rejects.toThrow("exceeded 200-item contract");
|
||||
});
|
||||
});
|
||||
+90
-61
@@ -1,12 +1,12 @@
|
||||
import { PACKAGE_TRENDING_LEADERBOARD_LIMIT } from "clawhub-schema";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { isSkillOfficial } from "./badges";
|
||||
import { getSkillCategoriesForSkill } from "./categories";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
|
||||
import type { PublicSkill, PublicUser } from "./publicUser";
|
||||
|
||||
export type HomeListingKind = "skills" | "plugins";
|
||||
export type HomeListingTab = "popular" | "trending" | "officials" | "new";
|
||||
export type HomeListingTab = "featured" | "popular" | "trending";
|
||||
|
||||
export type HomeSkillListingEntry = {
|
||||
skill: PublicSkill;
|
||||
@@ -18,18 +18,29 @@ export type HomeListingCacheEntry =
|
||||
| { kind: "skills"; items: HomeSkillListingEntry[]; hasMore: boolean }
|
||||
| { kind: "plugins"; items: PackageListItem[]; hasMore: boolean };
|
||||
|
||||
export type HomeListingInitialData = {
|
||||
kind: "skills";
|
||||
tab: "popular";
|
||||
type HomeListingInitialDataBase = {
|
||||
tab: HomeListingTab;
|
||||
categorySlugs: [];
|
||||
fetchLimit: typeof HOME_LISTING_PAGE_SIZE;
|
||||
items: HomeSkillListingEntry[];
|
||||
hasMore: boolean;
|
||||
featuredAvailability: Record<HomeListingKind, boolean>;
|
||||
};
|
||||
|
||||
export type HomeListingInitialData =
|
||||
| (HomeListingInitialDataBase & {
|
||||
kind: "skills";
|
||||
items: HomeSkillListingEntry[];
|
||||
})
|
||||
| (HomeListingInitialDataBase & {
|
||||
kind: "plugins";
|
||||
items: PackageListItem[];
|
||||
});
|
||||
|
||||
export const HOME_LISTING_PAGE_SIZE = 20;
|
||||
|
||||
const PLUGIN_CATALOG_PAGE_LIMIT = 100;
|
||||
// Highlighted skill responses are cursorless, so request the backend's full public maximum.
|
||||
const FEATURED_SKILL_LIST_LIMIT = 200;
|
||||
|
||||
export function homeListingCacheKey({
|
||||
kind,
|
||||
@@ -45,28 +56,6 @@ export function homeListingCacheKey({
|
||||
return ["listing", kind, tab, categoryCacheKey(categorySlugs), fetchLimit].join(":");
|
||||
}
|
||||
|
||||
export function filterHomeSkillsByTab(entries: HomeSkillListingEntry[], tab: HomeListingTab) {
|
||||
if (tab === "officials") {
|
||||
return entries.filter((entry) => isSkillOfficial(entry.skill));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function filterHomePluginsByTab(items: PackageListItem[], tab: HomeListingTab) {
|
||||
if (tab === "officials") {
|
||||
return items.filter((item) => item.isOfficial);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function isNewHomeSkillEligible(skill: PublicSkill) {
|
||||
return (
|
||||
!skill.isSuspicious &&
|
||||
skill.githubScanStatus !== "pending" &&
|
||||
skill.githubScanStatus !== "suspicious"
|
||||
);
|
||||
}
|
||||
|
||||
export function itemMatchesAnyHomeCategory(
|
||||
item: { categories?: readonly string[] | null },
|
||||
categorySlugs: readonly string[],
|
||||
@@ -98,14 +87,8 @@ export function uniqueHomePlugins(items: PackageListItem[]) {
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
export function sortHomeSkillEntries(entries: HomeSkillListingEntry[], tab: HomeListingTab) {
|
||||
function sortHomeSkillEntries(entries: HomeSkillListingEntry[]) {
|
||||
return [...entries].sort((left, right) => {
|
||||
if (tab === "new") {
|
||||
return (
|
||||
(right.skill.updatedAt ?? right.skill.createdAt ?? right.skill._creationTime ?? 0) -
|
||||
(left.skill.updatedAt ?? left.skill.createdAt ?? left.skill._creationTime ?? 0)
|
||||
);
|
||||
}
|
||||
return (right.skill.stats?.downloads ?? 0) - (left.skill.stats?.downloads ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -129,6 +112,7 @@ export async function fetchHomeSkillListing(
|
||||
};
|
||||
}
|
||||
|
||||
const requestLimit = tab === "featured" ? FEATURED_SKILL_LIST_LIMIT : numItems;
|
||||
const categoriesToFetch = categorySlugs.length > 0 ? categorySlugs : [null];
|
||||
const results = await Promise.all(
|
||||
categoriesToFetch.map(async (categorySlug) => {
|
||||
@@ -136,21 +120,19 @@ export async function fetchHomeSkillListing(
|
||||
let cursor: string | null | undefined;
|
||||
let hasMore = false;
|
||||
|
||||
while (page.length < numItems) {
|
||||
while (page.length < requestLimit) {
|
||||
const result = await convexHttp.query(api.skills.listPublicPageV4, {
|
||||
cursor: cursor ?? undefined,
|
||||
numItems: numItems - page.length,
|
||||
sort: tab === "new" ? "newest" : "downloads",
|
||||
numItems: requestLimit - page.length,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
officialFirst: tab === "officials" ? true : undefined,
|
||||
highlightedOnly: tab === "featured" ? true : undefined,
|
||||
categorySlug: categorySlug ?? undefined,
|
||||
});
|
||||
if (Array.isArray(result)) break;
|
||||
|
||||
const resultPage = ((result as { page?: HomeSkillListingEntry[] }).page ?? []).filter(
|
||||
(entry) =>
|
||||
skillMatchesAnyHomeCategory(entry.skill, categorySlugs) &&
|
||||
(tab !== "new" || isNewHomeSkillEligible(entry.skill)),
|
||||
(entry) => skillMatchesAnyHomeCategory(entry.skill, categorySlugs),
|
||||
);
|
||||
page.push(...resultPage);
|
||||
|
||||
@@ -164,11 +146,9 @@ export async function fetchHomeSkillListing(
|
||||
}),
|
||||
);
|
||||
const pages = results.flatMap((result) => result.page);
|
||||
const sorted = sortHomeSkillEntries(
|
||||
filterHomeSkillsByTab(uniqueHomeSkillEntries(pages), tab),
|
||||
tab,
|
||||
);
|
||||
const hasMore = sorted.length > numItems || results.some((result) => result.hasMore);
|
||||
const sorted = sortHomeSkillEntries(uniqueHomeSkillEntries(pages));
|
||||
const hasMore =
|
||||
sorted.length > numItems || (tab !== "featured" && results.some((result) => result.hasMore));
|
||||
const page = sorted.slice(0, numItems);
|
||||
return { page, hasMore };
|
||||
}
|
||||
@@ -179,29 +159,47 @@ export async function fetchHomePluginListing(
|
||||
limit: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const openClawOfficials = tab === "officials";
|
||||
const categoriesToFetch = categorySlugs.length > 0 ? categorySlugs : [null];
|
||||
const featured = tab === "featured";
|
||||
const trending = tab === "trending";
|
||||
const usesGlobalTrendingFilter = trending && categorySlugs.length > 1;
|
||||
const categoriesToFetch = usesGlobalTrendingFilter
|
||||
? [null]
|
||||
: categorySlugs.length > 0
|
||||
? categorySlugs
|
||||
: [null];
|
||||
const results = await Promise.all(
|
||||
categoriesToFetch.map(async (categorySlug) => {
|
||||
const items: PackageListItem[] = [];
|
||||
let cursor: string | null | undefined;
|
||||
let hasMore = false;
|
||||
let trendingCandidatesScanned = 0;
|
||||
|
||||
while (items.length < limit) {
|
||||
const result = await fetchPluginCatalog({
|
||||
category: categorySlug ?? undefined,
|
||||
cursor: cursor ?? undefined,
|
||||
isOfficial: openClawOfficials ? true : undefined,
|
||||
excludedScanStatuses: tab === "new" ? ["pending", "suspicious"] : undefined,
|
||||
sort: tab === "new" ? "updated" : "downloads",
|
||||
limit: Math.min(limit - items.length, PLUGIN_CATALOG_PAGE_LIMIT),
|
||||
featured: featured ? true : undefined,
|
||||
sort: trending ? "trending" : "downloads",
|
||||
limit: usesGlobalTrendingFilter
|
||||
? PLUGIN_CATALOG_PAGE_LIMIT
|
||||
: Math.min(limit - items.length, PLUGIN_CATALOG_PAGE_LIMIT),
|
||||
signal,
|
||||
});
|
||||
trendingCandidatesScanned += result.items.length;
|
||||
items.push(
|
||||
...result.items.filter((item) => itemMatchesAnyHomeCategory(item, categorySlugs)),
|
||||
);
|
||||
|
||||
hasMore = result.nextCursor != null;
|
||||
if (
|
||||
usesGlobalTrendingFilter &&
|
||||
trendingCandidatesScanned >= PACKAGE_TRENDING_LEADERBOARD_LIMIT &&
|
||||
result.nextCursor
|
||||
) {
|
||||
throw new Error(
|
||||
`Trending plugin feed exceeded ${PACKAGE_TRENDING_LEADERBOARD_LIMIT}-item contract`,
|
||||
);
|
||||
}
|
||||
if (!result.nextCursor || result.nextCursor === cursor) break;
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
@@ -210,10 +208,7 @@ export async function fetchHomePluginListing(
|
||||
}),
|
||||
);
|
||||
let items = uniqueHomePlugins(results.flatMap((result) => result.items));
|
||||
items = filterHomePluginsByTab(items, tab);
|
||||
if (tab === "new") {
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
} else if (tab === "popular" || openClawOfficials) {
|
||||
if (tab === "popular" || featured) {
|
||||
items.sort((a, b) => (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0));
|
||||
}
|
||||
const page = items.slice(0, limit);
|
||||
@@ -223,15 +218,49 @@ export async function fetchHomePluginListing(
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchHomeFeaturedAvailability(kind: HomeListingKind, signal?: AbortSignal) {
|
||||
if (kind === "skills") {
|
||||
const result = await convexHttp.query(api.skills.listPublicPageV4, {
|
||||
numItems: 1,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
highlightedOnly: true,
|
||||
});
|
||||
return (
|
||||
!Array.isArray(result) &&
|
||||
((result as { page?: HomeSkillListingEntry[] }).page?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
const result = await fetchPluginCatalog({
|
||||
featured: true,
|
||||
sort: "downloads",
|
||||
limit: 1,
|
||||
signal,
|
||||
});
|
||||
return result.items.length > 0;
|
||||
}
|
||||
|
||||
export async function fetchInitialHomeListing(): Promise<HomeListingInitialData> {
|
||||
const result = await fetchHomeSkillListing("popular", [], HOME_LISTING_PAGE_SIZE);
|
||||
const [featuredPlugins, hasFeaturedSkills] = await Promise.all([
|
||||
fetchHomePluginListing("featured", [], HOME_LISTING_PAGE_SIZE),
|
||||
fetchHomeFeaturedAvailability("skills").catch(() => false),
|
||||
]);
|
||||
const hasFeaturedPlugins = featuredPlugins.items.length > 0;
|
||||
const result = hasFeaturedPlugins
|
||||
? featuredPlugins
|
||||
: await fetchHomePluginListing("popular", [], HOME_LISTING_PAGE_SIZE);
|
||||
return {
|
||||
kind: "skills",
|
||||
tab: "popular",
|
||||
kind: "plugins",
|
||||
tab: hasFeaturedPlugins ? "featured" : "popular",
|
||||
categorySlugs: [],
|
||||
fetchLimit: HOME_LISTING_PAGE_SIZE,
|
||||
items: result.page,
|
||||
items: result.items,
|
||||
hasMore: result.hasMore,
|
||||
featuredAvailability: {
|
||||
plugins: hasFeaturedPlugins,
|
||||
skills: hasFeaturedSkills,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+23
-15
@@ -26079,20 +26079,26 @@ a.search-empty-action {
|
||||
}
|
||||
|
||||
.home-v2-popular-publishers-track {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
padding: 4px 2px 12px;
|
||||
}
|
||||
|
||||
.home-v2-popular-publishers-retry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.home-v2-popular-publisher-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
flex: 0 0 calc((100% - 40px) / 5);
|
||||
width: calc((100% - 40px) / 5);
|
||||
min-width: 0;
|
||||
min-height: 158px;
|
||||
min-height: 150px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--hv2-border-strong) 88%, transparent);
|
||||
border-radius: 10px;
|
||||
@@ -26118,14 +26124,14 @@ a.search-empty-action {
|
||||
.home-v2-popular-publisher-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 14px 14px 10px;
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.home-v2-popular-publisher-card .marketplace-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--oc-radius-inset);
|
||||
background: rgb(22 22 24);
|
||||
border-color: var(--hv2-border-strong);
|
||||
@@ -26157,7 +26163,7 @@ a.search-empty-action {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 14px 14px;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.home-v2-popular-publisher-bio {
|
||||
@@ -26184,9 +26190,12 @@ a.search-empty-action {
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.home-v2-popular-publisher-card {
|
||||
flex-basis: min(240px, 42vw);
|
||||
width: min(240px, 42vw);
|
||||
.home-v2-popular-publishers-track {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: min(240px, 42vw);
|
||||
grid-template-columns: none;
|
||||
grid-template-rows: repeat(2, minmax(150px, auto));
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26202,9 +26211,8 @@ a.search-empty-action {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-v2-popular-publisher-card {
|
||||
flex-basis: min(220px, 76vw);
|
||||
width: min(220px, 76vw);
|
||||
.home-v2-popular-publishers-track {
|
||||
grid-auto-columns: min(220px, 76vw);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user