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 --git a/src/components/Header.tsx b/src/components/Header.tsx index 097147d11..c0257d345 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,7 +1,7 @@ import { useAuthActions } from "@convex-dev/auth/react"; -import { Link } from "@tanstack/react-router"; -import { Menu, Monitor, Moon, Sun } from "lucide-react"; -import { useMemo, useRef } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { Github, Menu, Monitor, Moon, Search, Sun } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; import { getUserFacingAuthError } from "../lib/authErrorMessage"; import { gravatarUrl } from "../lib/gravatar"; import { isModerator } from "../lib/roles"; @@ -28,6 +28,7 @@ export default function Header() { const siteName = useMemo(() => getSiteName(siteMode), [siteMode]); const isSoulMode = siteMode === "souls"; const clawHubUrl = getClawHubSiteUrl(); + const navigate = useNavigate(); const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined); const handle = me?.handle ?? me?.displayName ?? "user"; @@ -36,6 +37,9 @@ export default function Header() { const { error: authError, clear: clearAuthError } = useAuthError(); const signInRedirectTo = getCurrentRelativeUrl(); + const [navSearchQuery, setNavSearchQuery] = useState(""); + const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const setTheme = (next: "system" | "light" | "dark") => { startThemeTransition({ nextTheme: next, @@ -49,281 +53,364 @@ export default function Header() { }); }; + const handleNavSearch = (e: React.FormEvent) => { + e.preventDefault(); + const q = navSearchQuery.trim(); + if (!q) return; + void navigate({ + to: "/search", + search: { q, type: undefined }, + }); + setNavSearchQuery(""); + setMobileSearchOpen(false); + }; + return (
- - - - - {siteName} - - -
-
- - - - - - {isSoulMode ? ( + +
+
+
+ + {/* Mobile search bar (expandable) */} + {mobileSearchOpen ? ( +
+
+
+ {isSoulMode ? null : ( + + Users + + )} + {isSoulMode ? null : ( + + About + + )} + {me ? ( + + Stars + + ) : null} + {me ? ( + + Dashboard + + ) : null} + {isStaff ? ( + + Manage + + ) : null} +
+
); diff --git a/src/components/MarketplaceIcon.tsx b/src/components/MarketplaceIcon.tsx new file mode 100644 index 000000000..3e3d6a243 --- /dev/null +++ b/src/components/MarketplaceIcon.tsx @@ -0,0 +1,63 @@ +import { FileText, Package, Plug, User } from "lucide-react"; + +type MarketplaceIconProps = { + kind: "skill" | "plugin" | "soul" | "user"; + label: string; + imageUrl?: string | null; + size?: "sm" | "md"; +}; + +const TONES = [ + { accent: "oklch(0.63 0.16 42)", wash: "oklch(0.95 0.04 42)" }, + { accent: "oklch(0.61 0.15 168)", wash: "oklch(0.95 0.04 168)" }, + { accent: "oklch(0.59 0.14 236)", wash: "oklch(0.95 0.04 236)" }, + { accent: "oklch(0.66 0.13 92)", wash: "oklch(0.96 0.04 92)" }, +] as const; + +function hashTone(label: string) { + let sum = 0; + for (const char of label) sum += char.charCodeAt(0); + return TONES[sum % TONES.length] ?? TONES[0]; +} + +function getIcon(kind: MarketplaceIconProps["kind"]) { + switch (kind) { + case "plugin": + return Plug; + case "soul": + return FileText; + case "user": + return User; + default: + return Package; + } +} + +export function MarketplaceIcon({ + kind, + label, + imageUrl, + size = "sm", +}: MarketplaceIconProps) { + const Icon = getIcon(kind); + const tone = hashTone(label); + + return ( + + ); +} diff --git a/src/components/PluginListItem.tsx b/src/components/PluginListItem.tsx new file mode 100644 index 000000000..b55a9105c --- /dev/null +++ b/src/components/PluginListItem.tsx @@ -0,0 +1,39 @@ +import { Link } from "@tanstack/react-router"; +import { MarketplaceIcon } from "./MarketplaceIcon"; +import { familyLabel } from "../lib/packageLabels"; +import type { PackageListItem } from "../lib/packageApi"; + +type PluginListItemProps = { + item: PackageListItem; +}; + +export function PluginListItem({ item }: PluginListItemProps) { + return ( + + +
+
+ {item.ownerHandle ? ( + <> + @{item.ownerHandle} + / + + ) : null} + {item.displayName} + {familyLabel(item.family)} + {item.isOfficial ? Verified : null} +
+

{item.summary ?? "Plugin package for agent workflows."}

+
+ Plugin + {item.latestVersion ? ( + v{item.latestVersion} + ) : null} + + {item.ownerHandle ? `@${item.ownerHandle}` : "community"} + +
+
+ + ); +} diff --git a/src/components/SkillCard.tsx b/src/components/SkillCard.tsx index e84669d1c..1eca6134f 100644 --- a/src/components/SkillCard.tsx +++ b/src/components/SkillCard.tsx @@ -1,5 +1,6 @@ import { Link } from "@tanstack/react-router"; import type { ReactNode } from "react"; +import { MarketplaceIcon } from "./MarketplaceIcon"; import type { PublicSkill } from "../lib/publicUser"; type SkillCardProps = { @@ -43,7 +44,10 @@ export function SkillCard({ ))} ) : null} -

{skill.displayName}

+
+ +

{skill.displayName}

+

{skill.summary ?? summaryFallback}

{meta}
diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index bf23e5ea4..2aa3c514a 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -9,7 +9,8 @@ import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage"; import { useAuthStatus } from "../lib/useAuthStatus"; import { ClientOnly } from "./ClientOnly"; import { SkillCommentsPanel } from "./SkillCommentsPanel"; -import { SkillDetailTabs } from "./SkillDetailTabs"; +import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs"; +import { SkillMetadataSidebar } from "./SkillMetadataSidebar"; import { buildSkillHref, formatConfigSnippet, @@ -93,7 +94,7 @@ export function SkillDetailPage({ ); const [tagName, setTagName] = useState("latest"); const [tagVersionId, setTagVersionId] = useState | "">(""); - const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files"); + const [activeTab, setActiveTab] = useState("readme"); const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false); const [isReportDialogOpen, setIsReportDialogOpen] = useState(false); const [reportReason, setReportReason] = useState(""); @@ -208,7 +209,7 @@ export function SkillDetailPage({ ?.clawdis; const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]); const nixPlugin = clawdis?.nix?.plugin; - const nixSystems = clawdis?.nix?.systems ?? []; + const _nixSystems = clawdis?.nix?.systems ?? []; const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null; const configRequirements = clawdis?.config; const configExample = configRequirements?.example @@ -398,68 +399,79 @@ export function SkillDetailPage({ /> ) : null} - {nixSnippet ? ( -
-

- Install via Nix -

-

- {nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"} -

-
-              {nixSnippet}
-            
+
+
+ {nixSnippet ? ( +
+

+ Install via Nix +

+
+                  {nixSnippet}
+                
+
+ ) : null} + + {configExample ? ( +
+

+ Config example +

+
+                  {configExample}
+                
+
+ ) : null} + + setShouldPrefetchCompare(true)} + readmeContent={readmeContent} + readmeError={readmeError} + latestFiles={latestFiles} + latestVersionId={latestVersion?._id ?? null} + skill={skill as Doc<"skills">} + diffVersions={diffVersions} + versions={versions} + nixPlugin={Boolean(nixPlugin)} + suppressVersionScanResults={suppressVersionScanResults} + scanResultsSuppressedMessage={scanResultsSuppressedMessage} + /> + + +

+ Comments +

+

+ Loading comments... +

+
+ } + > + +
- ) : null} - {configExample ? ( -
-

- Config example -

-

- Starter config for this plugin bundle. -

-
-              {configExample}
-            
-
- ) : null} - - setShouldPrefetchCompare(true)} - readmeContent={readmeContent} - readmeError={readmeError} - latestFiles={latestFiles} - latestVersionId={latestVersion?._id ?? null} - skill={skill as Doc<"skills">} - diffVersions={diffVersions} - versions={versions} - nixPlugin={Boolean(nixPlugin)} - suppressVersionScanResults={suppressVersionScanResults} - scanResultsSuppressedMessage={scanResultsSuppressedMessage} - /> - - -

- Comments -

-

- Loading comments… -

-
- } - > - - + type SkillFile = Doc<"skillVersions">["files"][number]; +export type DetailTab = "readme" | "files" | "compare" | "versions"; + type SkillDetailTabsProps = { - activeTab: "files" | "compare" | "versions"; - setActiveTab: (tab: "files" | "compare" | "versions") => void; + activeTab: DetailTab; + setActiveTab: (tab: DetailTab) => void; onCompareIntent: () => void; readmeContent: string | null; readmeError: string | null; @@ -43,31 +47,42 @@ export function SkillDetailTabs({ suppressVersionScanResults, scanResultsSuppressedMessage, }: SkillDetailTabsProps) { + const compareEnabled = (versions?.length ?? 0) > 1; + return (
+ {compareEnabled ? ( + + ) : null}
+ {activeTab === "readme" ? ( +
+ {readmeContent ? ( +
+ {readmeContent} +
+ ) : readmeError ? ( +
+

No README available

+

+ This skill doesn't have a SKILL.md file yet. +

+
+ ) : ( +
+ Loading README... +
+ )} +
+ ) : null} + {activeTab === "files" ? ( - Loading file viewer…
}> + Loading file viewer...}> @@ -90,7 +124,7 @@ export function SkillDetailTabs({ {activeTab === "compare" ? (
- Loading diff viewer…
}> + Loading diff viewer...}> diff --git a/src/components/SkillDiffCard.tsx b/src/components/SkillDiffCard.tsx index 4a9a83462..3fff78cb0 100644 --- a/src/components/SkillDiffCard.tsx +++ b/src/components/SkillDiffCard.tsx @@ -285,77 +285,86 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa Inline or side-by-side diff for any file.

-
- Diff layout - - -
+ {!diffUnavailable ? ( +
+ Diff layout + + +
+ ) : null} -
-
- - -
- -
- - -
-
+ {!diffUnavailable ? ( + <> +
+
+ + +
+ +
+ + +
+
-
- - 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 ? (
); 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)} + + + + +
+
+ + ); +} 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.
-
- - Browse Skills - - - Reviewer Doc - -
-
-
+
+
+ + Browse Skills + + + Reviewer Doc + +
+ ); } 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. +

+
{ + event.preventDefault(); + void navigate({ + to: "/souls", + search: { + q: trimmedQuery || undefined, + sort: undefined, + dir: undefined, + view: undefined, + focus: undefined, + }, + }); }} - className="btn" > - Browse souls - -
-
-
- { - event.preventDefault(); - void navigate({ - to: "/souls", - search: { - q: trimmedQuery || undefined, - sort: undefined, - dir: undefined, - view: undefined, - focus: undefined, - }, - }); - }} - > - / - setQuery(event.target.value)} - /> - -
-
Search souls. Versioned, readable, easy to remix.
+ setQuery(event.target.value)} + /> + +
-
-

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 ? ( + + ) : 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 ( -
-
-

- Plugins -

-

- Browse the plugin catalog. -

-
+ 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, + }), + }); + } + }; -
-
-
{ - event.preventDefault(); - void navigate({ - search: (prev) => ({ - ...prev, - cursor: undefined, - q: query.trim() || undefined, - }), - }); - }} + const handleFamilySort = (value: string) => { + const family = + value === "code-plugin" || value === "bundle-plugin" ? value : undefined; + void navigate({ + search: (prev) => ({ + ...prev, + cursor: undefined, + family: family as "code-plugin" | "bundle-plugin" | undefined, + }), + }); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + void navigate({ + search: (prev) => ({ + ...prev, + cursor: undefined, + q: query.trim() || undefined, + }), + }); + }; + + return ( +
+
+

Plugins

+
+ - Publish Plugin + Publish
-
-
- {([ - { value: undefined, label: "All" }, - { value: "code-plugin" as const, label: "Code" }, - { value: "bundle-plugin" as const, label: "Bundles" }, - ]).map((opt) => ( - - ))} -
- - -
- - {items.length === 0 ? ( -
No plugins match that filter.
- ) : ( - <> -
- {items.map((item) => ( - -
- {familyLabel(item.family)} - {item.isOfficial ? ( - - Verified - - ) : null} -
-

{item.displayName}

-

- {item.summary ?? "No summary provided."} -

-
- - {item.ownerHandle ? `by ${item.ownerHandle}` : "community"} - - {item.latestVersion ? ( - v{item.latestVersion} - ) : null} -
- - ))} +
+
); } diff --git a/src/routes/search.tsx b/src/routes/search.tsx index 7c8c58e71..4e233ab90 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -1,42 +1,170 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; -import { detectSiteMode } from "../lib/site"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Search } from "lucide-react"; +import { useState } from "react"; +import { PluginListItem } from "../components/PluginListItem"; +import { SkillListItem } from "../components/SkillListItem"; +import { UserListItem } from "../components/UserListItem"; +import type { PublicSkill, PublicUser } from "../lib/publicUser"; +import { + useUnifiedSearch, + type UnifiedPluginResult, + type UnifiedSkillResult, + type UnifiedUserResult, +} from "../lib/useUnifiedSearch"; + +type SearchState = { + q?: string; + type?: "all" | "skills" | "plugins" | "users"; +}; export const Route = createFileRoute("/search")({ - validateSearch: (search) => ({ + validateSearch: (search): SearchState => ({ q: typeof search.q === "string" && search.q.trim() ? search.q : undefined, - highlighted: search.highlighted === "1" || search.highlighted === "true" ? true : undefined, - nonSuspicious: - search.nonSuspicious === "1" || search.nonSuspicious === "true" ? true : undefined, + type: + search.type === "skills" || search.type === "plugins" || search.type === "users" + ? search.type + : undefined, }), - beforeLoad: ({ search, location }) => { - const hostname = - (location as { url?: URL }).url?.hostname ?? - (typeof window !== "undefined" ? window.location.hostname : undefined); - const mode = detectSiteMode(hostname); - if (mode === "skills") { - throw redirect({ - to: "/skills", - search: { - q: search.q || undefined, - sort: undefined, - dir: undefined, - highlighted: search.highlighted || undefined, - nonSuspicious: search.nonSuspicious || undefined, - view: undefined, - focus: undefined, - }, - replace: true, - }); - } - - throw redirect({ - to: "/", - search: { - q: search.q || undefined, - highlighted: undefined, - search: search.q ? undefined : true, - }, + component: UnifiedSearchPage, +}); + +function UnifiedSearchPage() { + const search = Route.useSearch(); + const navigate = useNavigate(); + const activeType = search.type ?? "all"; + const [query, setQuery] = useState(search.q ?? ""); + + const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch( + search.q ?? "", + activeType, + ); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + void navigate({ + to: "/search", + search: { q: query.trim() || undefined, type: search.type }, + }); + }; + + const setType = (type: "all" | "skills" | "plugins" | "users") => { + void navigate({ + to: "/search", + search: { q: search.q, type: type === "all" ? undefined : type }, replace: true, }); - }, -}); + }; + + return ( +
+

+ {search.q ? ( + <> + Search results for "{search.q}" + + ) : ( + "Search" + )} +

+ +
+
+
+
+ +
+ + + + +
+ + {isSearching ? ( +
+
Searching...
+
+ ) : !search.q ? ( +
+

+ Enter a search term to find skills, plugins, and users +

+
+ ) : results.length === 0 ? ( +
+

No results found for "{search.q}"

+
+ ) : ( +
+ {results.map((item) => + item.type === "skill" ? ( + + ) : item.type === "plugin" ? ( + + ) : ( + + ), + )} +
+ )} +
+ ); +} + +function SkillResultRow({ result }: { result: UnifiedSkillResult }) { + const skill = result.skill as unknown as PublicSkill; + return ( + + ); +} + +function PluginResultRow({ result }: { result: UnifiedPluginResult }) { + return ; +} + +function UserResultRow({ result }: { result: UnifiedUserResult }) { + const user = result.user as PublicUser; + return ; +} diff --git a/src/routes/skills/-SkillsResults.tsx b/src/routes/skills/-SkillsResults.tsx index bf37ee6d1..133d23a23 100644 --- a/src/routes/skills/-SkillsResults.tsx +++ b/src/routes/skills/-SkillsResults.tsx @@ -1,8 +1,8 @@ -import { Link } from "@tanstack/react-router"; import type { RefObject } from "react"; import { SkillCard } from "../../components/SkillCard"; +import { SkillListItem } from "../../components/SkillListItem"; import { getPlatformLabels } from "../../components/skillDetailUtils"; -import { SkillMetricsRow, SkillStatsTripletLine } from "../../components/SkillStats"; +import { SkillStatsTripletLine } from "../../components/SkillStats"; import { UserBadge } from "../../components/UserBadge"; import { getSkillBadges } from "../../lib/badges"; import { buildSkillHref, type SkillListEntry } from "./-types"; @@ -24,7 +24,7 @@ export function SkillsResults({ isLoadingSkills, sorted, view, - listDoneLoading, + listDoneLoading: _listDoneLoading, hasQuery, canLoadMore, isLoadingMore, @@ -35,12 +35,24 @@ export function SkillsResults({ return ( <> {isLoadingSkills ? ( -
-
Loading skills…
+
+ {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+
+
+
+ ))}
) : sorted.length === 0 ? ( -
- {listDoneLoading || hasQuery ? "No skills match that filter." : "Loading skills…"} +
+

No skills found

+

+ {hasQuery ? "Try a different search term or remove filters." : "No skills have been published yet."} +

) : view === "cards" ? (
@@ -78,45 +90,17 @@ export function SkillsResults({ })}
) : ( -
-
- Skill - Summary - Author - Stats -
+
{sorted.map((entry) => { const skill = entry.skill; const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null; - const skillHref = buildSkillHref(skill, ownerHandle); return ( - - - - {skill.displayName} - {getSkillBadges(skill).map((badge) => ( - {badge} - ))} - - {entry.latestVersion?.version ? ( - v{entry.latestVersion.version} - ) : null} - - - {skill.summary ?? "No summary provided."} - - - - - - - - + ); })}
@@ -130,13 +114,13 @@ export function SkillsResults({ > {canAutoLoad ? ( isLoadingMore ? ( - "Loading more…" + "Loading more..." ) : ( "Scroll to load more" ) ) : ( )}
diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 97e7cbb92..df68594a7 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -1,12 +1,24 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { useQuery } from "convex/react"; -import { useRef } from "react"; +import { Search } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { api } from "../../../convex/_generated/api"; +import { BrowseSidebar } from "../../components/BrowseSidebar"; +import { SKILL_CATEGORIES } from "../../lib/categories"; +import { formatCompactStat } from "../../lib/numberFormat"; import { parseSort } from "./-params"; import { SkillsResults } from "./-SkillsResults"; -import { SkillsToolbar } from "./-SkillsToolbar"; import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel"; +const SORT_OPTIONS = [ + { value: "downloads", label: "Most downloaded" }, + { value: "stars", label: "Most starred" }, + { value: "installs", label: "Most installed" }, + { value: "updated", label: "Recently updated" }, + { value: "newest", label: "Newest" }, + { value: "name", label: "Name" }, +]; + export const Route = createFileRoute("/skills/")({ validateSearch: (search): SkillsSearchState => { return { @@ -53,7 +65,8 @@ export function SkillsIndex() { const searchInputRef = useRef(null); const totalSkills = useQuery(api.skills.countPublicSkills); const totalSkillsText = - typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null; + typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null; + const [sidebarOpen, setSidebarOpen] = useState(false); const model = useSkillsBrowseModel({ navigate, @@ -61,48 +74,133 @@ export function SkillsIndex() { searchInputRef, }); + const sortOptionsWithRelevance = model.hasQuery + ? [{ value: "relevance", label: "Relevance" }, ...SORT_OPTIONS] + : SORT_OPTIONS; + + const handleFilterToggle = useCallback( + (key: string) => { + if (key === "highlighted") model.onToggleHighlighted(); + else if (key === "nonSuspicious") model.onToggleNonSuspicious(); + }, + [model.onToggleHighlighted, model.onToggleNonSuspicious], + ); + + const handleCategoryChange = useCallback( + (slug: string | undefined) => { + if (slug) { + const cat = SKILL_CATEGORIES.find((c) => c.slug === slug); + if (cat?.keywords[0]) { + model.onQueryChange(cat.keywords[0]); + } + } else { + model.onQueryChange(""); + } + }, + [model.onQueryChange], + ); + + const activeCategory = useMemo(() => { + if (!model.query) return undefined; + return ( + SKILL_CATEGORIES.find((c) => + c.keywords.some((k) => k === model.query.trim().toLowerCase()), + )?.slug ?? undefined + ); + }, [model.query]); + return ( -
-
-

+
+
+

Skills - {totalSkillsText && {` (${totalSkillsText})`}} + {totalSkillsText ? ( + {totalSkillsText} + ) : null}

-

- {model.isLoadingSkills - ? "Loading skills…" - : `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(", ")})` : ""}.`} -

-

-
- setSidebarOpen(!sidebarOpen)} + aria-label="Toggle filters" + > + Filters + +
+
+
); diff --git a/src/routes/souls/index.tsx b/src/routes/souls/index.tsx index 612193521..91d4582ef 100644 --- a/src/routes/souls/index.tsx +++ b/src/routes/souls/index.tsx @@ -1,10 +1,4 @@ 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 { SoulCard } from "../../components/SoulCard"; -import { SoulMetricsRow, SoulStatsTripletLine } from "../../components/SoulStats"; -import type { PublicSoul } from "../../lib/publicUser"; const sortKeys = ["newest", "downloads", "stars", "name", "updated"] as const; type SortKey = (typeof sortKeys)[number]; @@ -16,7 +10,7 @@ function parseSort(value: unknown): SortKey { return "newest"; } -function parseDir(value: unknown, sort: SortKey): SortDir { +function _parseDir(value: unknown, sort: SortKey): SortDir { if (value === "asc" || value === "desc") return value; return sort === "name" ? "asc" : "desc"; } @@ -31,212 +25,57 @@ export const Route = createFileRoute("/souls/")({ focus: search.focus === "search" ? "search" : undefined, }; }, - component: SoulsIndex, + component: SoulsHoldingPage, }); -function SoulsIndex() { - const navigate = Route.useNavigate(); - const search = Route.useSearch(); - const sort = search.sort ?? "newest"; - const dir = parseDir(search.dir, sort); - const view = search.view ?? "list"; - const [query, setQuery] = useState(search.q ?? ""); - - const souls = useQuery(api.souls.list, { limit: 500 }) as PublicSoul[] | undefined; - const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds); - const seedEnsuredRef = useRef(false); - const searchInputRef = useRef(null); - const isLoadingSouls = souls === undefined; - - useEffect(() => { - setQuery(search.q ?? ""); - }, [search.q]); - - // Auto-focus search input when focus=search param is present - useEffect(() => { - if (search.focus === "search" && searchInputRef.current) { - searchInputRef.current.focus(); - // Clear the focus param from URL to avoid re-focusing on navigation - void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true }); - } - }, [search.focus, navigate]); - - useEffect(() => { - if (seedEnsuredRef.current) return; - seedEnsuredRef.current = true; - void ensureSoulSeeds({}); - }, [ensureSoulSeeds]); - - const filtered = useMemo(() => { - const value = query.trim().toLowerCase(); - const all = souls ?? []; - if (!value) return all; - return all.filter((soul) => { - if (soul.slug.toLowerCase().includes(value)) return true; - if (soul.displayName.toLowerCase().includes(value)) return true; - return (soul.summary ?? "").toLowerCase().includes(value); - }); - }, [query, souls]); - - const sorted = useMemo(() => { - const multiplier = dir === "asc" ? 1 : -1; - const results = [...filtered]; - results.sort((a, b) => { - switch (sort) { - case "downloads": - return (a.stats.downloads - b.stats.downloads) * multiplier; - case "stars": - return (a.stats.stars - b.stats.stars) * multiplier; - case "updated": - return (a.updatedAt - b.updatedAt) * multiplier; - case "name": - return ( - (a.displayName.localeCompare(b.displayName) || a.slug.localeCompare(b.slug)) * - multiplier - ); - default: - return (a.createdAt - b.createdAt) * multiplier; - } - }); - return results; - }, [dir, filtered, sort]); - - const showing = sorted.length; - const total = souls?.length; - +function SoulsHoldingPage() { return ( -
-
+
+
-

- Souls -

-

- {isLoadingSouls - ? "Loading souls…" - : `${showing}${typeof total === "number" ? ` of ${total}` : ""} souls.`} +

+ Souls + Coming soon +
+

SOUL.md discovery is on deck

+

+ This page is the holding area for public SOUL.md profiles you’ll be able to discover, + compare, and share. We’re not shipping a half-baked directory just to tick a box.

-
-
- { - const next = event.target.value; - const trimmed = next.trim(); - setQuery(next); - void navigate({ - search: (prev) => ({ ...prev, q: trimmed ? next : undefined }), - replace: true, - }); - }} - placeholder="Filter by name, slug, or summary…" - /> -
-
- - - -
+
+
+

Discover

+

Browse public system personas, writing voices, and full character sheets.

+
+
+

Share

+

Publish versioned SOUL.md files with attribution and clean history.

+
+
+

Compare

+

Inspect changes, stats, and adoption without the usual metadata sludge.

+
-
+ - {isLoadingSouls ? ( -
-
Loading souls…
-
- ) : showing === 0 ? ( -
No souls match that filter.
- ) : view === "cards" ? ( -
- {sorted.map((soul) => ( - - -
- } - /> - ))} +
+
+ In the meantime +

+ ClawHub already handles skills and plugins. Souls will get the same discovery treatment + once the publishing flow is ready. +

- ) : ( -
- {sorted.map((soul) => ( - -
-
- {soul.displayName} - /{soul.slug} -
-
{soul.summary ?? "SOUL.md bundle."}
-
-
- -
- - ))} +
+ + Browse Skills + + + Browse Users +
- )} +
); } diff --git a/src/routes/u/$handle.tsx b/src/routes/u/$handle.tsx index a5f7eac58..a0099ec3d 100644 --- a/src/routes/u/$handle.tsx +++ b/src/routes/u/$handle.tsx @@ -3,9 +3,7 @@ import { useMutation, useQuery } from "convex/react"; import { useEffect, useState } from "react"; import { api } from "../../../convex/_generated/api"; import type { Doc } from "../../../convex/_generated/dataModel"; -import { SkillCard } from "../../components/SkillCard"; -import { SkillStatsTripletLine } from "../../components/SkillStats"; -import { getSkillBadges } from "../../lib/badges"; +import { SkillListItem } from "../../components/SkillListItem"; import type { PublicSkill, PublicUser } from "../../lib/publicUser"; export const Route = createFileRoute("/u/$handle")({ @@ -65,14 +63,18 @@ function UserProfile() { const published = publishedSkills ?? []; return ( -
-
-