diff --git a/.gitignore b/.gitignore
index 3798bd7b2..040178f3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,7 @@ coverage
playwright-report
test-results
.playwright
+convex/_generated/
+skills-lock.json
+*/skills/*
+skills/*
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index ee8145b43..a2d5147ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -87,3 +87,11 @@
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
+
+
+This project uses [Convex](https://convex.dev) as its backend.
+
+When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
+
+Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
+
diff --git a/CLAUDE.md b/CLAUDE.md
index b109ba1fc..55bac1107 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,3 +45,11 @@
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
+
+
+This project uses [Convex](https://convex.dev) as its backend.
+
+When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
+
+Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
+
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 083510fae..5750296ec 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -48,6 +48,7 @@ import type * as lib_contentTypes from "../lib/contentTypes.js";
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
+import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
import type * as lib_githubBackup from "../lib/githubBackup.js";
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
import type * as lib_githubImport from "../lib/githubImport.js";
@@ -92,11 +93,13 @@ import type * as lib_userSearch from "../lib/userSearch.js";
import type * as lib_webhooks from "../lib/webhooks.js";
import type * as llmEval from "../llmEval.js";
import type * as maintenance from "../maintenance.js";
+import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as publishers from "../publishers.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
+import type * as seedDemo from "../seedDemo.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skillTransfers from "../skillTransfers.js";
@@ -161,6 +164,7 @@ declare const fullApi: ApiFromModules<{
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
+ "lib/githubActionsOidc": typeof lib_githubActionsOidc;
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubIdentity": typeof lib_githubIdentity;
"lib/githubImport": typeof lib_githubImport;
@@ -205,11 +209,13 @@ declare const fullApi: ApiFromModules<{
"lib/webhooks": typeof lib_webhooks;
llmEval: typeof llmEval;
maintenance: typeof maintenance;
+ packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
publishers: typeof publishers;
rateLimits: typeof rateLimits;
search: typeof search;
seed: typeof seed;
+ seedDemo: typeof seedDemo;
seedSouls: typeof seedSouls;
skillStatEvents: typeof skillStatEvents;
skillTransfers: typeof skillTransfers;
diff --git a/convex/lib/githubActionsOidc.test.ts b/convex/lib/githubActionsOidc.test.ts
index e6831ec3d..c94b866c5 100644
--- a/convex/lib/githubActionsOidc.test.ts
+++ b/convex/lib/githubActionsOidc.test.ts
@@ -1,6 +1,6 @@
/* @vitest-environment node */
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
extractWorkflowFilenameFromWorkflowRef,
verifyGitHubActionsTrustedPublishJwt,
diff --git a/convex/seedDemo.ts b/convex/seedDemo.ts
new file mode 100644
index 000000000..db3e6d9f8
--- /dev/null
+++ b/convex/seedDemo.ts
@@ -0,0 +1,411 @@
+import type { Id } from "./_generated/dataModel";
+import { internalMutation } from "./functions";
+
+const DEMO_SKILLS = [
+ {
+ slug: "mcp-github",
+ displayName: "MCP GitHub",
+ summary: "Full GitHub API integration via MCP — issues, PRs, repos, code search, and actions.",
+ downloads: 14200,
+ stars: 342,
+ installs: 8100,
+ },
+ {
+ slug: "claude-memory",
+ displayName: "Claude Memory",
+ summary: "Persistent memory layer for Claude — stores context across conversations with vector recall.",
+ downloads: 11800,
+ stars: 287,
+ installs: 6400,
+ },
+ {
+ slug: "web-scraper-pro",
+ displayName: "Web Scraper Pro",
+ summary: "Intelligent web scraping with automatic pagination, JS rendering, and structured data extraction.",
+ downloads: 9400,
+ stars: 198,
+ installs: 5200,
+ },
+ {
+ slug: "sql-analyst",
+ displayName: "SQL Analyst",
+ summary: "Natural language to SQL with schema introspection, query optimization, and result visualization.",
+ downloads: 8700,
+ stars: 221,
+ installs: 4800,
+ },
+ {
+ slug: "pytest-agent",
+ displayName: "Pytest Agent",
+ summary: "Automated test generation and execution for Python — coverage analysis, mutation testing, fixtures.",
+ downloads: 7200,
+ stars: 156,
+ installs: 3900,
+ },
+ {
+ slug: "docker-compose-helper",
+ displayName: "Docker Compose Helper",
+ summary: "Generate, validate, and debug Docker Compose configurations with multi-service orchestration.",
+ downloads: 6800,
+ stars: 134,
+ installs: 3600,
+ },
+ {
+ slug: "api-docs-generator",
+ displayName: "API Docs Generator",
+ summary: "Auto-generate OpenAPI specs and beautiful documentation from any codebase or endpoint.",
+ downloads: 5900,
+ stars: 178,
+ installs: 3100,
+ },
+ {
+ slug: "slack-bot-builder",
+ displayName: "Slack Bot Builder",
+ summary: "Build and deploy Slack bots with natural language — slash commands, modals, and event handlers.",
+ downloads: 5400,
+ stars: 112,
+ installs: 2800,
+ },
+ {
+ slug: "terraform-assistant",
+ displayName: "Terraform Assistant",
+ summary: "Infrastructure as code helper — plan reviews, drift detection, module generation for AWS/GCP/Azure.",
+ downloads: 4800,
+ stars: 145,
+ installs: 2400,
+ },
+ {
+ slug: "regex-wizard",
+ displayName: "Regex Wizard",
+ summary: "Natural language to regex with live testing, explanation, and edge case generation.",
+ downloads: 4200,
+ stars: 89,
+ installs: 2100,
+ },
+ {
+ slug: "git-history-explorer",
+ displayName: "Git History Explorer",
+ summary: "Semantic search through git history — find commits by intent, trace code evolution, blame analysis.",
+ downloads: 3900,
+ stars: 102,
+ installs: 1900,
+ },
+ {
+ slug: "cron-scheduler",
+ displayName: "Cron Scheduler",
+ summary: "Natural language to cron expressions with timezone handling, overlap protection, and monitoring.",
+ downloads: 3400,
+ stars: 67,
+ installs: 1600,
+ },
+ {
+ slug: "jwt-debugger",
+ displayName: "JWT Debugger",
+ summary: "Decode, verify, and generate JWTs with visual payload inspection and expiry tracking.",
+ downloads: 3100,
+ stars: 78,
+ installs: 1400,
+ },
+ {
+ slug: "graphql-builder",
+ displayName: "GraphQL Builder",
+ summary: "Schema-first GraphQL development — type generation, resolver scaffolding, and playground integration.",
+ downloads: 2800,
+ stars: 94,
+ installs: 1200,
+ },
+ {
+ slug: "security-scanner",
+ displayName: "Security Scanner",
+ summary: "OWASP-aware security scanning for codebases — dependency audit, secret detection, SAST patterns.",
+ downloads: 2500,
+ stars: 156,
+ installs: 1100,
+ },
+ {
+ slug: "markdown-slides",
+ displayName: "Markdown Slides",
+ summary: "Turn markdown into presentation decks with themes, speaker notes, and PDF export.",
+ downloads: 2200,
+ stars: 45,
+ installs: 900,
+ },
+ {
+ slug: "env-manager",
+ displayName: "Env Manager",
+ summary: "Environment variable management across projects — sync .env files, validate schemas, rotate secrets.",
+ downloads: 1800,
+ stars: 56,
+ installs: 800,
+ },
+ {
+ slug: "csv-transform",
+ displayName: "CSV Transform",
+ summary: "Powerful CSV/TSV manipulation — column transforms, joins, pivots, and format conversion.",
+ downloads: 1500,
+ stars: 34,
+ installs: 600,
+ },
+ {
+ slug: "ssh-config-manager",
+ displayName: "SSH Config Manager",
+ summary: "Manage SSH configs, keys, and tunnels with natural language — jump hosts, port forwarding, agent setup.",
+ downloads: 1200,
+ stars: 42,
+ installs: 500,
+ },
+ {
+ slug: "changelog-writer",
+ displayName: "Changelog Writer",
+ summary: "Generate changelogs from git history with conventional commit parsing and release note formatting.",
+ downloads: 980,
+ stars: 28,
+ installs: 400,
+ },
+];
+
+const DEMO_OWNERS = [
+ { handle: "anthropic", displayName: "Anthropic", highlighted: true },
+ { handle: "openai-labs", displayName: "OpenAI Labs", highlighted: false },
+ { handle: "devtools-co", displayName: "DevTools Co", highlighted: false },
+ { handle: "securityfirst", displayName: "SecurityFirst", highlighted: true },
+ { handle: "dataflow", displayName: "DataFlow", highlighted: false },
+];
+
+export const seedDemoSkills = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ // Check if we already seeded
+ const existingSkill = await ctx.db
+ .query("skills")
+ .withIndex("by_slug", (q) => q.eq("slug", "mcp-github"))
+ .first();
+ if (existingSkill) {
+ return { seeded: false, reason: "already seeded" };
+ }
+
+ // Create a seed user
+ const seedUserId = await ctx.db.insert("users", {
+ name: "ClawHub Demo",
+ displayName: "ClawHub Demo",
+ handle: "clawhub-demo",
+ image: undefined,
+ role: "admin",
+ });
+
+ // Create publisher accounts
+ const publisherIds: string[] = [];
+ for (const owner of DEMO_OWNERS) {
+ const pubId = await ctx.db.insert("publishers", {
+ kind: "org",
+ handle: owner.handle,
+ displayName: owner.displayName,
+ linkedUserId: seedUserId,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ publisherIds.push(pubId);
+
+ // Add membership
+ await ctx.db.insert("publisherMembers", {
+ publisherId: pubId as Id<"publishers">,
+ userId: seedUserId,
+ role: "owner",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ }
+
+ const now = Date.now();
+ const DAY = 86400000;
+
+ for (let i = 0; i < DEMO_SKILLS.length; i++) {
+ const s = DEMO_SKILLS[i];
+ const ownerIdx = i % publisherIds.length;
+ const createdDaysAgo = Math.floor(Math.random() * 90) + 7;
+ const updatedDaysAgo = Math.floor(Math.random() * createdDaysAgo);
+ const createdAt = now - createdDaysAgo * DAY;
+ const updatedAt = now - updatedDaysAgo * DAY;
+ const version = `${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 20)}`;
+
+ const isHighlighted = i < 6;
+ const badges = isHighlighted
+ ? { highlighted: { byUserId: seedUserId, at: now } }
+ : undefined;
+
+ const numVersions = Math.floor(Math.random() * 8) + 1;
+ const numComments = Math.floor(Math.random() * 15);
+
+ // Create skill first (without latestVersionId)
+ const skillId = await ctx.db.insert("skills", {
+ slug: s.slug,
+ displayName: s.displayName,
+ summary: s.summary,
+ ownerUserId: seedUserId,
+ ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
+ tags: {},
+ badges,
+ moderationStatus: "active",
+ moderationVerdict: "clean",
+ stats: {
+ downloads: s.downloads,
+ installsCurrent: Math.floor(s.installs * 0.3),
+ installsAllTime: s.installs,
+ stars: s.stars,
+ versions: numVersions,
+ comments: numComments,
+ },
+ statsDownloads: s.downloads,
+ statsStars: s.stars,
+ statsInstallsCurrent: Math.floor(s.installs * 0.3),
+ statsInstallsAllTime: s.installs,
+ createdAt,
+ updatedAt,
+ });
+
+ // Create skillBadges entry for highlighted skills
+ if (isHighlighted) {
+ await ctx.db.insert("skillBadges", {
+ skillId,
+ kind: "highlighted",
+ byUserId: seedUserId,
+ at: now,
+ });
+ }
+
+ // Now create version with real skillId
+ const versionId = await ctx.db.insert("skillVersions", {
+ skillId,
+ version,
+ changelog: `Release ${version} — improvements and bug fixes.`,
+ files: [],
+ parsed: { frontmatter: {} },
+ createdBy: seedUserId,
+ createdAt: updatedAt,
+ });
+
+ // Patch skill with version info
+ await ctx.db.patch(skillId, {
+ latestVersionId: versionId,
+ latestVersionSummary: {
+ version,
+ createdAt: updatedAt,
+ changelog: `Release ${version}`,
+ },
+ tags: { latest: versionId },
+ });
+
+ // Create digest for search
+ await ctx.db.insert("skillSearchDigest", {
+ skillId,
+ slug: s.slug,
+ displayName: s.displayName,
+ summary: s.summary,
+ ownerUserId: seedUserId,
+ ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
+ ownerHandle: DEMO_OWNERS[ownerIdx].handle,
+ ownerName: DEMO_OWNERS[ownerIdx].displayName,
+ ownerDisplayName: DEMO_OWNERS[ownerIdx].displayName,
+ ownerImage: undefined,
+ latestVersionId: versionId,
+ latestVersionSummary: {
+ version,
+ createdAt: updatedAt,
+ changelog: `Release ${version}`,
+ },
+ tags: { latest: versionId },
+ badges,
+ stats: {
+ downloads: s.downloads,
+ installsCurrent: Math.floor(s.installs * 0.3),
+ installsAllTime: s.installs,
+ stars: s.stars,
+ versions: Math.floor(Math.random() * 8) + 1,
+ comments: Math.floor(Math.random() * 15),
+ },
+ statsDownloads: s.downloads,
+ statsStars: s.stars,
+ statsInstallsCurrent: Math.floor(s.installs * 0.3),
+ statsInstallsAllTime: s.installs,
+ softDeletedAt: undefined,
+ moderationStatus: "active",
+ moderationFlags: undefined,
+ moderationReason: undefined,
+ isSuspicious: false,
+ createdAt,
+ updatedAt,
+ });
+ }
+
+ return { seeded: true, count: DEMO_SKILLS.length };
+ },
+});
+
+// Repair globalStats count to match actual seeded data
+export const repairGlobalStats = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ // Count active digests — push filter server-side
+ const digests = await ctx.db
+ .query("skillSearchDigest")
+ .withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
+ .filter((q) => q.eq(q.field("moderationStatus"), "active"))
+ .collect();
+
+ const count = digests.length;
+
+ // Update globalStats
+ const stats = await ctx.db
+ .query("globalStats")
+ .filter((q) => q.eq(q.field("key"), "default"))
+ .first();
+
+ if (stats) {
+ await ctx.db.patch(stats._id, { activeSkillsCount: count, updatedAt: Date.now() });
+ } else {
+ await ctx.db.insert("globalStats", {
+ key: "default",
+ activeSkillsCount: count,
+ updatedAt: Date.now(),
+ });
+ }
+
+ return { count };
+ },
+});
+
+// Repair function to add missing skillBadges for already-seeded data
+export const repairHighlightedBadges = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ const highlightedSlugs = DEMO_SKILLS.slice(0, 6).map((s) => s.slug);
+ let fixed = 0;
+
+ for (const slug of highlightedSlugs) {
+ const skill = await ctx.db
+ .query("skills")
+ .withIndex("by_slug", (q) => q.eq("slug", slug))
+ .first();
+ if (!skill) continue;
+
+ // Check if badge already exists
+ const existing = await ctx.db
+ .query("skillBadges")
+ .withIndex("by_skill_kind", (q) =>
+ q.eq("skillId", skill._id).eq("kind", "highlighted"),
+ )
+ .first();
+ if (existing) continue;
+
+ await ctx.db.insert("skillBadges", {
+ skillId: skill._id,
+ kind: "highlighted",
+ byUserId: skill.ownerUserId,
+ at: Date.now(),
+ });
+ fixed++;
+ }
+
+ return { fixed };
+ },
+});
diff --git a/convex/users.test.ts b/convex/users.test.ts
index e0dadfc8b..e3059b072 100644
--- a/convex/users.test.ts
+++ b/convex/users.test.ts
@@ -1113,6 +1113,45 @@ describe("users.list", () => {
});
});
+ it("includes an exact publisher-handle match even when the linked user is banned", async () => {
+ vi.mocked(requireUser).mockResolvedValue({
+ userId: "users:admin",
+ user: { _id: "users:admin", role: "admin" },
+ } as never);
+ const users = [
+ {
+ _id: "users:1",
+ _creationTime: 3,
+ handle: "different-login",
+ displayName: "ClawGrid",
+ deletedAt: 123,
+ role: "user",
+ },
+ { _id: "users:2", _creationTime: 2, handle: "alice", role: "user" },
+ ];
+ const { ctx } = makeListCtx(users, {
+ publishersByHandle: {
+ clawgrid: {
+ _id: "publishers:clawgrid",
+ handle: "clawgrid",
+ kind: "user",
+ linkedUserId: "users:1",
+ },
+ },
+ usersById: {
+ "users:1": users[0]!,
+ },
+ });
+ const listHandler = (
+ list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise }
+ )._handler;
+
+ await expect(listHandler(ctx, { limit: 10, search: "clawgrid" })).resolves.toMatchObject({
+ total: 1,
+ items: [{ _id: "users:1", deletedAt: 123 }],
+ });
+ });
+
it("treats whitespace search as empty search", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
diff --git a/convex/users.ts b/convex/users.ts
index 1e9bf0836..c575acd5a 100644
--- a/convex/users.ts
+++ b/convex/users.ts
@@ -1,7 +1,7 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
-import type { ActionCtx, MutationCtx } from "./_generated/server";
+import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
@@ -57,7 +57,7 @@ export const searchInternal = internalQuery({
const limit = clampInt(args.limit ?? 20, 1, MAX_USER_LIST_LIMIT);
const exactHandleUser = args.query
- ? await getActiveUserByHandleOrPersonalPublisher(ctx, args.query)
+ ? await getUserByHandleOrPersonalPublisher(ctx, args.query)
: null;
const result = await queryUsersForAdminList(ctx, {
limit,
@@ -365,7 +365,41 @@ export const list = query({
const { user } = await requireUser(ctx);
assertAdmin(user);
const limit = clampInt(args.limit ?? 50, 1, MAX_USER_LIST_LIMIT);
- return queryUsersForAdminList(ctx, { limit, search: args.search });
+ const exactHandleUser = args.search
+ ? await getUserByHandleOrPersonalPublisher(ctx, args.search)
+ : null;
+ const result = await queryUsersForAdminList(ctx, {
+ limit,
+ search: args.search,
+ exactUserId: exactHandleUser?._id,
+ });
+ const dedupedUsers = exactHandleUser
+ ? [exactHandleUser, ...result.items.filter((entry) => entry._id !== exactHandleUser._id)]
+ : result.items;
+ const total = exactHandleUser
+ ? result.total + (result.containsExactUser ? 0 : 1)
+ : result.total;
+ return {
+ items: dedupedUsers.slice(0, limit),
+ total,
+ };
+ },
+});
+
+export const listPublic = query({
+ args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ const limit = clampInt(args.limit ?? 40, 1, 100);
+ const result = await queryUsersForPublicList(ctx, {
+ limit,
+ search: args.search,
+ });
+ return {
+ items: result.items
+ .map((user) => toPublicUser(user))
+ .filter((user): user is NonNullable> => Boolean(user)),
+ total: result.total,
+ };
},
});
@@ -379,13 +413,7 @@ function computeUserSearchScanLimit(limit: number) {
}
async function queryUsersForAdminList(
- ctx: {
- db: {
- query: (table: "users") => {
- order: (order: "desc") => { take: (n: number) => Promise[]> };
- };
- };
- },
+ ctx: Pick,
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
) {
const normalizedSearch = normalizeSearchQuery(args.search);
@@ -407,6 +435,25 @@ async function queryUsersForAdminList(
};
}
+async function queryUsersForPublicList(
+ ctx: Pick,
+ args: { limit: number; search?: string },
+) {
+ const normalizedSearch = normalizeSearchQuery(args.search);
+ const scanLimit = normalizedSearch
+ ? computeUserSearchScanLimit(args.limit)
+ : clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
+ const scannedUsers = await ctx.db.query("users").order("desc").take(scanLimit);
+ const activeUsers = scannedUsers.filter(
+ (user) => !user.deletedAt && !user.deactivatedAt && Boolean(user.handle),
+ );
+ const result = buildUserSearchResults(activeUsers, normalizedSearch);
+ return {
+ items: result.items.slice(0, args.limit),
+ total: result.total,
+ };
+}
+
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(Math.trunc(value), min), max);
}
diff --git a/src/__tests__/header.test.tsx b/src/__tests__/header.test.tsx
index 5a95a2c28..5955b317f 100644
--- a/src/__tests__/header.test.tsx
+++ b/src/__tests__/header.test.tsx
@@ -5,8 +5,12 @@ import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
+const siteModeMock = vi.fn(() => "souls");
+const convexQueryMock = vi.fn().mockResolvedValue(0);
+
vi.mock("@tanstack/react-router", () => ({
Link: (props: { children: ReactNode }) => {props.children} ,
+ useNavigate: () => vi.fn(),
}));
vi.mock("@convex-dev/auth/react", () => ({
@@ -51,7 +55,7 @@ vi.mock("../lib/roles", () => ({
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
- getSiteMode: () => "souls",
+ getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
@@ -63,6 +67,24 @@ vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
+vi.mock("../convex/client", () => ({
+ convexHttp: {
+ query: convexQueryMock,
+ },
+}));
+
+vi.mock("../../convex/_generated/api", () => ({
+ api: {
+ skills: {
+ countPublicSkills: "countPublicSkills",
+ },
+ },
+}));
+
+vi.mock("../lib/numberFormat", () => ({
+ formatCompactStat: (n: number) => String(n),
+}));
+
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => {children}
,
DropdownMenuContent: ({ children }: { children: ReactNode }) => {children}
,
@@ -78,8 +100,23 @@ vi.mock("../components/ui/toggle-group", () => ({
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
+ siteModeMock.mockReturnValue("souls");
+
render();
expect(screen.queryByText("Packages")).toBeNull();
});
+
+ it("renders a plain Skills tab without fetching a count", () => {
+ siteModeMock.mockReturnValue("skills");
+ convexQueryMock.mockClear();
+
+ render();
+
+ expect(screen.getAllByText("Skills")).toHaveLength(2);
+ expect(screen.getAllByText("Souls")).toHaveLength(2);
+ expect(screen.getAllByText("Users")).toHaveLength(2);
+ expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
+ expect(convexQueryMock).not.toHaveBeenCalled();
+ });
});
diff --git a/src/__tests__/search-route.test.ts b/src/__tests__/search-route.test.ts
index 98aca8a95..d7dc4449c 100644
--- a/src/__tests__/search-route.test.ts
+++ b/src/__tests__/search-route.test.ts
@@ -1,99 +1,66 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
- createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
+ createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
+ __config: config,
+ }),
redirect: (options: unknown) => ({ redirect: options }),
+ Link: "a",
+ useNavigate: () => vi.fn(),
}));
import { Route } from "../routes/search";
-function runBeforeLoad(
- search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
- hostname = "clawdhub.com",
-) {
+function runValidateSearch(search: Record) {
const route = Route as unknown as {
__config: {
- beforeLoad?: (args: {
- search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
- location: { url: URL };
- }) => void;
+ validateSearch?: (search: Record) => unknown;
};
};
- const beforeLoad = route.__config.beforeLoad as (args: {
- search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
- location: { url: URL };
- }) => void;
- let thrown: unknown;
-
- try {
- beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } });
- } catch (error) {
- thrown = error;
- }
-
- return thrown;
+ const validateSearch = route.__config.validateSearch;
+ return validateSearch ? validateSearch(search) : {};
}
describe("search route", () => {
- it("redirects skills host to the skills index", () => {
- expect(runBeforeLoad({ q: "crab", highlighted: true }, "clawdhub.com")).toEqual({
- redirect: {
- to: "/skills",
- search: {
- q: "crab",
- sort: undefined,
- dir: undefined,
- highlighted: true,
- nonSuspicious: undefined,
- view: undefined,
- },
- replace: true,
- },
+ it("validates search with query", () => {
+ expect(runValidateSearch({ q: "crab" })).toEqual({
+ q: "crab",
+ type: undefined,
+ });
+ });
+
+ it("validates search with type filter", () => {
+ expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
+ q: "crab",
+ type: "skills",
});
});
- it("forwards nonSuspicious filter to skills index", () => {
- expect(runBeforeLoad({ q: "crab", nonSuspicious: true }, "clawdhub.com")).toEqual({
- redirect: {
- to: "/skills",
- search: {
- q: "crab",
- sort: undefined,
- dir: undefined,
- highlighted: undefined,
- nonSuspicious: true,
- view: undefined,
- },
- replace: true,
- },
+ it("ignores invalid type filter", () => {
+ expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
+ q: "crab",
+ type: undefined,
});
});
- it("redirects souls host with query to home search", () => {
- expect(runBeforeLoad({ q: "crab", highlighted: true }, "onlycrabs.ai")).toEqual({
- redirect: {
- to: "/",
- search: {
- q: "crab",
- highlighted: undefined,
- search: undefined,
- },
- replace: true,
- },
+ it("accepts the users type filter", () => {
+ expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
+ q: "vincent",
+ type: "users",
});
});
- it("redirects souls host without query to home with search mode", () => {
- expect(runBeforeLoad({}, "onlycrabs.ai")).toEqual({
- redirect: {
- to: "/",
- search: {
- q: undefined,
- highlighted: undefined,
- search: true,
- },
- replace: true,
- },
+ it("strips empty query", () => {
+ expect(runValidateSearch({ q: " " })).toEqual({
+ q: undefined,
+ type: undefined,
});
});
+
+ it("has a component (not a redirect-only route)", () => {
+ const route = Route as unknown as {
+ __config: { component?: unknown };
+ };
+ expect(route.__config.component).toBeDefined();
+ });
});
diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx
index 30679957e..2e3cf4e79 100644
--- a/src/__tests__/skill-detail-page.test.tsx
+++ b/src/__tests__/skill-detail-page.test.tsx
@@ -24,6 +24,10 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
+vi.mock("../components/SkillCommentsPanel", () => ({
+ SkillCommentsPanel: () =>
,
+}));
+
describe("SkillDetailPage", () => {
const skillId = "skills:1" as Id<"skills">;
const ownerId = "users:1" as Id<"users">;
@@ -129,9 +133,10 @@ describe("SkillDetailPage", () => {
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
- expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
+ expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -204,7 +209,7 @@ describe("SkillDetailPage", () => {
/>,
);
- expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
+ expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(getReadmeMock).not.toHaveBeenCalled();
});
@@ -462,10 +467,24 @@ describe("SkillDetailPage", () => {
it("defers compare version query until compare tab is requested", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
+ if (
+ args &&
+ typeof args === "object" &&
+ "skillId" in args &&
+ "limit" in args &&
+ (args as { limit: number }).limit === 50
+ ) {
+ return [
+ { _id: "skillVersions:1", version: "1.0.0", files: [] },
+ { _id: "skillVersions:2", version: "1.1.0", files: [] },
+ ];
+ }
+ if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
+ if ((args as { limit: number }).limit === 200) return [];
+ }
if (args && typeof args === "object" && "limit" in args) {
return [];
}
- if (args && typeof args === "object" && "skillId" in args) return [];
if (args && typeof args === "object" && "slug" in args) {
return {
skill: {
@@ -494,6 +513,7 @@ describe("SkillDetailPage", () => {
render( );
expect(await screen.findByText("Weather")).toBeTruthy();
+ expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
expect(
useQueryMock.mock.calls.some((call) => {
diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx
index 2a256c435..78a4291fb 100644
--- a/src/__tests__/skills-index.test.tsx
+++ b/src/__tests__/skills-index.test.tsx
@@ -66,7 +66,7 @@ describe("SkillsIndex", () => {
it("renders an empty state when no skills are returned", async () => {
render( );
await act(async () => {});
- expect(screen.getByText("No skills match that filter.")).toBeTruthy();
+ expect(screen.getByText("No skills found")).toBeTruthy();
});
it("shows loading state before fetch completes", async () => {
@@ -74,9 +74,9 @@ describe("SkillsIndex", () => {
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
render( );
await act(async () => {});
- // Header subtitle and results area both show "Loading skills…"
- expect(screen.getAllByText("Loading skills…").length).toBeGreaterThanOrEqual(1);
- expect(screen.queryByText("No skills match that filter.")).toBeNull();
+ // Results area shows skeleton or dash while loading
+ expect(screen.getByText("\u2014")).toBeTruthy();
+ expect(screen.queryByText("No skills found")).toBeNull();
});
it("shows empty state immediately when search returns no results", async () => {
@@ -91,8 +91,8 @@ describe("SkillsIndex", () => {
});
// Should show empty state, not loading
- expect(screen.getByText("No skills match that filter.")).toBeTruthy();
- expect(screen.queryByText("Loading skills…")).toBeNull();
+ expect(screen.getByText("No skills found")).toBeTruthy();
+ expect(screen.queryByText(/Loading skills/)).toBeNull();
});
it("skips list fetch and calls search when query is set", async () => {
@@ -136,7 +136,7 @@ describe("SkillsIndex", () => {
render( );
- const input = screen.getByPlaceholderText("Filter by name, slug, or summary…");
+ const input = screen.getByPlaceholderText("Search skills...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -161,7 +161,7 @@ describe("SkillsIndex", () => {
render( );
- const input = screen.getByPlaceholderText("Filter by name, slug, or summary…");
+ const input = screen.getByPlaceholderText("Search skills...");
await act(async () => {
fireEvent.change(input, { target: { value: "cli-design-framework" } });
await vi.runAllTimersAsync();
@@ -252,7 +252,7 @@ describe("SkillsIndex", () => {
});
const titles = Array.from(
- document.querySelectorAll(".skills-table-name > span:first-child"),
+ document.querySelectorAll(".skill-list-item-name"),
).map((node) => node.textContent);
expect(titles[0]).toBe("Older High Score");
@@ -323,7 +323,7 @@ describe("SkillsIndex", () => {
fireEvent.click(loadMoreButton);
});
- expect(screen.getByText("Loading…")).toBeTruthy();
+ expect(screen.getByText(/Loading/)).toBeTruthy();
});
});
diff --git a/src/components/BrowseSidebar.tsx b/src/components/BrowseSidebar.tsx
new file mode 100644
index 000000000..f5c5ca901
--- /dev/null
+++ b/src/components/BrowseSidebar.tsx
@@ -0,0 +1,120 @@
+import {
+ Database,
+ GitBranch,
+ MessageSquare,
+ Package,
+ Plug,
+ Shield,
+ Wrench,
+ Zap,
+} from "lucide-react";
+import type { SkillCategory } from "../lib/categories";
+
+type FilterItem = {
+ key: string;
+ label: string;
+ active: boolean;
+};
+
+type SortOption = {
+ value: string;
+ label: string;
+};
+
+type BrowseSidebarProps = {
+ categories?: SkillCategory[];
+ activeCategory?: string;
+ onCategoryChange?: (slug: string | undefined) => void;
+ sortOptions: SortOption[];
+ activeSort: string;
+ onSortChange: (value: string) => void;
+ filters: FilterItem[];
+ onFilterToggle: (key: string) => void;
+};
+
+const CATEGORY_ICONS: Record = {
+ "mcp-tools": ,
+ prompts: ,
+ workflows: ,
+ "dev-tools": ,
+ data: ,
+ security: ,
+ automation: ,
+ other: ,
+};
+
+export function BrowseSidebar({
+ categories,
+ activeCategory,
+ onCategoryChange,
+ sortOptions,
+ activeSort,
+ onSortChange,
+ filters,
+ onFilterToggle,
+}: BrowseSidebarProps) {
+ return (
+
+ );
+}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 206f38c69..5268a6ee4 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,35 +1,104 @@
+import { Link } from "@tanstack/react-router";
import { getSiteName } from "../lib/site";
export function Footer() {
const siteName = getSiteName();
return (
-
-
- Diff layout
- updateViewMode("split")}
- >
- Side-by-side
-
- updateViewMode("inline")}
- >
- Inline
-
-
+ {!diffUnavailable ? (
+
+ Diff layout
+ updateViewMode("split")}
+ >
+ Side-by-side
+
+ updateViewMode("inline")}
+ >
+ Inline
+
+
+ ) : null}
-
-
- Left
- setLeftVersionId(event.target.value as Id<"skillVersions">)}
- >
-
- Select version
-
- {renderOptions(versionOptions)}
-
-
-
{
- setLeftVersionId(rightVersionId);
- setRightVersionId(leftVersionId);
- }}
- disabled={!leftVersionId || !rightVersionId}
- >
- Swap
-
-
- Right
- setRightVersionId(event.target.value as Id<"skillVersions">)}
- >
-
- Select version
-
- {renderOptions(versionOptions)}
-
-
-
+ {!diffUnavailable ? (
+ <>
+
+
+ Left
+ setLeftVersionId(event.target.value as Id<"skillVersions">)}
+ >
+
+ Select version
+
+ {renderOptions(versionOptions)}
+
+
+
{
+ setLeftVersionId(rightVersionId);
+ setRightVersionId(leftVersionId);
+ }}
+ disabled={!leftVersionId || !rightVersionId}
+ >
+ Swap
+
+
+ Right
+ setRightVersionId(event.target.value as Id<"skillVersions">)}
+ >
+
+ Select version
+
+ {renderOptions(versionOptions)}
+
+
+
-
-
- Left {leftLabel} • Right {rightLabel}
-
- {diffUnavailable ? Need at least 2 versions. : null}
-
+
+
+ Left {leftLabel} • Right {rightLabel}
+
+
+ >
+ ) : null}
- {fileDiffItems.length === 0 ? (
+ {diffUnavailable ? (
+
+ Publish another version to compare changes side by side.
+
+ ) : fileDiffItems.length === 0 ? (
No files to compare.
) : (
fileDiffItems.map((item) => (
diff --git a/src/components/SkillFilesPanel.test.tsx b/src/components/SkillFilesPanel.test.tsx
index d5e395910..6e1868353 100644
--- a/src/components/SkillFilesPanel.test.tsx
+++ b/src/components/SkillFilesPanel.test.tsx
@@ -9,14 +9,6 @@ vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
}));
-vi.mock("react-markdown", () => ({
- default: ({ children }: { children: string }) =>
{children}
,
-}));
-
-vi.mock("remark-gfm", () => ({
- default: {},
-}));
-
type SkillFile = Doc<"skillVersions">["files"][number];
function makeFile(path: string, size: number): SkillFile {
@@ -38,8 +30,6 @@ describe("SkillFilesPanel", () => {
render(
}
- readmeContent={"# skill"}
- readmeError={null}
latestFiles={[makeFile("scripts/run.sh", 10)]}
/>,
);
@@ -72,8 +62,6 @@ describe("SkillFilesPanel", () => {
render(
}
- readmeContent={"# skill"}
- readmeError={null}
latestFiles={[makeFile("a.txt", 5), makeFile("b.txt", 6)]}
/>,
);
diff --git a/src/components/SkillFilesPanel.tsx b/src/components/SkillFilesPanel.tsx
index 7d12d4ec3..e6d73a42b 100644
--- a/src/components/SkillFilesPanel.tsx
+++ b/src/components/SkillFilesPanel.tsx
@@ -1,7 +1,5 @@
import { useAction } from "convex/react";
import { useCallback, useEffect, useRef, useState } from "react";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { formatBytes } from "./skillDetailUtils";
@@ -10,15 +8,11 @@ type SkillFile = Doc<"skillVersions">["files"][number];
type SkillFilesPanelProps = {
versionId: Id<"skillVersions"> | null;
- readmeContent: string | null;
- readmeError: string | null;
latestFiles: SkillFile[];
};
export function SkillFilesPanel({
versionId,
- readmeContent,
- readmeError,
latestFiles,
}: SkillFilesPanelProps) {
const getFileText = useAction(api.skills.getFileText);
@@ -92,20 +86,6 @@ export function SkillFilesPanel({
return (
-
-
- SKILL.md
-
-
- {readmeContent ? (
-
{readmeContent}
- ) : readmeError ? (
-
Failed to load SKILL.md: {readmeError}
- ) : (
-
Loading…
- )}
-
-
diff --git a/src/components/SkillHeader.tsx b/src/components/SkillHeader.tsx
index 81bba2826..cc68364cb 100644
--- a/src/components/SkillHeader.tsx
+++ b/src/components/SkillHeader.tsx
@@ -1,16 +1,11 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
-import {
- PLATFORM_SKILL_LICENSE,
- PLATFORM_SKILL_LICENSE_SUMMARY,
-} from "clawhub-schema/licenseConstants";
import { Package } from "lucide-react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
-import { getRuntimeEnv } from "../lib/runtimeEnv";
-import { SkillInstallCard } from "./SkillInstallCard";
+
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { UserBadge } from "./UserBadge";
@@ -113,10 +108,9 @@ export function SkillHeader({
onTagSubmit,
onTagDelete,
tagVersions,
- clawdis,
- osLabels,
+ clawdis: _clawdis,
+ osLabels: _osLabels,
}: SkillHeaderProps) {
- const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
@@ -260,7 +254,6 @@ export function SkillHeader({
- {PLATFORM_SKILL_LICENSE}
{getSkillBadges(skill).map((badge) => (
{badge}
@@ -274,21 +267,6 @@ export function SkillHeader({
- {!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
-
- Download zip
-
- ) : null}
-
-
- License
- {PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_SUMMARY}
-
-
{isAuthenticated ? (
) : null}
-
>
);
diff --git a/src/components/SkillInstallCard.tsx b/src/components/SkillInstallCard.tsx
index dcc2a63fd..c065d304b 100644
--- a/src/components/SkillInstallCard.tsx
+++ b/src/components/SkillInstallCard.tsx
@@ -1,9 +1,4 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
-import {
- PLATFORM_SKILL_LICENSE,
- PLATFORM_SKILL_LICENSE_SUMMARY,
- PLATFORM_SKILL_LICENSE_URL,
-} from "clawhub-schema/licenseConstants";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
type SkillInstallCardProps = {
@@ -30,32 +25,14 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
const hasInstallSpecs = installSpecs.length > 0;
const hasDependencies = dependencies.length > 0;
const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation);
- const hasLicense = true;
- if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks && !hasLicense) {
+ if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) {
return null;
}
return (
-
-
- License
-
-
-
{PLATFORM_SKILL_LICENSE}
-
- {PLATFORM_SKILL_LICENSE_SUMMARY}
-
-
-
-
{hasRuntimeRequirements ? (
diff --git a/src/components/SkillListItem.tsx b/src/components/SkillListItem.tsx
new file mode 100644
index 000000000..ec0ad606d
--- /dev/null
+++ b/src/components/SkillListItem.tsx
@@ -0,0 +1,52 @@
+import { Link } from "@tanstack/react-router";
+import { Package, Star } from "lucide-react";
+import { MarketplaceIcon } from "./MarketplaceIcon";
+import { getSkillBadges } from "../lib/badges";
+import { formatCompactStat } from "../lib/numberFormat";
+import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
+import { timeAgo } from "../lib/timeAgo";
+
+type SkillListItemProps = {
+ skill: PublicSkill;
+ ownerHandle?: string | null;
+ owner?: PublicPublisher | null;
+};
+
+export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) {
+ const handle = ownerHandle ?? owner?.handle ?? null;
+ const ownerSegment = handle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
+ const href = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}`;
+ const badges = getSkillBadges(skill);
+
+ return (
+
+
+
+
+ {handle ? (
+ <>
+ @{handle}
+ /
+ >
+ ) : null}
+ {skill.displayName}
+ {badges.map((b) => (
+
+ {b}
+
+ ))}
+
+ {skill.summary ?
{skill.summary}
: null}
+
+
Updated {timeAgo(skill.updatedAt)}
+
+ {formatCompactStat(skill.stats.stars)}
+
+
+ {formatCompactStat(skill.stats.downloads)}
+
+
+
+
+ );
+}
diff --git a/src/components/SkillMetadataSidebar.tsx b/src/components/SkillMetadataSidebar.tsx
new file mode 100644
index 000000000..ca8fe7bd1
--- /dev/null
+++ b/src/components/SkillMetadataSidebar.tsx
@@ -0,0 +1,142 @@
+import type { ClawdisSkillMetadata } from "clawhub-schema";
+import {
+ PLATFORM_SKILL_LICENSE,
+ PLATFORM_SKILL_LICENSE_SUMMARY,
+} from "clawhub-schema/licenseConstants";
+import { Package, Star } from "lucide-react";
+import type { Id } from "../../convex/_generated/dataModel";
+import { formatCompactStat } from "../lib/numberFormat";
+import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
+import { getRuntimeEnv } from "../lib/runtimeEnv";
+import { timeAgo } from "../lib/timeAgo";
+import { UserBadge } from "./UserBadge";
+
+type SkillMetadataSidebarProps = {
+ skill: PublicSkill;
+ latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
+ owner: PublicPublisher | null;
+ ownerHandle: string | null;
+ clawdis?: ClawdisSkillMetadata;
+ osLabels: string[];
+ tagEntries: Array<[string, Id<"skillVersions">]>;
+ isMalwareBlocked?: boolean;
+ isRemoved?: boolean;
+ nixPlugin?: string;
+};
+
+export function SkillMetadataSidebar({
+ skill,
+ latestVersion,
+ owner,
+ ownerHandle,
+ clawdis: _clawdis,
+ osLabels,
+ tagEntries,
+ isMalwareBlocked,
+ isRemoved,
+ nixPlugin,
+}: SkillMetadataSidebarProps) {
+ const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
+
+ return (
+
+ );
+}
diff --git a/src/components/SoulCard.tsx b/src/components/SoulCard.tsx
index 6b445b8e3..34d59439a 100644
--- a/src/components/SoulCard.tsx
+++ b/src/components/SoulCard.tsx
@@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
+import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicSoul } from "../lib/publicUser";
type SoulCardProps = {
@@ -11,7 +12,10 @@ type SoulCardProps = {
export function SoulCard({ soul, summaryFallback, meta }: SoulCardProps) {
return (
- {soul.displayName}
+
+
+
{soul.displayName}
+
{soul.summary ?? summaryFallback}
{meta}
diff --git a/src/components/UserListItem.tsx b/src/components/UserListItem.tsx
new file mode 100644
index 000000000..cd3a3f935
--- /dev/null
+++ b/src/components/UserListItem.tsx
@@ -0,0 +1,31 @@
+import { Link } from "@tanstack/react-router";
+import { MarketplaceIcon } from "./MarketplaceIcon";
+import type { PublicUser } from "../lib/publicUser";
+
+type UserListItemProps = {
+ user: PublicUser;
+};
+
+export function UserListItem({ user }: UserListItemProps) {
+ const handle = user.handle?.trim();
+ if (!handle) return null;
+
+ const displayName = user.displayName ?? user.name ?? handle;
+
+ return (
+
+
+
+
+ {displayName}
+ @{handle}
+
+
{user.bio?.trim() || "Builder on ClawHub."}
+
+ User
+ Profile
+
+
+
+ );
+}
diff --git a/src/lib/categories.ts b/src/lib/categories.ts
new file mode 100644
index 000000000..027bf1026
--- /dev/null
+++ b/src/lib/categories.ts
@@ -0,0 +1,17 @@
+export type SkillCategory = {
+ slug: string;
+ label: string;
+ icon: string;
+ keywords: string[];
+};
+
+export const SKILL_CATEGORIES: SkillCategory[] = [
+ { slug: "mcp-tools", label: "MCP Tools", icon: "plug", keywords: ["mcp", "tool", "server"] },
+ { slug: "prompts", label: "Prompts", icon: "message-square", keywords: ["prompt", "template", "system"] },
+ { slug: "workflows", label: "Workflows", icon: "git-branch", keywords: ["workflow", "pipeline", "chain"] },
+ { slug: "dev-tools", label: "Dev Tools", icon: "wrench", keywords: ["dev", "debug", "lint", "test", "build"] },
+ { slug: "data", label: "Data & APIs", icon: "database", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
+ { slug: "security", label: "Security", icon: "shield", keywords: ["security", "scan", "auth", "encrypt"] },
+ { slug: "automation", label: "Automation", icon: "zap", keywords: ["auto", "cron", "schedule", "bot"] },
+ { slug: "other", label: "Other", icon: "package", keywords: [] },
+];
diff --git a/src/lib/timeAgo.ts b/src/lib/timeAgo.ts
new file mode 100644
index 000000000..d4a6dd95a
--- /dev/null
+++ b/src/lib/timeAgo.ts
@@ -0,0 +1,33 @@
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+const WEEK = 7 * DAY;
+const MONTH = 30 * DAY;
+const YEAR = 365 * DAY;
+
+export function timeAgo(timestamp: number): string {
+ const diff = Date.now() - timestamp;
+ if (diff < MINUTE) return "just now";
+ if (diff < HOUR) {
+ const m = Math.floor(diff / MINUTE);
+ return `${m}m ago`;
+ }
+ if (diff < DAY) {
+ const h = Math.floor(diff / HOUR);
+ return `${h}h ago`;
+ }
+ if (diff < WEEK) {
+ const d = Math.floor(diff / DAY);
+ return `${d}d ago`;
+ }
+ if (diff < MONTH) {
+ const w = Math.floor(diff / WEEK);
+ return `${w}w ago`;
+ }
+ if (diff < YEAR) {
+ const m = Math.floor(diff / MONTH);
+ return `${m}mo ago`;
+ }
+ const y = Math.floor(diff / YEAR);
+ return `${y}y ago`;
+}
diff --git a/src/lib/useUnifiedSearch.ts b/src/lib/useUnifiedSearch.ts
new file mode 100644
index 000000000..4be92f560
--- /dev/null
+++ b/src/lib/useUnifiedSearch.ts
@@ -0,0 +1,150 @@
+import { useAction } from "convex/react";
+import { useEffect, useRef, useState } from "react";
+import { api } from "../../convex/_generated/api";
+import { convexHttp } from "../convex/client";
+import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
+import type { PublicUser } from "./publicUser";
+
+export type UnifiedSkillResult = {
+ type: "skill";
+ skill: {
+ _id: string;
+ slug: string;
+ displayName: string;
+ summary?: string | null;
+ ownerUserId: string;
+ ownerPublisherId?: string | null;
+ stats: { downloads: number; stars: number; versions?: number };
+ updatedAt: number;
+ createdAt: number;
+ };
+ ownerHandle: string | null;
+ score: number;
+};
+
+export type UnifiedPluginResult = {
+ type: "plugin";
+ plugin: PackageListItem;
+};
+
+export type UnifiedUserResult = {
+ type: "user";
+ user: PublicUser;
+};
+
+export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
+
+export function useUnifiedSearch(
+ query: string,
+ activeType: "all" | "skills" | "plugins" | "users",
+) {
+ const searchSkills = useAction(api.search.searchSkills);
+ const [results, setResults] = useState
([]);
+ const [skillCount, setSkillCount] = useState(0);
+ const [pluginCount, setPluginCount] = useState(0);
+ const [userCount, setUserCount] = useState(0);
+ const [isSearching, setIsSearching] = useState(false);
+ const requestRef = useRef(0);
+
+ useEffect(() => {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ setResults([]);
+ setSkillCount(0);
+ setPluginCount(0);
+ setUserCount(0);
+ setIsSearching(false);
+ return;
+ }
+
+ requestRef.current += 1;
+ const requestId = requestRef.current;
+ setIsSearching(true);
+
+ const handle = window.setTimeout(() => {
+ void (async () => {
+ try {
+ const promises: [
+ Promise | null,
+ Promise<{ items: PackageListItem[] }> | null,
+ Promise<{ items: PublicUser[] }> | null,
+ ] = [null, null];
+
+ if (activeType === "all" || activeType === "skills") {
+ promises[0] = searchSkills({
+ query: trimmed,
+ limit: 25,
+ nonSuspiciousOnly: true,
+ });
+ }
+
+ if (activeType === "all" || activeType === "plugins") {
+ promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
+ }
+
+ if (activeType === "all" || activeType === "users") {
+ promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
+ }
+
+ const [skillsRaw, pluginsRaw, usersRaw] = await Promise.all(promises);
+
+ if (requestId !== requestRef.current) return;
+
+ const skillResults: UnifiedSkillResult[] = (
+ (skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
+ ).map((entry) => ({
+ type: "skill" as const,
+ skill: entry.skill,
+ ownerHandle: entry.ownerHandle,
+ score: entry.score,
+ }));
+
+ const pluginResults: UnifiedPluginResult[] = (
+ (pluginsRaw as { items: PackageListItem[] })?.items ?? []
+ ).map((item) => ({
+ type: "plugin" as const,
+ plugin: item,
+ }));
+
+ setSkillCount(skillResults.length);
+ setPluginCount(pluginResults.length);
+ const userResults: UnifiedUserResult[] = (
+ (usersRaw as { items: PublicUser[] })?.items ?? []
+ ).map((user) => ({
+ type: "user" as const,
+ user,
+ }));
+ setUserCount(userResults.length);
+
+ const merged: UnifiedResult[] = [];
+ if (activeType === "all") {
+ merged.push(...skillResults, ...pluginResults, ...userResults);
+ } else if (activeType === "skills") {
+ merged.push(...skillResults);
+ } else if (activeType === "plugins") {
+ merged.push(...pluginResults);
+ } else {
+ merged.push(...userResults);
+ }
+
+ setResults(merged);
+ } catch {
+ if (requestId === requestRef.current) {
+ setResults([]);
+ setSkillCount(0);
+ setPluginCount(0);
+ setUserCount(0);
+ }
+ } finally {
+ if (requestId === requestRef.current) {
+ setIsSearching(false);
+ }
+ }
+ })();
+ }, 300);
+
+ return () => window.clearTimeout(handle);
+ }, [query, activeType, searchSkills]);
+
+ return { results, skillCount, pluginCount, userCount, isSearching };
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index fd213b7c8..e1cb9a544 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as UsersIndexRouteImport } from './routes/users/index'
import { Route as SoulsIndexRouteImport } from './routes/souls/index'
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
import { Route as PluginsIndexRouteImport } from './routes/plugins/index'
@@ -95,6 +96,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const UsersIndexRoute = UsersIndexRouteImport.update({
+ id: '/users/',
+ path: '/users/',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SoulsIndexRoute = SoulsIndexRouteImport.update({
id: '/souls/',
path: '/souls/',
@@ -187,6 +193,7 @@ export interface FileRoutesByFullPath {
'/plugins/': typeof PluginsIndexRoute
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
+ '/users/': typeof UsersIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -214,6 +221,7 @@ export interface FileRoutesByTo {
'/plugins': typeof PluginsIndexRoute
'/skills': typeof SkillsIndexRoute
'/souls': typeof SoulsIndexRoute
+ '/users': typeof UsersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -242,6 +250,7 @@ export interface FileRoutesById {
'/plugins/': typeof PluginsIndexRoute
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
+ '/users/': typeof UsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -271,6 +280,7 @@ export interface FileRouteTypes {
| '/plugins/'
| '/skills/'
| '/souls/'
+ | '/users/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -298,6 +308,7 @@ export interface FileRouteTypes {
| '/plugins'
| '/skills'
| '/souls'
+ | '/users'
id:
| '__root__'
| '/'
@@ -325,6 +336,7 @@ export interface FileRouteTypes {
| '/plugins/'
| '/skills/'
| '/souls/'
+ | '/users/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -353,6 +365,7 @@ export interface RootRouteChildren {
PluginsIndexRoute: typeof PluginsIndexRoute
SkillsIndexRoute: typeof SkillsIndexRoute
SoulsIndexRoute: typeof SoulsIndexRoute
+ UsersIndexRoute: typeof UsersIndexRoute
}
declare module '@tanstack/react-router' {
@@ -441,6 +454,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/users/': {
+ id: '/users/'
+ path: '/users'
+ fullPath: '/users/'
+ preLoaderRoute: typeof UsersIndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/souls/': {
id: '/souls/'
path: '/souls'
@@ -561,6 +581,7 @@ const rootRouteChildren: RootRouteChildren = {
PluginsIndexRoute: PluginsIndexRoute,
SkillsIndexRoute: SkillsIndexRoute,
SoulsIndexRoute: SoulsIndexRoute,
+ UsersIndexRoute: UsersIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/src/routes/about.tsx b/src/routes/about.tsx
index 53f2b1cf9..9ac7e1cce 100644
--- a/src/routes/about.tsx
+++ b/src/routes/about.tsx
@@ -81,56 +81,59 @@ export const Route = createFileRoute('/about')({
function AboutPage() {
return (
-
-
-
+
+
+
About
Policy
-
- What ClawHub Will Not Host
-
-
+
What ClawHub will not host
+
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
- defenses, abuse platforms, scam people, invade privacy, or enable non-consensual
- behavior, it does not belong here.
+ defenses, scam people, invade privacy, or enable non-consensual behavior, it does not
+ belong here.
-
- We moderate based on end-to-end abuse patterns, not just isolated keywords.
-
-
+
+
+
Moderation stance
+
+ We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
+ Predatory workflows get removed.
+
+
+
-
+
+
+
Immediate rejection categories
+
+
{prohibitedCategories.map((category) => (
-
-
- {category.title}
-
-
- {category.examples}
-
+
+ {category.title}
+ {category.examples}
))}
-
+
+
-
-
- Recent patterns we are explicitly not okay with
-
-
- {recentPatterns.map((pattern) => (
-
- {pattern}
-
- ))}
-
-
+
+
+
Recent patterns we are explicitly not okay with
+
+
+ {recentPatterns.map((pattern) => (
+
+ {pattern}
+
+ ))}
+
+
-
-
- Enforcement
-
+
+
+
Enforcement
We may hide, remove, or hard-delete violating skills.
@@ -143,21 +146,21 @@ function AboutPage() {
We do not guarantee warning-first enforcement for obvious abuse.
-
-
-
+
+
+
);
}
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx
index 6045aaeb0..a77fae623 100644
--- a/src/routes/dashboard.tsx
+++ b/src/routes/dashboard.tsx
@@ -120,18 +120,55 @@ function Dashboard() {
const skills = mySkills ?? [];
const packages = myPackages ?? [];
+ const isLoading = mySkills === undefined;
const ownerHandle =
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
+ // Welcome state for new users with no content
+ if (!isLoading && skills.length === 0 && packages.length === 0) {
+ return (
+
+
+
+ Welcome to ClawHub
+
+
+ You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
+
+
+
+ Publish a Skill
+
+
+ Browse Skills
+
+
+
+
+ );
+ }
+
return (
-
+
Publisher Dashboard
- Owner-only view for skills and plugins, including security scans and verification.
+ Manage your published skills and plugins.
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index e745032ae..6bfb1e9f2 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -2,14 +2,15 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
-import { InstallSwitcher } from "../components/InstallSwitcher";
import { SkillCard } from "../components/SkillCard";
+import { SkillListItem } from "../components/SkillListItem";
import { SkillStatsTripletLine } from "../components/SkillStats";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
import { UserBadge } from "../components/UserBadge";
import { convexHttp } from "../convex/client";
import { getSkillBadges } from "../lib/badges";
+import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill, PublicSoul } from "../lib/publicUser";
import { getSiteMode } from "../lib/site";
@@ -31,27 +32,38 @@ function SkillsHome() {
};
const [highlighted, setHighlighted] = useState
([]);
- const [popular, setPopular] = useState([]);
+ const [trending, setTrending] = useState([]);
+ const [recent, setRecent] = useState([]);
+ const [skillCount, setSkillCount] = useState(null);
useEffect(() => {
let cancelled = false;
- convexHttp
- .query(api.skills.listHighlightedPublic, { limit: 6 })
- .then((r) => {
- if (!cancelled) setHighlighted(r as SkillPageEntry[]);
- })
- .catch(() => {});
- convexHttp
- .query(api.skills.listPublicPageV4, {
- numItems: 12,
+
+ Promise.all([
+ convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
+ convexHttp.query(api.skills.listPublicPageV4, {
+ numItems: 8,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
- })
- .then((r) => {
- if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
+ }),
+ convexHttp.query(api.skills.listPublicPageV4, {
+ numItems: 8,
+ sort: "updated",
+ dir: "desc",
+ nonSuspiciousOnly: true,
+ }),
+ convexHttp.query(api.skills.countPublicSkills, {}),
+ ])
+ .then(([h, t, r, c]) => {
+ if (cancelled) return;
+ setHighlighted(h as SkillPageEntry[]);
+ setTrending((t as { page: SkillPageEntry[] }).page);
+ setRecent((r as { page: SkillPageEntry[] }).page);
+ setSkillCount(c as number);
})
.catch(() => {});
+
return () => {
cancelled = true;
};
@@ -59,89 +71,192 @@ function SkillsHome() {
return (
-
-
-
-
Lobster-light. Agent-right.
-
ClawHub, the skill dock for sharp agents.
-
- Upload AgentSkills bundles, version them like npm, and make them searchable with
- vectors. No gatekeeping, just signal.
-
-
-
- Publish Skill
-
+
+
+
+
+
Discovery hub
+
The collaborative hub for agent skills
+
+ {skillCount != null
+ ? `${formatCompactStat(skillCount)} public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own.`
+ : "Public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own."}
+
+
+
+ Browse All Skills & Plugins
+
+
+ + Publish Yours
+
+
+
+ Sharp filters. Clean listings. Discovery that feels more like a real index and less
+ like a sad spreadsheet.
+
+
+
+
- Browse skills
+ Skills
+ Browse ranked skill bundles
+ Popular installs, fresh updates, staff picks.
+
+
+ Plugins
+ Find agent-ready packages
+ Code plugins, bundles, and verified publishers.
+
+
+ Users
+ Meet the builders
+ Profiles, bios, and the people shipping useful stuff.
+
+
+ Souls
+ SOUL.md discovery is coming
+ Holding page for the next catalog surface.
-
-
-
-
-
Search skills. Versioned, rollback-ready.
-
-
- Highlighted skills
- Curated signal — highlighted for quick trust.
-
- {highlighted.length === 0 ? (
-
No highlighted skills yet.
- ) : (
- highlighted.map((entry) => (
-
0 ? (
+
+
+
Trending
+
+ See all
+
+
+
+ {trending.map((entry) => (
+
-
-
-
-
-
- }
+ ownerHandle={entry.ownerHandle}
+ owner={entry.owner}
/>
- ))
- )}
-
-
+ ))}
+
+
+ ) : null}
-
- Popular skills
- Most-downloaded, non-suspicious picks.
-
- {popular.length === 0 ? (
-
No skills yet. Be the first.
- ) : (
- popular.map((entry) => (
+ {/* Recently updated */}
+ {recent.length > 0 ? (
+
+
+
Recently updated
+
+ See all
+
+
+
+ {recent.map((entry) => (
+
+ ))}
+
+
+ ) : null}
+
+ {/* Staff picks */}
+ {highlighted.length > 0 ? (
+
+
+
Staff picks
+
+ See all
+
+
+
+ {
+ highlighted.map((entry) => (
}
/>
- ))
- )}
-
-
+ ))}
+
+
+ ) : null}
+
+ {/* Quick links */}
+
+
+ Most starred
+
+
- See all skills
+ New this week
+
+
+ Browse plugins
+
+
+ Browse users
+
+
+ Souls coming soon
+
+
+ Staff picks
@@ -181,6 +319,7 @@ function SkillsHome() {
);
}
+
function OnlyCrabsHome() {
const navigate = Route.useNavigate();
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
@@ -197,69 +336,65 @@ function OnlyCrabsHome() {
return (
-
-
-
-
SOUL.md, shared.
-
SoulHub, where system lore lives.
-
- Share SOUL.md bundles, version them like docs, and keep personal system lore in one
- public place.
-
-
-
- Publish Soul
-
-
+
+
+
+
OnlyCrabs
+
SoulHub, where system lore lives.
+
+ Share SOUL.md bundles, version them like docs, and keep personal system lore in one
+ public place.
+
+
-
-
-
-
-
Search souls. Versioned, readable, easy to remix.
+
setQuery(event.target.value)}
+ />
+
+ Search
+
+
-
- Latest souls
- Newest SOUL.md bundles across the hub.
+
+
+
Latest souls
+
+ See all
+
+
{latest.length === 0 ? (
No souls yet. Be the first.
@@ -278,21 +413,6 @@ function OnlyCrabsHome() {
))
)}
-
-
- See all souls
-
-
);
diff --git a/src/routes/management.tsx b/src/routes/management.tsx
index 27c538cce..b2dd15992 100644
--- a/src/routes/management.tsx
+++ b/src/routes/management.tsx
@@ -86,6 +86,13 @@ function promptBanReason(label: string) {
return trimmed.length > 0 ? trimmed : undefined;
}
+function promptUnbanReason(label: string) {
+ const result = window.prompt(`Unban reason for ${label} (optional)`);
+ if (result === null) return null;
+ const trimmed = result.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
export const Route = createFileRoute("/management")({
validateSearch: (search) => ({
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
@@ -118,6 +125,7 @@ function Management() {
const setRole = useMutation(api.users.setRole);
const banUser = useMutation(api.users.banUser);
+ const unbanUser = useMutation(api.users.unbanUser);
const setBatch = useMutation(api.skills.setBatch);
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
const hardDelete = useMutation(api.skills.hardDelete);
@@ -867,11 +875,32 @@ function Management() {
const label = `@${user.handle ?? user.name ?? "user"}`;
const reason = promptBanReason(label);
if (reason === null) return;
- void banUser({ userId: user._id, reason });
+ void banUser({ userId: user._id, reason }).catch((error) =>
+ window.alert(formatMutationError(error)),
+ );
}}
>
Ban user
+ {user.deletedAt && !user.deactivatedAt ? (
+ {
+ const label = `@${user.handle ?? user.name ?? "user"}`;
+ if (!window.confirm(`Unban ${label} and restore eligible skills?`)) {
+ return;
+ }
+ const reason = promptUnbanReason(label);
+ if (reason === null) return;
+ void unbanUser({ userId: user._id, reason }).catch((error) =>
+ window.alert(formatMutationError(error)),
+ );
+ }}
+ >
+ Unban user
+
+ ) : null}
))
diff --git a/src/routes/plugins/index.tsx b/src/routes/plugins/index.tsx
index b8a63b6b5..8853a36e4 100644
--- a/src/routes/plugins/index.tsx
+++ b/src/routes/plugins/index.tsx
@@ -1,8 +1,9 @@
-import { createFileRoute } from "@tanstack/react-router";
-import { Link } from "@tanstack/react-router";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { Search } from "lucide-react";
import { useEffect, useState } from "react";
+import { BrowseSidebar } from "../../components/BrowseSidebar";
+import { PluginListItem } from "../../components/PluginListItem";
import { fetchPluginCatalog, type PackageListItem } from "../../lib/packageApi";
-import { familyLabel } from "../../lib/packageLabels";
type PluginSearchState = {
q?: string;
@@ -38,94 +39,93 @@ export const Route = createFileRoute("/plugins/")({
}),
loaderDeps: ({ search }) => search,
loader: async ({ deps }) => {
- const data = await fetchPluginCatalog({
- q: deps.q,
- cursor: deps.q ? undefined : deps.cursor,
- family: deps.family,
- isOfficial: deps.verified,
- executesCode: deps.executesCode,
- limit: 50,
- });
- return {
- items: data.items,
- nextCursor: data.nextCursor,
- } satisfies PluginsLoaderData;
+ try {
+ const data = await fetchPluginCatalog({
+ q: deps.q,
+ cursor: deps.q ? undefined : deps.cursor,
+ family: deps.family,
+ isOfficial: deps.verified,
+ executesCode: deps.executesCode,
+ limit: 50,
+ });
+ return {
+ items: data.items ?? [],
+ nextCursor: data.nextCursor ?? null,
+ } satisfies PluginsLoaderData;
+ } catch {
+ return { items: [], nextCursor: null } satisfies PluginsLoaderData;
+ }
},
component: PluginsIndex,
});
-function VerifiedBadge() {
- return (
-
-
-
-
- );
-}
-
export function PluginsIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const { items, nextCursor } = Route.useLoaderData() as PluginsLoaderData;
const [query, setQuery] = useState(search.q ?? "");
+ const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
setQuery(search.q ?? "");
}, [search.q]);
- return (
-
-
+ const handleFilterToggle = (key: string) => {
+ if (key === "verified") {
+ void navigate({
+ search: (prev) => ({
+ ...prev,
+ cursor: undefined,
+ verified: prev.verified ? undefined : true,
+ }),
+ });
+ } else if (key === "executesCode") {
+ void navigate({
+ search: (prev) => ({
+ ...prev,
+ cursor: undefined,
+ executesCode: prev.executesCode ? undefined : true,
+ }),
+ });
+ }
+ };
-