diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 888d62a11..2629ba151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,13 @@ jobs: - name: Test run: bun run test + env: + VITE_CONVEX_URL: https://example.invalid - name: Coverage run: bun run coverage + env: + VITE_CONVEX_URL: https://example.invalid - name: ClawHub CLI Verify run: bun run --cwd packages/clawhub verify 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 3a301a772..49873ffff 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/schema.ts b/convex/schema.ts index 4ed178d93..36e820e45 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -40,7 +40,8 @@ const users = defineTable({ }) .index("email", ["email"]) .index("phone", ["phone"]) - .index("handle", ["handle"]); + .index("handle", ["handle"]) + .index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]); const publishers = defineTable({ kind: v.union(v.literal("user"), v.literal("org")), 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.ts b/convex/users.ts index bed03ac7c..3efa7602f 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,22 +1,17 @@ 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 { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access"; import { syncGitHubProfile } from "./lib/githubAccount"; -import { toPublicUser } from "./lib/public"; import { ensurePersonalPublisherForUser, getActiveUserByHandleOrPersonalPublisher, getPublisherByHandle, getUserByHandleOrPersonalPublisher, } from "./lib/publishers"; +import { toPublicUser } from "./lib/public"; import { getLatestActiveReservedHandle, isHandleReservedForAnotherUser, @@ -301,9 +296,7 @@ export async function ensureHandler(ctx: MutationCtx) { updates.updatedAt = Date.now(); await ctx.db.patch(userId, updates); } - const ensuredUser = hasUpdates - ? ({ ...user, ...updates } as Doc<"users">) - : ((await ctx.db.get(userId)) ?? user); + const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user); await ensurePersonalPublisherForUser(ctx, ensuredUser); return await ctx.db.get(userId); } @@ -393,6 +386,23 @@ export const list = query({ }, }); +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, + }; + }, +}); + function normalizeSearchQuery(search?: string) { const trimmed = search?.trim().toLowerCase(); return trimmed ? trimmed : undefined; @@ -403,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); @@ -431,6 +435,27 @@ 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") + .withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined)) + .order("desc") + .take(scanLimit); + const activeUsers = scannedUsers.filter((user) => 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); } @@ -442,6 +467,41 @@ export const getByHandle = query({ }, }); +/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */ +export const getHoverStats = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + const skills = []; + let cursor: string | null = null; + + for (;;) { + const page = await ctx.db + .query("skills") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.userId)) + .paginate({ cursor, numItems: 100 }); + skills.push(...page.page); + if (page.isDone) { + break; + } + cursor = page.continueCursor; + } + + const active = skills.filter((s) => !s.softDeletedAt); + let totalStars = 0; + let totalDownloads = 0; + for (const s of active) { + totalStars += s.stats?.stars ?? 0; + totalDownloads += s.stats?.downloads ?? 0; + } + + return { + publishedSkills: active.length, + totalStars, + totalDownloads, + }; + }, +}); + export const getReservedHandleInternal = internalQuery({ args: { handle: v.string() }, handler: async (ctx, args) => { @@ -860,8 +920,7 @@ async function ensurePublisherHandleWithActor( if (existing) { const nextDisplayName = - args.displayName?.trim() && - (!existing.displayName || existing.displayName === existing.handle) + args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle) ? displayName : existing.displayName; await ctx.db.patch(existing._id, { diff --git a/public/clawd-logo.png b/public/clawd-logo.png index 450e7d406..5b4f98839 100644 Binary files a/public/clawd-logo.png and b/public/clawd-logo.png differ diff --git a/public/clawd-mark.png b/public/clawd-mark.png index dd8a2c7ed..7533d1545 100644 Binary files a/public/clawd-mark.png and b/public/clawd-mark.png differ diff --git a/public/favicon.ico b/public/favicon.ico index ff0aff670..f5619bbfc 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 000000000..2476f5188 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo192.png b/public/logo192.png index fc44b0a37..1a63f2d5c 100644 Binary files a/public/logo192.png and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png index a4e47a654..5b4f98839 100644 Binary files a/public/logo512.png and b/public/logo512.png differ diff --git a/src/__tests__/header.test.tsx b/src/__tests__/header.test.tsx index 298dae594..846466f96 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", () => ({ @@ -16,12 +20,14 @@ vi.mock("@convex-dev/auth/react", () => ({ }), })); +const authStatusMock = vi.fn(() => ({ + isAuthenticated: false, + isLoading: false, + me: null, +})); + vi.mock("../lib/useAuthStatus", () => ({ - useAuthStatus: () => ({ - isAuthenticated: false, - isLoading: false, - me: null, - }), + useAuthStatus: () => authStatusMock(), })); vi.mock("../lib/theme", () => ({ @@ -56,7 +62,7 @@ vi.mock("../lib/roles", () => ({ vi.mock("../lib/site", () => ({ getClawHubSiteUrl: () => "https://clawhub.ai", - getSiteMode: () => "souls", + getSiteMode: () => siteModeMock(), getSiteName: () => "OnlyCrabs", })); @@ -68,6 +74,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}
, @@ -85,8 +109,22 @@ 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__/import.route.test.tsx b/src/__tests__/import.route.test.tsx index 0fba7b1e3..9de383615 100644 --- a/src/__tests__/import.route.test.tsx +++ b/src/__tests__/import.route.test.tsx @@ -17,6 +17,7 @@ const useAuthStatusMock = vi.fn(); let useActionCallCount = 0; vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useQuery: (...args: unknown[]) => useQueryMock(...args), useAction: () => { const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3]; diff --git a/src/__tests__/packages-publish-route.test.tsx b/src/__tests__/packages-publish-route.test.tsx index 209c80ce3..9d1425644 100644 --- a/src/__tests__/packages-publish-route.test.tsx +++ b/src/__tests__/packages-publish-route.test.tsx @@ -26,6 +26,7 @@ const useAuthStatusMock = vi.fn(); const originalFetch = globalThis.fetch; vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useMutation: () => generateUploadUrl, useAction: () => publishRelease, useQuery: () => undefined, diff --git a/src/__tests__/search-route.test.ts b/src/__tests__/search-route.test.ts index 98aca8a95..adb2dd907 100644 --- a/src/__tests__/search-route.test.ts +++ b/src/__tests__/search-route.test.ts @@ -1,99 +1,74 @@ import { describe, expect, it, vi } from "vitest"; +process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud"; + + +vi.mock("../convex/client", () => ({ + convex: {}, + convexHttp: { query: vi.fn() }, +})); + 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("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("validates search with type filter", () => { + expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({ + q: "crab", + type: "skills", }); }); - 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("ignores invalid type filter", () => { + expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({ + q: "crab", + type: undefined, }); }); - 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("accepts the users type filter", () => { + expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({ + q: "vincent", + type: "users", }); }); + + 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 29364d207..60c3fb415 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -6,6 +6,18 @@ import { SkillDetailPage } from "../components/SkillDetailPage"; const navigateMock = vi.fn(); const useAuthStatusMock = vi.fn(); +process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud"; + + +vi.mock("../components/UserBadge", () => ({ + UserBadge: () => null, +})); + +vi.mock("../convex/client", () => ({ + convex: {}, + convexHttp: { query: vi.fn() }, +})); + vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: unknown }) => children, useNavigate: () => navigateMock, @@ -15,6 +27,7 @@ const useQueryMock = vi.fn(); const getReadmeMock = vi.fn(); vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: () => vi.fn(), useAction: () => getReadmeMock, @@ -24,8 +37,8 @@ vi.mock("../lib/useAuthStatus", () => ({ useAuthStatus: () => useAuthStatusMock(), })); -vi.mock("../components/SkillDiffCard", () => ({ - SkillDiffCard: () =>
, +vi.mock("../components/SkillCommentsPanel", () => ({ + SkillCommentsPanel: () =>
, })); describe("SkillDetailPage", () => { @@ -58,11 +71,8 @@ describe("SkillDetailPage", () => { return undefined; }); - const { container } = render(); - // Loading state now renders a skeleton, not text - expect( - container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'), - ).toBeTruthy(); + render(); + expect(screen.getByText(/Loading skill/i)).toBeTruthy(); expect(screen.queryByText(/Skill not found/i)).toBeNull(); }); @@ -135,174 +145,11 @@ describe("SkillDetailPage", () => { />, ); - // With initialData, should render content instead of skeleton - expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy(); + expect(screen.queryByText(/Loading skill/i)).toBeNull(); + expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0); expect(screen.getByText(/Get current weather\./i)).toBeTruthy(); - expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy(); - }); - - it("shows capability tags on the skill page without other scan findings", async () => { - useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { - if (args === "skip") return undefined; - if (args && typeof args === "object" && "skillId" in args) return []; - return undefined; - }); - - render( - , - ); - - expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy(); - expect(screen.getByText("Capability signals")).toBeTruthy(); - expect(screen.getByText("Crypto")).toBeTruthy(); - expect(screen.getByText("Requires wallet")).toBeTruthy(); - expect(screen.getByText("Can make purchases")).toBeTruthy(); - }); - - it("prefers the full frontmatter description over the shortened summary in the header", async () => { - useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { - if (args === "skip") return undefined; - if (args && typeof args === "object" && "skillId" in args) return []; - return undefined; - }); - - const fullDescription = - "Add credit-based payments to any OpenClaw skill. Register paid skills, charge users per call, track earnings, and withdraw USDC. Use when a user wants to monetize a skill."; - - render( - , - ); - - expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy(); - // The header now always shows skill.summary (not frontmatter.description) - expect( - screen.getByText("Add credit-based payments to any OpenClaw skill. Register paid skills..."), - ).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 () => { @@ -375,7 +222,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(); }); @@ -394,17 +241,17 @@ describe("SkillDetailPage", () => { useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { if (args === "skip") return undefined; if (args && typeof args === "object" && "skillId" in args) return []; - return { - skill: { - _id: "skills:1", - slug: "weather", - displayName: "Weather", - summary: "Get current weather.", - ownerUserId: "users:1", - ownerPublisherId: "publishers:steipete", - tags: {}, - stats: { stars: 0, downloads: 0 }, - }, + return { + skill: { + _id: "skills:1", + slug: "weather", + displayName: "Weather", + summary: "Get current weather.", + ownerUserId: "users:1", + ownerPublisherId: "publishers:steipete", + tags: {}, + stats: { stars: 0, downloads: 0 }, + }, owner: { _id: "publishers:steipete", _creationTime: 0, @@ -417,9 +264,8 @@ describe("SkillDetailPage", () => { }; }); - const { container } = render(); - // Loading state now renders a skeleton, not text - expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy(); + render(); + expect(screen.getByText(/Loading skill/i)).toBeTruthy(); await waitFor(() => { expect(navigateMock).toHaveBeenCalled(); @@ -524,7 +370,7 @@ describe("SkillDetailPage", () => { />, ); - expect(screen.queryByText(/Skill not found/i)).toBeNull(); + expect(screen.queryByText(/Loading skill/i)).toBeNull(); expect(screen.getAllByText("Weather").length).toBeGreaterThan(0); expect(navigateMock).not.toHaveBeenCalled(); }); @@ -634,10 +480,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: { @@ -666,6 +526,7 @@ describe("SkillDetailPage", () => { render(); expect(await screen.findByText("Weather")).toBeTruthy(); + expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy(); expect( useQueryMock.mock.calls.some((call) => { @@ -679,9 +540,7 @@ describe("SkillDetailPage", () => { }), ).toBe(false); - const compareTab = screen.getByRole("tab", { name: /compare/i }); - fireEvent.mouseEnter(compareTab); - fireEvent.click(compareTab); + fireEvent.click(screen.getByRole("button", { name: /compare/i })); await waitFor(() => { expect( diff --git a/src/__tests__/skill-route-loader.test.ts b/src/__tests__/skill-route-loader.test.ts index bb64a85ef..5aac513a5 100644 --- a/src/__tests__/skill-route-loader.test.ts +++ b/src/__tests__/skill-route-loader.test.ts @@ -17,6 +17,7 @@ vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: { + beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown; loader?: (args: { params: { owner: string; slug: string } }) => Promise; component?: unknown; head?: unknown; @@ -31,6 +32,7 @@ vi.mock("../lib/skillPage", () => ({ async function loadRoute() { return (await import("../routes/$owner/$slug")).Route as unknown as { __config: { + beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown; loader?: (args: { params: { owner: string; slug: string } }) => Promise; head?: (args: { params: { owner: string; slug: string }; @@ -45,6 +47,14 @@ async function loadRoute() { }; } +async function runBeforeLoad(params: { owner: string; slug: string }) { + const route = await loadRoute(); + const beforeLoad = route.__config.beforeLoad as ((args: { + params: { owner: string; slug: string }; + }) => unknown) | undefined; + return beforeLoad?.({ params }); +} + async function runLoader(params: { owner: string; slug: string }) { const route = await loadRoute(); const loader = route.__config.loader as (args: { @@ -71,6 +81,18 @@ function runHead( } describe("skill route loader", () => { + it("allows numeric owner handles in beforeLoad", () => { + expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow(); + }); + + it("allows raw owner ids in beforeLoad", () => { + expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow(); + }); + + it("allows raw publisher ids in beforeLoad", () => { + expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow(); + }); + beforeEach(() => { fetchSkillPageDataMock.mockReset(); }); diff --git a/src/__tests__/skills-index-load-more.test.tsx b/src/__tests__/skills-index-load-more.test.tsx index bf6165375..418134a09 100644 --- a/src/__tests__/skills-index-load-more.test.tsx +++ b/src/__tests__/skills-index-load-more.test.tsx @@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useAction: (...args: unknown[]) => convexReactMocks.useAction(...args), useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args), })); diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index c1145c499..f99f2f659 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useAction: (...args: unknown[]) => convexReactMocks.useAction(...args), useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args), })); @@ -66,7 +67,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 +75,9 @@ describe("SkillsIndex", () => { convexHttpMock.query.mockReturnValue(new Promise(() => {})); render(); await act(async () => {}); - // Header subtitle shows "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 +92,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 +137,7 @@ describe("SkillsIndex", () => { render(); - const input = screen.getByPlaceholderText("Search skills 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 +162,7 @@ describe("SkillsIndex", () => { render(); - const input = screen.getByPlaceholderText("Search skills 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(); @@ -251,9 +252,12 @@ describe("SkillsIndex", () => { await vi.runAllTimersAsync(); }); - const links = screen.getAllByRole("link"); - expect(links[0]?.textContent).toContain("Older High Score"); - expect(links[1]?.textContent).toContain("Newer Low Score"); + const titles = Array.from( + document.querySelectorAll(".skill-list-item-name"), + ).map((node) => node.textContent); + + expect(titles[0]).toBe("Older High Score"); + expect(titles[1]).toBe("Newer Low Score"); }); it("passes nonSuspiciousOnly to list query when filter is active", async () => { @@ -288,42 +292,6 @@ describe("SkillsIndex", () => { ); }); - it("passes capabilityTag to list query when tag filter is active", async () => { - searchMock = { tag: "crypto" }; - render(); - await act(async () => {}); - - expect(convexHttpMock.query).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - capabilityTag: "crypto", - }), - ); - }); - - it("shows and clears the active capability tag filter", async () => { - searchMock = { tag: "crypto" }; - render(); - await act(async () => {}); - - const capabilityChip = screen.getByRole("button", { name: /crypto/i }); - expect(capabilityChip).toBeTruthy(); - - await act(async () => { - fireEvent.click(capabilityChip); - }); - - expect(navigateMock).toHaveBeenCalled(); - const lastCall = navigateMock.mock.calls.at(-1)?.[0] as { - replace?: boolean; - search: (prev: Record) => Record; - }; - expect(lastCall.replace).toBe(true); - expect(lastCall.search({ tag: "crypto" })).toEqual({ - tag: undefined, - }); - }); - it("shows load-more button when more results are available", async () => { vi.stubGlobal("IntersectionObserver", undefined); convexHttpMock.query.mockResolvedValue({ @@ -356,7 +324,7 @@ describe("SkillsIndex", () => { fireEvent.click(loadMoreButton); }); - expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true); + expect(screen.getByText(/Loading/)).toBeTruthy(); }); }); diff --git a/src/__tests__/upload.route.test.tsx b/src/__tests__/upload.route.test.tsx index 017a439c0..526916dc8 100644 --- a/src/__tests__/upload.route.test.tsx +++ b/src/__tests__/upload.route.test.tsx @@ -22,6 +22,7 @@ const useAuthStatusMock = vi.fn(); let useActionCallCount = 0; vi.mock("convex/react", () => ({ + ConvexReactClient: class {}, useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: () => generateUploadUrl, useAction: () => { diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index 76aecdbcb..950736147 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import { convex } from "../convex/client"; import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage"; import { clearAuthError, setAuthError } from "../lib/useAuthError"; +import { TooltipProvider } from "./ui/tooltip"; import { UserBootstrap } from "./UserBootstrap"; function getPendingAuthCode() { @@ -82,10 +83,12 @@ export function AuthErrorHandler() { export function AppProviders({ children }: { children: React.ReactNode }) { return ( - - - - {children} + + + + + {children} + ); } 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/DeploymentDriftBanner.tsx b/src/components/DeploymentDriftBanner.tsx index a3419dc58..6e4b360d8 100644 --- a/src/components/DeploymentDriftBanner.tsx +++ b/src/components/DeploymentDriftBanner.tsx @@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() { return (
Deploy mismatch detected. Frontend expects backend build {drift.expectedBuildSha}{" "} but Convex reports {drift.actualBuildSha}. diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index aee1a8f10..8d0a638fb 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,34 +1,51 @@ +import { Link } from "@tanstack/react-router"; +import { FOOTER_NAV_SECTIONS } from "../lib/nav-items"; import { getSiteName } from "../lib/site"; -import { Separator } from "./ui/separator"; export function Footer() { const siteName = getSiteName(); return ( -