Compare commits

...
Author SHA1 Message Date
Val AlexanderandClaude Opus 4.6 5003c1bec8 fix: address remaining PR review comments
- Re-throw non-rate-limit errors in plugin loader so route error
  boundary handles real failures instead of showing empty results
- Bump requestRef on query clear to invalidate in-flight searches
  and prevent stale results from repopulating
- Replace Promise.all with Promise.allSettled in unified search so
  one failing provider doesn't blank results from other sources
- Log unexpected errors in unified search catch block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 02:04:19 -05:00
Val AlexanderandClaude Opus 4.6 a8a6242f87 fix: address PR review — reason guard, one-shot fetch, index scan
- Reject empty reason strings in setSoftDeleted calls + re-add .catch()
  for error feedback (both reported-skills and skill-tools sections)
- Replace useQuery with ConvexHttpClient.query() on /users public
  browse page per CLAUDE.md policy
- Add by_active_handle compound index on users table to avoid full
  table scan in queryUsersForPublicList

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:55:41 -05:00
Val AlexanderandClaude Opus 4.6 39c0fa2531 merge: integrate origin/main into feat/marketplace-ui-overhaul
Keep feature branch UI overhaul (custom CSS) while incorporating
security/stability fixes from main:
- setSoftDeleted now requires moderation reason (runtime-critical)
- moderationNotes displayed in skill detail when available
- Rate limit handling for plugin catalog
- Tailwind @theme block for auto-merged component compatibility
- Capability tag passthrough to SecurityScanResults
- ALL_CATEGORY_KEYWORDS export for skills browse model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 00:28:17 -05:00
Vincent Koc 051b1dafcd fix: audit fixes — warm colors, lint, ARIA labels
- Replace ~20 remaining warm hex colors in upload/form styles
  (#ffddc9, #9a3a24, #fff3ec, etc.) with monochrome equivalents
- Fix 2 lint errors: remove unused Link import (search.tsx),
  prefix unused parseDir with underscore (souls/index.tsx)
- Add aria-label to PluginListItem and UserListItem for
  screen reader identification
2026-04-06 22:58:12 +01:00
Vincent Koc 383844cacf chore: various component changes 2026-04-06 22:03:49 +01:00
Vincent Koc b7923edbfd Update .gitignore 2026-04-06 20:41:09 +01:00
Vincent Koc 522fa22026 fix: polish pass — tighter spacing, no empty sections, subtle skeletons
- Home page sections only render when data arrives (no skeleton flash)
- Removed SkeletonRows component from home page (unused)
- Skeleton bars simplified: static gray bars, no shimmer animation
- Skeleton row padding matches list item padding
- Hero padding tightened (48px top → 32px)
- Hero subtitle made concise ("20 skill bundles... Browse, install, publish.")
- Removed redundant explainer paragraph
- Browse results count shows em dash while loading (not "Loading...")
- .section class uses spacing tokens
- .section-title uses monospace font at --fs-lg
- .section-subtitle uses --fs-sm
- results-list removed fadeIn animation (subtle state changes only)
2026-04-04 19:26:44 +09:00
ImLukeF e41d5d6314 fix: restore banned accounts in management 2026-04-04 20:49:24 +11:00
Vincent Koc c62ab8bc96 fix: complete audit cleanup — monochrome purity, touch targets, a11y
Theming (P0):
- Removed all 78 [data-theme="dark"] override selectors (dark is now
  default, these were dead code with conflicting warm colors)
- Replaced 56 instances of rgba(255,107,74,x) warm coral with
  rgba(255,255,255,x) monochrome equivalents
- Replaced hard-coded warm hex colors (#c35640, #ff6b4a, etc.) with
  gray monochrome values
- CSS file reduced from ~6300 to 5909 lines

Touch targets (P1):
- Added min-height: 36px to .btn (was ~24px)
- Added min-height: 36px to .navbar-tab (was ~27px)
- Added min-height: 32px to .sidebar-option and .sidebar-checkbox
- Increased padding on buttons and tabs

Accessibility (P2/P3):
- Comprehensive prefers-reduced-motion: reduce rule — disables all
  animations AND transitions for users who prefer reduced motion
- Covers shimmer, fadeIn, fadeUp, and all CSS transitions
2026-04-04 17:15:28 +09:00
Vincent Koc 6df3a3b9ef feat: monochrome dark TUI aesthetic — complete design pivot
Complete visual redesign to a dark, monochrome, terminal-inspired
aesthetic inspired by Warp, modern TUI tools, and blueprint designs.

Color system:
- Default is now dark (#0a0a0a bg, #e0e0e0 ink, #141414 surface)
- All accent colors removed — monochrome only (white as accent)
- Borders use rgba(255,255,255,0.08) for subtle separation
- Light theme available as optional override via [data-theme="light"]

Typography:
- All fonts now IBM Plex Mono (display, body, code all monospace)
- Brand name is lowercase monospace
- Section titles are uppercase monospace with letter-spacing
- Tags and badges use monospace font

Geometry:
- All border-radius reduced to 1-2px (sharp TUI corners)
- No shadows anywhere (--shadow: none)
- No backdrop-filter blur on navbar
- Cards, buttons, inputs all have sharp edges

Components:
- Buttons: transparent bg with border, monospace text
- Primary buttons: white on black (inverted)
- Tags: border-only, no colored backgrounds
- Cards: dark surface with subtle border
- Brand mark: 24px square instead of 28px circle

Layout:
- Replaced category grid with simple quick links
- Removed all warm color references
- Home section titles are small uppercase labels
- Skill list item names use --ink (no accent color)
2026-04-04 16:08:12 +09:00
Vincent Koc 7452cf6f69 feat: white background, onboarding explainer, filter reset, transitions
- Switch light theme from warm beige (#f8f2ed) to neutral white (#fafafa)
  with neutral gray ink (#1a1a1a) and borders (rgba black)
- Switch dark theme from warm brown to neutral dark (#111111) with
  neutral gray borders (rgba white)
- Replace fake category grid (8 keyword-search cards) with curated
  quick links (Most starred, New this week, Browse plugins, Staff picks)
- Add "What are skills?" explainer paragraph below hero CTAs
- Add fadeIn animation on results list when data arrives
- Add "Clear" button in browse results toolbar when filters are active
- Tighten browse layout gap from 24px to 16px
2026-04-04 15:01:57 +09:00
Vincent Koc b4e8a26eb4 chore: remove accidentally committed skill/agent config files 2026-04-04 00:01:59 +09:00
Vincent Koc 7ff601bcb8 fix: resolve all lint errors
- Remove unused imports (v, getRuntimeEnv) from seedDemo.ts and SkillHeader.tsx
- Replace `as any` casts with proper Id<"publishers"> types in seedDemo.ts
- Prefix unused params with underscore (_clawdis, _osLabels, _nixSystems, _listDoneLoading)
- Remove unused convexSiteUrl variable from SkillHeader
2026-04-04 00:01:43 +09:00
Vincent Koc 70af109cb2 fix: address PR review feedback
- Import internalMutation from convex/functions (not _generated/server)
  to get trigger wrapping per CLAUDE.md rules
- Derive activeCategory from current search query so sidebar category
  selection shows correct visual/ARIA state
- Push moderationStatus filter server-side in repairGlobalStats to
  avoid full table scan
- Reset skillCount/pluginCount to 0 in useUnifiedSearch catch block
  to prevent stale badge values after search errors
2026-04-03 23:41:54 +09:00
Vincent Koc 1504708208 feat: marketplace UI overhaul — HuggingFace/npm-style discovery hub
Complete frontend rebuild of ClawHub into a marketplace-style discovery
hub inspired by HuggingFace and npm. No backend changes.

Navigation:
- Two-row header: brand + search bar + user actions on top, content
  type tabs (Skills, Plugins) with count badges below
- Inline search in navbar navigates to /search
- Mobile: search collapses behind icon, tabs scroll horizontally

Home page:
- Value-prop hero with Browse/Publish CTAs (no duplicate search)
- Trending section (8 skills by downloads)
- Recently updated section (8 skills by update time)
- Staff picks grid (6 highlighted skill cards)
- Browse by category grid (8 categories with Lucide icons)
- Skeleton loading rows while data fetches

Browse pages (Skills + Plugins):
- Full-width search bar above sidebar+results grid
- Left sidebar with sort options, categories, filter checkboxes
  (proper ARIA: fieldset/legend, role=radiogroup, aria-checked)
- List view uses compact SkillListItem rows (owner/name/summary/meta)
- Card view with hover border feedback
- View toggle (List/Cards)
- Better empty states with guidance text

Skill detail page:
- README tab as default (was Files)
- Two-column layout: tabs+comments on left, metadata sidebar on right
- Removed duplicate README from Files tab
- Removed duplicate Download button from header (kept in sidebar)
- Removed SkillInstallCard from header (license info in sidebar)
- Nix/config snippets moved inside two-column layout
- Friendly "No README available" instead of raw Convex errors

Unified search (/search):
- Real search results page (was redirect-only)
- Type tabs: All / Skills / Plugins with counts
- useUnifiedSearch hook fires skill search + plugin catalog in parallel
- Consistent SkillListItem rendering for results

Dashboard:
- Welcome state for new users with empty dashboard
- Simplified header copy

Profiles & Footer:
- Richer user profiles: large avatar, stat row, SkillListItem for
  published/starred skills
- Multi-column footer: Browse / Publish / Community / Platform

Design system:
- Spacing tokens: --space-1 (4px) through --space-8 (64px)
- Typography scale: --fs-xs through --fs-3xl (8 values, was 42)
- Radius tokens: --r-lg/md/sm/xs/pill (renamed from --radius-* to
  avoid Tailwind CSS v4 variable collision)
- Flat buttons (killed gradient, removed hover lift/shadow)
- Complete markdown styles: tables, blockquotes, lists, images, hr,
  heading hierarchy with h1/h2 bottom borders (npm-style)
- Removed decorative elements: body gradient backgrounds, card
  shadows, brand mark animation, category card glow

New components:
- SkillListItem — compact HF-style row
- BrowseSidebar — faceted filter sidebar with ARIA
- SkillMetadataSidebar — detail page right sidebar
- useUnifiedSearch — parallel search hook
- timeAgo — relative time formatter
- categories — static skill category taxonomy

Seed data:
- seedDemo.ts with 20 realistic skills, 5 publishers
- repairHighlightedBadges for skillBadges table
- repairGlobalStats for correct count

Test updates:
- Updated 5 test files for new text, class names, and prop changes
- All 122 test files, 915 tests passing
2026-04-03 22:58:30 +09:00
46 changed files with 10039 additions and 4060 deletions
+4
View File
@@ -24,3 +24,7 @@ coverage
playwright-report
test-results
.playwright
convex/_generated/
skills-lock.json
*/skills/*
skills/*
+8
View File
@@ -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.
<!-- convex-ai-start -->
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`.
<!-- convex-ai-end -->
+8
View File
@@ -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.
<!-- convex-ai-start -->
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`.
<!-- convex-ai-end -->
+6
View File
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
extractWorkflowFilenameFromWorkflowRef,
verifyGitHubActionsTrustedPublishJwt,
+2 -1
View File
@@ -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")),
+411
View File
@@ -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 };
},
});
+44 -20
View File
@@ -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<ReturnType<typeof toPublicUser>> => 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<Doc<"users">[]> };
};
};
},
ctx: Pick<QueryCtx, "db">,
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<QueryCtx, "db">,
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);
}
@@ -860,8 +885,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, {
+38 -1
View File
@@ -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 }) => <a href="/">{props.children}</a>,
useNavigate: () => vi.fn(),
}));
vi.mock("@convex-dev/auth/react", () => ({
@@ -56,7 +60,7 @@ vi.mock("../lib/roles", () => ({
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => "souls",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
@@ -68,6 +72,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 }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@@ -85,8 +107,23 @@ vi.mock("../components/ui/toggle-group", () => ({
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<Header />);
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders a plain Skills tab without fetching a count", () => {
siteModeMock.mockReturnValue("skills");
convexQueryMock.mockClear();
render(<Header />);
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();
});
});
+39 -72
View File
@@ -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<string, unknown>) {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: {
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
location: { url: URL };
}) => void;
validateSearch?: (search: Record<string, unknown>) => 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();
});
});
+40 -194
View File
@@ -24,8 +24,8 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
vi.mock("../components/SkillCommentsPanel", () => ({
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
}));
describe("SkillDetailPage", () => {
@@ -58,11 +58,8 @@ describe("SkillDetailPage", () => {
return undefined;
});
const { container } = render(<SkillDetailPage slug="weather" />);
// Loading state now renders a skeleton, not text
expect(
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
).toBeTruthy();
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -135,174 +132,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(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Crypto payments for AI skills.",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: { license: "MIT-0", frontmatter: {} },
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
sha256hash: "abc123",
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
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(
<SkillDetailPage
slug="skill-pay"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "skill-pay",
displayName: "SkillPay",
summary: "Add credit-based payments to any OpenClaw skill. Register paid skills...",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: {
license: "MIT-0",
frontmatter: {
description: fullDescription,
},
},
files: [
{
path: "SKILL.md",
size: 10,
storageId,
sha256: "abc",
contentType: "text/markdown",
},
],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# SkillPay",
readmeError: null,
}}
/>,
);
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 +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();
});
@@ -394,17 +228,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 +251,8 @@ describe("SkillDetailPage", () => {
};
});
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
// Loading state now renders a skeleton, not text
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -524,7 +357,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 +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: {
@@ -666,6 +513,7 @@ describe("SkillDetailPage", () => {
render(<SkillDetailPage slug="weather" />);
expect(await screen.findByText("Weather")).toBeTruthy();
expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
expect(
useQueryMock.mock.calls.some((call) => {
@@ -679,9 +527,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(
+15 -48
View File
@@ -66,7 +66,7 @@ describe("SkillsIndex", () => {
it("renders an empty state when no skills are returned", async () => {
render(<SkillsIndex />);
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(<SkillsIndex />);
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 +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(<SkillsIndex />);
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 +161,7 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
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 +251,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 +291,6 @@ describe("SkillsIndex", () => {
);
});
it("passes capabilityTag to list query when tag filter is active", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
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(<SkillsIndex />);
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<string, unknown>) => Record<string, unknown>;
};
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 +323,7 @@ describe("SkillsIndex", () => {
fireEvent.click(loadMoreButton);
});
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByText(/Loading/)).toBeTruthy();
});
});
+120
View File
@@ -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<string, React.ReactNode> = {
"mcp-tools": <Plug size={15} />,
prompts: <MessageSquare size={15} />,
workflows: <GitBranch size={15} />,
"dev-tools": <Wrench size={15} />,
data: <Database size={15} />,
security: <Shield size={15} />,
automation: <Zap size={15} />,
other: <Package size={15} />,
};
export function BrowseSidebar({
categories,
activeCategory,
onCategoryChange,
sortOptions,
activeSort,
onSortChange,
filters,
onFilterToggle,
}: BrowseSidebarProps) {
return (
<aside className="browse-sidebar" aria-label="Browse filters">
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
<legend className="sidebar-title">Sort by</legend>
{sortOptions.map((opt) => (
<button
key={opt.value}
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeSort === opt.value}
onClick={() => onSortChange(opt.value)}
>
{opt.label}
</button>
))}
</fieldset>
{categories && onCategoryChange ? (
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
<legend className="sidebar-title">Categories</legend>
<button
className={`sidebar-option${!activeCategory ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={!activeCategory}
onClick={() => onCategoryChange(undefined)}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.slug}
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeCategory === cat.slug}
onClick={() => onCategoryChange(cat.slug)}
>
<span className="sidebar-option-icon" aria-hidden="true">
{CATEGORY_ICONS[cat.slug]}
</span>
{cat.label}
</button>
))}
</fieldset>
) : null}
<fieldset className="sidebar-section" aria-label="Toggle filters">
<legend className="sidebar-title">Filters</legend>
{filters.map((f) => (
<label key={f.key} className="sidebar-checkbox">
<input
type="checkbox"
checked={f.active}
onChange={() => onFilterToggle(f.key)}
aria-label={f.label}
/>
<span>{f.label}</span>
</label>
))}
</fieldset>
</aside>
);
}
+101 -24
View File
@@ -1,34 +1,111 @@
import { Link } from "@tanstack/react-router";
import { getSiteName } from "../lib/site";
import { Separator } from "./ui/separator";
export function Footer() {
const siteName = getSiteName();
return (
<footer className="mt-auto px-7 pb-8 pt-12">
<div className="mx-auto max-w-[1200px]">
<Separator className="mb-6" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.82rem] text-[color:var(--ink-soft)]">
<span className="font-semibold text-[color:var(--ink)]">{siteName}</span>
<FooterLink href="https://openclaw.ai">OpenClaw</FooterLink>
<FooterLink href="https://vercel.com">Vercel</FooterLink>
<FooterLink href="https://www.convex.dev">Convex</FooterLink>
<FooterLink href="https://github.com/openclaw/clawhub">Open source (MIT)</FooterLink>
<FooterLink href="https://steipete.me">Peter Steinberger</FooterLink>
<footer className="site-footer" role="contentinfo">
<div className="site-footer-inner">
<div className="site-footer-divider" aria-hidden="true" />
<div className="footer-grid">
<div className="footer-col">
<h4 className="footer-col-title">Browse</h4>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
<Link to="/plugins">Plugins</Link>
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
>
Souls
</Link>
<Link to="/users">Users</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Staff Picks
</Link>
<Link to="/search" search={{ q: undefined, type: undefined }}>
Search
</Link>
</div>
<div className="footer-col">
<h4 className="footer-col-title">Publish</h4>
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
Publish Skill
</Link>
<Link
to="/publish-plugin"
search={{
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
}}
>
Publish Plugin
</Link>
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
Documentation
</a>
</div>
<div className="footer-col">
<h4 className="footer-col-title">Community</h4>
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
GitHub
</a>
<Link to="/about">About</Link>
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
OpenClaw
</a>
</div>
<div className="footer-col">
<h4 className="footer-col-title">Platform</h4>
<span>MIT Licensed</span>
<a href="https://vercel.com" target="_blank" rel="noreferrer">
Deployed on Vercel
</a>
<a href="https://www.convex.dev" target="_blank" rel="noreferrer">
Powered by Convex
</a>
</div>
</div>
<div className="footer-bottom">
<span>
{siteName} An{" "}
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
OpenClaw
</a>{" "}
project by{" "}
<a href="https://steipete.me" target="_blank" rel="noreferrer">
Peter Steinberger
</a>
</span>
</div>
</div>
</footer>
);
}
function FooterLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-[color:var(--ink-soft)] transition-colors duration-150 hover:text-[color:var(--ink)]"
>
{children}
</a>
);
}
+366 -271
View File
@@ -1,17 +1,15 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link } from "@tanstack/react-router";
import { Menu, Monitor, Moon, Plus, Search, 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";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { useAuthError } from "../lib/useAuthError";
import { SignInButton } from "./SignInButton";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,24 +17,28 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signOut } = useAuthActions();
const { signIn, signOut } = useAuthActions();
const { mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
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";
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
const isStaff = isModerator(me);
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({
@@ -51,277 +53,370 @@ export default function Header() {
});
};
const navLinks = (
<>
{isSoulMode ? (
<a
href={clawHubUrl}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
ClawHub
</a>
) : null}
{isSoulMode ? (
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Skills
</Link>
)}
{isSoulMode ? null : (
<Link
to="/plugins"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Plugins
</Link>
)}
<Link
to={isSoulMode ? "/souls" : "/skills"}
search={
isSoulMode
? { q: undefined, sort: undefined, dir: undefined, view: undefined, focus: "search" }
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: "search",
}
}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)] inline-flex items-center gap-1.5"
>
<Search className="h-3.5 w-3.5" />
Search
</Link>
{isSoulMode ? null : (
<Link
to="/about"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
About
</Link>
)}
{me ? (
<Link
to="/stars"
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Stars
</Link>
) : null}
{isStaff ? (
<Link
to="/management"
search={{ skill: undefined }}
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
>
Management
</Link>
) : null}
</>
);
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 (
<header className="sticky top-0 z-50 border-b border-[color:var(--line)] bg-[color:var(--nav-bg)] backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-4 px-5">
{/* Brand */}
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="flex items-center gap-2.5 font-display text-lg font-bold text-[color:var(--ink)] no-underline transition-opacity hover:opacity-80"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] p-0.5">
<img
src="/clawd-logo.png"
alt=""
aria-hidden="true"
className="h-full w-full rounded-full object-cover"
<header className="navbar">
<div className="navbar-inner">
{/* Row 1: Brand + Search + Actions */}
<div className="navbar-top">
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" />
</span>
<span className="brand-name">{siteName}</span>
</Link>
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
aria-label="Search"
/>
</span>
<span>{siteName}</span>
</Link>
</form>
{/* Desktop nav */}
<nav className="hidden items-center gap-6 md:flex">{navLinks}</nav>
{/* Actions */}
<div className="flex items-center gap-3">
{/* Publish CTA (desktop, authenticated) */}
{isAuthenticated && me && (
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
className="hidden sm:block"
<div className="nav-actions">
<button
className="navbar-search-mobile-trigger"
type="button"
aria-label="Search"
onClick={() => setMobileSearchOpen(!mobileSearchOpen)}
>
<Button variant="primary" size="sm">
<Plus className="h-3.5 w-3.5" />
Publish
</Button>
</Link>
)}
{/* Mobile nav trigger */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-72">
<SheetHeader>
<SheetTitle>{siteName}</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-4">{navLinks}</nav>
{/* Mobile theme toggle */}
<div className="mt-6 flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-[color:var(--ink-soft)]">
Theme
</span>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Mobile publish link */}
{isAuthenticated && me && (
<div className="mt-6">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="primary" className="w-full">
<Plus className="h-4 w-4" />
Publish Skill
</Button>
</Link>
</div>
)}
</SheetContent>
</Sheet>
</div>
{/* Desktop theme toggle */}
<div className="theme-toggle hidden md:block" ref={toggleRef}>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* User menu / Sign in */}
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex cursor-pointer items-center gap-2 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] px-2 py-1.5 text-sm font-semibold text-[color:var(--ink)] transition-colors hover:border-[color:var(--border-ui-hover)]"
>
<Avatar className="h-7 w-7">
{avatar && (
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
)}
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
</Avatar>
<span className="hidden font-mono text-xs sm:inline">@{handle}</span>
<span className="text-xs text-[color:var(--ink-soft)]"></span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div
className="flex items-center gap-1 text-[0.85rem] text-red-600 dark:text-red-400"
role="alert"
>
{authError}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="ml-1 cursor-pointer border-none bg-transparent p-0.5 text-inherit opacity-70 hover:opacity-100"
>
&times;
<Search size={18} aria-hidden="true" />
</button>
<div className="nav-mobile">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="nav-mobile-trigger" type="button" aria-label="Open menu">
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
</div>
) : null}
<SignInButton
variant="primary"
size="sm"
disabled={isLoading}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isSoulMode ? (
<DropdownMenuItem asChild>
<a href={clawHubUrl}>ClawHub</a>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
{isSoulMode ? (
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
)}
</DropdownMenuItem>
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/plugins">Plugins</Link>
</DropdownMenuItem>
)}
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
</DropdownMenuItem>
)}
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/users" search={{ q: undefined }}>
Users
</Link>
</DropdownMenuItem>
)}
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/about">About</Link>
</DropdownMenuItem>
)}
{me ? (
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
) : null}
{me ? (
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
) : null}
{isStaff ? (
<DropdownMenuItem asChild>
<Link to="/management" search={{ skill: undefined }}>
Management
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="theme-toggle" ref={toggleRef}>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setTheme(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
>
<span>Sign in</span>
<span className="hidden text-white/70 sm:inline">with GitHub</span>
</SignInButton>
</>
)}
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="user-trigger" type="button">
{avatar ? (
<img src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
) : (
<span className="user-menu-fallback">{initial}</span>
)}
<span className="mono">@{handle}</span>
<span className="user-menu-chevron"></span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{authError ? (
<div className="error" role="alert" style={{ fontSize: "0.85rem", marginRight: 8 }}>
{authError}{" "}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "inherit",
padding: "0 2px",
}}
>
&times;
</button>
</div>
) : null}
<button
className="btn btn-primary"
type="button"
disabled={isLoading}
onClick={() => {
clearAuthError();
void signIn(
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
});
}}
>
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
</button>
</>
)}
</div>
</div>
{/* Mobile search bar (expandable) */}
{mobileSearchOpen ? (
<form className="navbar-search-mobile" onSubmit={handleNavSearch}>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
/>
</form>
) : null}
{/* Row 2: Content type tabs */}
<nav className="navbar-tabs" aria-label="Content types">
<div className="navbar-tabs-primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{isSoulMode ? (
<Link
to="/souls"
className="navbar-tab"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
) : (
<Link
to="/skills"
className="navbar-tab"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
)}
{isSoulMode ? null : (
<Link to="/plugins" className="navbar-tab">
Plugins
</Link>
)}
{isSoulMode ? null : (
<Link
to="/souls"
className="navbar-tab"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
)}
</div>
<div className="navbar-tabs-secondary">
{isSoulMode ? null : (
<Link to="/users" search={{ q: undefined }} className="navbar-tab navbar-tab-secondary">
Users
</Link>
)}
{isSoulMode ? null : (
<Link to="/about" className="navbar-tab navbar-tab-secondary">
About
</Link>
)}
{me ? (
<Link to="/stars" className="navbar-tab navbar-tab-secondary">
Stars
</Link>
) : null}
{me ? (
<Link to="/dashboard" className="navbar-tab navbar-tab-secondary">
Dashboard
</Link>
) : null}
{isStaff ? (
<Link
to="/management"
search={{ skill: undefined }}
className="navbar-tab navbar-tab-secondary"
>
Manage
</Link>
) : null}
</div>
</nav>
</div>
</header>
);
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
+63
View File
@@ -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 (
<span
className={`marketplace-icon marketplace-icon-${size}`}
style={
{
"--marketplace-icon-accent": tone.accent,
"--marketplace-icon-wash": tone.wash,
} as React.CSSProperties
}
aria-hidden="true"
>
{imageUrl ? (
<img className="marketplace-icon-image" src={imageUrl} alt="" loading="lazy" />
) : (
<Icon className="marketplace-icon-glyph" strokeWidth={1.8} />
)}
</span>
);
}
+39
View File
@@ -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 (
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
<MarketplaceIcon kind="plugin" label={item.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{item.ownerHandle ? (
<>
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{item.displayName}</span>
<span className="tag tag-compact">{familyLabel(item.family)}</span>
{item.isOfficial ? <span className="tag tag-compact tag-accent">Verified</span> : null}
</div>
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Plugin</span>
{item.latestVersion ? (
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
</span>
</div>
</div>
</Link>
);
}
+13 -31
View File
@@ -1,8 +1,7 @@
import { Link } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicSkill } from "../lib/publicUser";
import { Badge } from "./ui/badge";
type SkillCardProps = {
skill: PublicSkill;
@@ -12,7 +11,6 @@ type SkillCardProps = {
summaryFallback: string;
meta: ReactNode;
href?: string;
verified?: boolean;
};
export function SkillCard({
@@ -23,7 +21,6 @@ export function SkillCard({
summaryFallback,
meta,
href,
verified,
}: SkillCardProps) {
const owner = encodeURIComponent(String(skill.ownerUserId));
const link = href ?? `/${owner}/${skill.slug}`;
@@ -31,43 +28,28 @@ export function SkillCard({
const hasTags = badges.length || chip || platformLabels?.length;
return (
<Link
to={link}
className="group flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)] hover:border-[color:var(--border-ui-hover)]"
>
<Link to={link} className="card skill-card">
{hasTags ? (
<div className="flex flex-wrap items-center gap-1.5">
<div className="skill-card-tags">
{badges.map((label) => (
<Badge key={label} variant="default">
<div key={label} className="tag">
{label}
</Badge>
</div>
))}
{chip ? (
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
{chip}
</Badge>
) : null}
{chip ? <div className="tag tag-accent tag-compact">{chip}</div> : null}
{platformLabels?.map((label) => (
<Badge key={label} variant="compact">
<div key={label} className="tag tag-compact">
{label}
</Badge>
</div>
))}
{verified && (
<span className="inline-flex items-center gap-1 text-[0.72rem] font-semibold text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
</span>
)}
</div>
) : null}
<h3 className="font-display text-base font-bold leading-tight text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
{skill.displayName}
</h3>
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
{skill.summary ?? summaryFallback}
</p>
<div className="mt-auto flex flex-col gap-2 pt-1 text-[0.82rem] text-[color:var(--ink-soft)]">
{meta}
<div className="skill-card-header">
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
<h3 className="skill-card-title">{skill.displayName}</h3>
</div>
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
</Link>
);
}
+150 -144
View File
@@ -2,19 +2,15 @@ import { useNavigate } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { canManageSkill, isModerator } from "../lib/roles";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { ClientOnly } from "./ClientOnly";
import { EmptyState } from "./EmptyState";
import { Container } from "./layout/Container";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs } from "./SkillDetailTabs";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import {
buildSkillHref,
formatConfigSnippet,
@@ -25,8 +21,6 @@ import {
import { SkillHeader } from "./SkillHeader";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillReportDialog } from "./SkillReportDialog";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
type SkillDetailPageProps = {
slug: string;
@@ -38,11 +32,13 @@ type SkillDetailPageProps = {
type SkillFile = Doc<"skillVersions">["files"][number];
function formatReportError(error: unknown) {
if (hasOwnProperty(error, "data")) {
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: unknown }).data;
if (typeof data === "string" && data.trim()) return data.trim();
if (
hasOwnProperty(data, "message") &&
data &&
typeof data === "object" &&
"message" in data &&
typeof (data as { message?: unknown }).message === "string"
) {
const message = (data as { message?: string }).message?.trim();
@@ -98,7 +94,7 @@ export function SkillDetailPage({
);
const [tagName, setTagName] = useState("latest");
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
const [reportReason, setReportReason] = useState("");
@@ -215,7 +211,6 @@ export function SkillDetailPage({
?.clawdis;
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
const nixPlugin = clawdis?.nix?.plugin;
const nixSystems = clawdis?.nix?.systems ?? [];
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null;
const configRequirements = clawdis?.config;
const configExample = configRequirements?.example
@@ -299,13 +294,10 @@ export function SkillDetailPage({
const deleteTag = (tag: string) => {
if (!skill) return;
toast(`Delete tag "${tag}"?`, {
action: {
label: "Delete",
onClick: () => {
void deleteTags({ skillId: skill._id, tags: [tag] });
},
},
if (!window.confirm(`Delete tag "${tag}"?`)) return;
void deleteTags({
skillId: skill._id,
tags: [tag],
});
};
@@ -324,9 +316,9 @@ export function SkillDetailPage({
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
closeReportDialog();
if (submission.reported) {
toast.success("Thanks — your report has been submitted.");
window.alert("Thanks — your report has been submitted.");
} else {
toast.info("You have already reported this skill.");
window.alert("You have already reported this skill.");
}
} catch (error) {
console.error("Failed to report skill", error);
@@ -336,19 +328,19 @@ export function SkillDetailPage({
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return <SkillDetailSkeleton />;
return (
<main className="section">
<div className="card">
<div className="loading-indicator">Loading skill</div>
</div>
</main>
);
}
if (result === null || !skill) {
return (
<main className="py-10">
<Container>
<EmptyState
title="Skill not found"
description="The skill you're looking for doesn't exist or may have been removed."
action={{ label: "Browse skills", href: "/skills" }}
/>
</Container>
<main className="section">
<div className="card">Skill not found.</div>
</main>
);
}
@@ -356,128 +348,142 @@ export function SkillDetailPage({
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
return (
<main className="py-10">
<Container>
<div className="flex flex-col gap-6">
<SkillHeader
<main className="section">
<div className="skill-detail-stack">
<SkillHeader
skill={skill}
owner={owner}
ownerHandle={ownerHandle}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
<div className="detail-layout">
<div className="detail-main">
{nixSnippet ? (
<div className="card">
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
Install via Nix
</h3>
<pre className="hero-install-code" style={{ marginTop: 8 }}>
{nixSnippet}
</pre>
</div>
) : null}
{configExample ? (
<div className="card">
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
Config example
</h3>
<pre className="hero-install-code" style={{ marginTop: 8 }}>
{configExample}
</pre>
</div>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => 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}
/>
<ClientOnly
fallback={
<div className="card">
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
Comments
</h2>
<p className="section-subtitle" style={{ marginTop: 12, marginBottom: 0 }}>
Loading comments...
</p>
</div>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
latestVersion={latestVersion}
modInfo={modInfo}
canManage={canManage}
isAuthenticated={isAuthenticated}
isStaff={isStaff}
isStarred={isStarred}
onToggleStar={() => void toggleStar({ skillId: skill._id })}
onOpenReport={openReportDialog}
forkOf={forkOf}
forkOfLabel={forkOfLabel}
forkOfHref={forkOfHref}
forkOfOwnerHandle={forkOfOwnerHandle}
canonical={canonical}
canonicalHref={canonicalHref}
canonicalOwnerHandle={canonicalOwnerHandle}
staffModerationNote={staffModerationNote}
staffVisibilityTag={staffVisibilityTag}
isAutoHidden={isAutoHidden}
isRemoved={isRemoved}
nixPlugin={nixPlugin}
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={tagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={tagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
{nixSnippet ? (
<Card>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Install via Nix
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
{nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"}
</p>
<pre className="hero-install-code mt-3">{nixSnippet}</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Config example
</h2>
<p className="text-sm text-[color:var(--ink-soft)]">
Starter config for this plugin bundle.
</p>
<pre className="hero-install-code mt-3">{configExample}</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => 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}
/>
<ClientOnly
fallback={
<Card>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Comments</h2>
<div className="flex flex-col gap-3 pt-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-20 w-full" />
</div>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
</div>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
</Container>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
isSubmitting={isSubmittingReport}
reportReason={reportReason}
reportError={reportError}
onReasonChange={setReportReason}
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
</main>
);
}
+83 -38
View File
@@ -1,9 +1,8 @@
import { lazy, Suspense } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SkillVersionsPanel } from "./SkillVersionsPanel";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
const SkillDiffCard = lazy(() =>
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
@@ -15,9 +14,11 @@ const SkillFilesPanel = lazy(() =>
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;
@@ -46,13 +47,30 @@ export function SkillDetailTabs({
suppressVersionScanResults,
scanResultsSuppressedMessage,
}: SkillDetailTabsProps) {
const compareEnabled = (versions?.length ?? 0) > 1;
return (
<Card>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
<TabsList>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger
value="compare"
<div className="card tab-card">
<div className="tab-header">
<button
className={`tab-button${activeTab === "readme" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("readme")}
>
README
</button>
<button
className={`tab-button${activeTab === "files" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("files")}
>
Files
</button>
{compareEnabled ? (
<button
className={`tab-button${activeTab === "compare" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("compare")}
onMouseEnter={() => {
onCompareIntent();
void import("./SkillDiffCard");
@@ -63,37 +81,64 @@ export function SkillDetailTabs({
}}
>
Compare
</TabsTrigger>
<TabsTrigger value="versions">Versions</TabsTrigger>
</TabsList>
</button>
) : null}
<button
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("versions")}
>
Versions
</button>
</div>
<TabsContent value="files">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
<SkillFilesPanel
versionId={latestVersionId}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
/>
</Suspense>
</TabsContent>
{activeTab === "readme" ? (
<div className="tab-body">
{readmeContent ? (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state" style={{ padding: "var(--space-6) var(--space-4)" }}>
<p className="empty-state-title">No README available</p>
<p className="empty-state-body">
This skill doesn't have a SKILL.md file yet.
</p>
</div>
) : (
<div className="stat" style={{ padding: 16 }}>
Loading README...
</div>
)}
</div>
) : null}
<TabsContent value="compare">
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
{activeTab === "files" ? (
<Suspense fallback={<div className="tab-body stat">Loading file viewer...</div>}>
<SkillFilesPanel
versionId={latestVersionId}
latestFiles={latestFiles}
/>
</Suspense>
) : null}
{activeTab === "compare" ? (
<div className="tab-body">
<Suspense fallback={<div className="stat">Loading diff viewer...</div>}>
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
</Suspense>
</TabsContent>
</div>
) : null}
<TabsContent value="versions">
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
</TabsContent>
</Tabs>
</Card>
{activeTab === "versions" ? (
<SkillVersionsPanel
versions={versions}
nixPlugin={nixPlugin}
skillSlug={skill.slug}
suppressScanResults={suppressVersionScanResults}
suppressedMessage={scanResultsSuppressedMessage}
/>
) : null}
</div>
);
}
+2 -2
View File
@@ -83,7 +83,7 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
});
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("shadow-sm");
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("is-active");
expect(screen.getByTestId("diff-editor").getAttribute("data-inline-fallback")).toBe("false");
});
@@ -106,6 +106,6 @@ describe("SkillDiffCard", () => {
await waitFor(() => {
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("true");
});
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("shadow-sm");
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
});
});
+96 -130
View File
@@ -14,11 +14,6 @@ import {
sortVersionsBySemver,
} from "../lib/diffing";
import { ClientOnly } from "./ClientOnly";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Label } from "./ui/label";
import { Skeleton } from "./ui/skeleton";
type SkillDiffCardProps = {
skill: Doc<"skills">;
@@ -277,174 +272,145 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
const fileSelected = Boolean(selectedItem);
const diffOptions = useMemo(() => buildDiffOptions(viewMode), [viewMode]);
const Wrapper = variant === "card" ? Card : "div";
const wrapperClassName = variant === "card" ? "flex flex-col gap-4" : "flex flex-col gap-4";
const containerClass = variant === "card" ? "card diff-card" : "diff-card diff-card-embedded";
return (
<Wrapper className={wrapperClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className={containerClass}>
<div className="diff-header">
<div>
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
Compare versions
</h2>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
<p className="section-subtitle" style={{ margin: 0 }}>
Inline or side-by-side diff for any file.
</p>
</div>
<fieldset className="inline-flex items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-[3px]">
<legend className="sr-only">Diff layout</legend>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "split"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
viewMode === "inline"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
{!diffUnavailable ? (
<fieldset className="diff-toggle-group">
<legend className="sr-only">Diff layout</legend>
<button
className={`diff-toggle${viewMode === "split" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("split")}
>
Side-by-side
</button>
<button
className={`diff-toggle${viewMode === "inline" ? " is-active" : ""}`}
type="button"
onClick={() => updateViewMode("inline")}
>
Inline
</button>
</fieldset>
) : null}
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-left">Left</Label>
<select
id="diff-left"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</Button>
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<Label htmlFor="diff-right">Right</Label>
<select
id="diff-right"
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
{!diffUnavailable ? (
<>
<div className="diff-controls">
<div className="diff-select">
<label htmlFor="diff-left">Left</label>
<select
id="diff-left"
className="search-input"
value={leftVersionId ?? ""}
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
<button
className="btn diff-swap"
type="button"
onClick={() => {
setLeftVersionId(rightVersionId);
setRightVersionId(leftVersionId);
}}
disabled={!leftVersionId || !rightVersionId}
>
Swap
</button>
<div className="diff-select">
<label htmlFor="diff-right">Right</label>
<select
id="diff-right"
className="search-input"
value={rightVersionId ?? ""}
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
>
<option value="" disabled>
Select version
</option>
{renderOptions(versionOptions)}
</select>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-[color:var(--ink-soft)]">
<span>
Left {leftLabel} Right {rightLabel}
</span>
{diffUnavailable ? <span>Need at least 2 versions.</span> : null}
</div>
<div className="diff-meta">
<span>
Left {leftLabel} Right {rightLabel}
</span>
</div>
</>
) : null}
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(160px,220px)_1fr]">
<div className="flex max-h-[500px] flex-col overflow-y-auto border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] md:border-r md:border-b-0">
{fileDiffItems.length === 0 ? (
<div className="p-3 text-sm text-[color:var(--ink-soft)]">No files to compare.</div>
<div className="diff-layout">
<div className="diff-files">
{diffUnavailable ? (
<div className="diff-empty">
Publish another version to compare changes side by side.
</div>
) : fileDiffItems.length === 0 ? (
<div className="diff-empty">No files to compare.</div>
) : (
fileDiffItems.map((item) => (
<button
key={item.path}
type="button"
className={`flex w-full cursor-pointer items-center gap-2 border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface)] ${
item.path === selectedPath
? "bg-[color:var(--surface)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
}`}
className={`diff-file${item.path === selectedPath ? " is-active" : ""}`}
onClick={() => setSelectedPath(item.path)}
>
<Badge
variant={
item.status === "added"
? "success"
: item.status === "removed"
? "destructive"
: item.status === "changed"
? "warning"
: "compact"
}
className="shrink-0 text-[0.65rem]"
>
{item.status}
</Badge>
<span className="truncate font-mono text-xs">{item.path}</span>
<span className={`diff-pill diff-pill-${item.status}`}>{item.status}</span>
<span className="diff-file-name">{item.path}</span>
</button>
))
)}
</div>
<div className="relative min-h-[300px]">
<div className="diff-view">
{error ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
{error}
</div>
<div className="diff-empty">{error}</div>
) : sizeWarning ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
<div className="diff-empty">
{sizeWarning.side === "left" ? "Left" : "Right"} file exceeds 200KB:{" "}
{sizeWarning.path}
</div>
) : diffUnavailable ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Publish another version to compare.
</div>
<div className="diff-empty">Publish another version to compare.</div>
) : !selectionReady ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select two versions to compare.
</div>
<div className="diff-empty">Select two versions to compare.</div>
) : !fileSelected ? (
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
Select a file to compare.
</div>
<div className="diff-empty">Select a file to compare.</div>
) : (
<ClientOnly fallback={<Skeleton className="h-full w-full" />}>
<ClientOnly fallback={<div className="diff-empty">Preparing diff</div>}>
<DiffEditor
key={`diff-${viewMode}`}
className={`h-full min-h-[400px] w-full ${viewMode === "inline" ? "max-w-full" : ""}`}
className={`diff-monaco diff-monaco-${viewMode}`}
original={leftText}
modified={rightText}
theme={getMonacoThemeName()}
loading={<Skeleton className="h-full w-full" />}
loading={<div className="diff-empty">Loading diff</div>}
options={diffOptions}
/>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--surface)]/80">
<Skeleton className="h-8 w-24" />
</div>
) : null}
{isLoading ? <div className="diff-loading">Loading</div> : null}
</ClientOnly>
)}
</div>
</div>
</Wrapper>
</div>
);
}
-8
View File
@@ -9,10 +9,6 @@ vi.mock("convex/react", () => ({
useAction: () => getFileTextMock,
}));
vi.mock("./MarkdownPreview", () => ({
MarkdownPreview: ({ children }: { children: string }) => <div>{children}</div>,
}));
type SkillFile = Doc<"skillVersions">["files"][number];
function makeFile(path: string, size: number): SkillFile {
@@ -34,8 +30,6 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("scripts/run.sh", 10)]}
/>,
);
@@ -68,8 +62,6 @@ describe("SkillFilesPanel", () => {
render(
<SkillFilesPanel
versionId={"skillVersions:1" as Id<"skillVersions">}
readmeContent={"# skill"}
readmeError={null}
latestFiles={[makeFile("a.txt", 5), makeFile("b.txt", 6)]}
/>,
);
+21 -53
View File
@@ -2,23 +2,17 @@ import { useAction } from "convex/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { MarkdownPreview } from "./MarkdownPreview";
import { formatBytes } from "./skillDetailUtils";
import { Skeleton } from "./ui/skeleton";
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);
@@ -91,82 +85,56 @@ export function SkillFilesPanel({
);
return (
<div className="grid max-w-full gap-5 overflow-x-auto">
<div>
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
SKILL.md
</h2>
<div>
{readmeContent ? (
<MarkdownPreview>{readmeContent}</MarkdownPreview>
) : readmeError ? (
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load SKILL.md: {readmeError}
</div>
) : (
<Skeleton className="h-24 w-full" />
)}
</div>
</div>
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(180px,280px)_1fr]">
<div className="flex flex-col border-b border-[color:var(--line)] md:border-r md:border-b-0">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<h3 className="m-0 font-display text-[1.05rem] font-bold text-[color:var(--ink)]">
<div className="tab-body">
<div className="file-browser">
<div className="file-list">
<div className="file-list-header">
<h3 className="section-title" style={{ fontSize: "1.05rem", margin: 0 }}>
Files
</h3>
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
<span className="section-subtitle" style={{ margin: 0 }}>
{latestFiles.length} total
</span>
</div>
<div className="flex max-h-[400px] flex-col overflow-y-auto">
<div className="file-list-body">
{latestFiles.length === 0 ? (
<div className="px-3 py-2 text-sm text-[color:var(--ink-soft)]">
No files available.
</div>
<div className="stat">No files available.</div>
) : (
latestFiles.map((file) => (
<button
key={file.path}
className={`flex w-full cursor-pointer items-center justify-between border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-muted)] ${
selectedPath === file.path
? "bg-[color:var(--surface-muted)] font-semibold text-[color:var(--ink)]"
: "bg-transparent text-[color:var(--ink)]"
className={`file-row file-row-button${
selectedPath === file.path ? " is-active" : ""
}`}
type="button"
onClick={() => handleSelect(file.path)}
aria-current={selectedPath === file.path ? "true" : undefined}
>
<span className="truncate font-mono text-xs">{file.path}</span>
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
{formatBytes(file.size)}
</span>
<span className="file-path">{file.path}</span>
<span className="file-meta">{formatBytes(file.size)}</span>
</button>
))
)}
</div>
</div>
<div className="flex flex-col">
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
<div className="truncate font-mono text-xs">{selectedPath ?? "Select a file"}</div>
<div className="file-viewer">
<div className="file-viewer-header">
<div className="file-path">{selectedPath ?? "Select a file"}</div>
{fileMeta ? (
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
<span className="file-meta">
{formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}
</span>
) : null}
</div>
<div className="min-h-[200px] p-3">
<div className="file-viewer-body">
{isLoading ? (
<Skeleton className="h-24 w-full" />
<div className="stat">Loading</div>
) : fileError ? (
<div className="text-sm text-[color:var(--ink-soft)]">
Failed to load file: {fileError}
</div>
<div className="stat">Failed to load file: {fileError}</div>
) : fileContent ? (
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{fileContent}
</pre>
<pre className="file-viewer-code">{fileContent}</pre>
) : (
<div className="text-sm text-[color:var(--ink-soft)]">Select a file to preview.</div>
<div className="stat">Select a file to preview.</div>
)}
</div>
</div>
+154 -211
View File
@@ -1,21 +1,12 @@
import { Link } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Link } from "@tanstack/react-router";
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 { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Input } from "./ui/input";
import { UserBadge } from "./UserBadge";
export type SkillModerationInfo = {
@@ -117,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 &&
@@ -134,8 +124,8 @@ export function SkillHeader({
return (
<>
{modInfo?.isPendingScan ? (
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<div className="pending-banner">
<div className="pending-banner-content">
<strong>Security scan in progress</strong>
<p>
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
@@ -145,8 +135,8 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
@@ -155,15 +145,15 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isSuspicious ? (
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
<div className="flex flex-col gap-2">
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
<strong>Skill flagged suspicious patterns detected</strong>
<p>
ClawHub Security flagged this skill as suspicious. Review the scan results before
using.
</p>
{canManage ? (
<p className="text-sm text-[color:var(--ink-soft)]">
<p className="pending-banner-appeal">
If you believe this skill has been incorrectly flagged, please{" "}
<a
href="https://github.com/openclaw/clawhub/issues"
@@ -178,142 +168,109 @@ export function SkillHeader({
</div>
</div>
) : modInfo?.isRemoved ? (
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill removed by moderator</strong>
<p>This skill has been removed and is not visible to others.</p>
</div>
</div>
) : modInfo?.isHiddenByMod ? (
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
<div className="flex flex-col gap-2">
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill hidden</strong>
<p>This skill is currently hidden and not visible to others.</p>
</div>
</div>
) : null}
<Card>
<div className={`flex flex-col gap-5${hasPluginBundle ? " pb-2" : ""}`}>
<div className="flex flex-col gap-5 md:flex-row md:gap-8">
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<Badge variant="compact">v{latestVersion.version}</Badge>
<div className="card skill-hero">
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title" style={{ margin: 0 }}>
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <span className="tag tag-accent">Plugin bundle (nix)</span> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</p>
{isStaff && staffModerationNote ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{staffModerationNote}
</div>
) : null}
{nixPlugin ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="flex flex-col gap-2 pt-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.stars}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="flex items-center gap-1 text-sm text-[color:var(--ink-soft)]">
<Package size={14} aria-hidden="true" /> {formattedStats.downloads}
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
</span>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{formattedStats.installsAllTime} all-time
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{canonicalHref ? (
<>
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
<span className="text-sm text-[color:var(--ink-soft)]">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
<Badge variant="compact">{PLATFORM_SKILL_LICENSE}</Badge>
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
{canonicalHref ? (
<>
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<span key={badge} className="tag tag-compact">
{badge}
</span>
))}
{isStaff && staffVisibilityTag ? (
<span className={`tag tag-compact${isAutoHidden || isRemoved ? " tag-accent" : ""}`}>
{staffVisibilityTag}
</span>
) : null}
</div>
</div>
<div className="flex w-full flex-col gap-3 md:w-[220px] md:shrink-0">
{!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
<a
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
className="inline-flex w-full items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
>
Download zip
</a>
) : null}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-[color:var(--ink-soft)]">
License
</span>
<span className="text-sm text-[color:var(--ink)]">
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_SUMMARY}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="skill-hero-sidebar">
<div className="skill-actions">
{isAuthenticated ? (
<button
className={`flex h-9 w-9 items-center justify-center rounded-full border transition-all duration-200 ${isStarred ? "border-amber-400/60 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-950/40" : "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:text-amber-500"}`}
className={`star-toggle${isStarred ? " is-active" : ""}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
@@ -322,16 +279,12 @@ export function SkillHeader({
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={onOpenReport}>
<button className="btn btn-sm btn-ghost" type="button" onClick={onOpenReport}>
Report
</Button>
</button>
) : null}
{isStaff ? (
<Link
to="/management"
search={{ skill: skill.slug }}
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-xs min-h-[34px] rounded-[var(--radius-pill)] px-3 py-1.5 border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] transition-all duration-200 no-underline"
>
<Link className="btn btn-sm" to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
) : null}
@@ -341,13 +294,12 @@ export function SkillHeader({
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
{overrideScanMessage}
</div>
<div className="skill-hero-note">{overrideScanMessage}</div>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-2">
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
@@ -355,77 +307,67 @@ export function SkillHeader({
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
/>
<p className="text-xs text-[color:var(--ink-soft)]">
<p className="scan-disclaimer">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
{hasPluginBundle ? (
<Card className="border-dashed">
<CardContent>
<div className="flex flex-col gap-1">
<div className="font-display text-base font-bold text-[color:var(--ink)]">
Plugin bundle (nix)
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
Skill pack · CLI binary · Config
<div className="skill-panel bundle-card">
<div className="bundle-header">
<div className="bundle-title">Plugin bundle (nix)</div>
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
</div>
<div className="bundle-includes">
<span>SKILL.md</span>
<span>CLI</span>
<span>Config</span>
</div>
{configRequirements ? (
<div className="bundle-section">
<div className="bundle-section-title">Config requirements</div>
<div className="bundle-meta">
{configRequirements.requiredEnv?.length ? (
<div className="stat">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="stat">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge>SKILL.md</Badge>
<Badge>CLI</Badge>
<Badge>Config</Badge>
</div>
{configRequirements ? (
<div className="flex flex-col gap-2">
<div className="text-sm font-semibold text-[color:var(--ink)]">
Config requirements
</div>
<div className="flex flex-col gap-1">
{configRequirements.requiredEnv?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Required env</strong>
<span>{configRequirements.requiredEnv.join(", ")}</span>
</div>
) : null}
{configRequirements.stateDirs?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>State dirs</strong>
<span>{configRequirements.stateDirs.join(", ")}</span>
</div>
) : null}
</div>
</div>
) : null}
{cliHelp ? (
<details className="flex flex-col gap-2">
<summary className="cursor-pointer text-sm font-semibold text-[color:var(--ink)]">
CLI help (from plugin)
</summary>
<pre className="mt-2 overflow-x-auto rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] p-3 font-mono text-xs">
{cliHelp}
</pre>
</details>
) : null}
</CardContent>
</Card>
) : null}
{cliHelp ? (
<details className="bundle-section bundle-details">
<summary>CLI help (from plugin)</summary>
<pre className="hero-install-code mono">{cliHelp}</pre>
</details>
) : null}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-[color:var(--line)] pt-4">
<div className="skill-tag-row">
{tagEntries.length === 0 ? (
<span className="m-0 text-sm text-[color:var(--ink-soft)]">No tags yet.</span>
<span className="section-subtitle" style={{ margin: 0 }}>
No tags yet.
</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag} className="gap-1.5">
<span key={tag} className="tag">
{tag}
<span className="text-[0.68rem] opacity-70">
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="ml-0.5 cursor-pointer border-none bg-transparent p-0 text-current opacity-60 hover:opacity-100"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
@@ -433,7 +375,7 @@ export function SkillHeader({
×
</button>
) : null}
</Badge>
</span>
))
)}
</div>
@@ -444,16 +386,16 @@ export function SkillHeader({
event.preventDefault();
onTagSubmit();
}}
className="flex flex-wrap items-end gap-2 border-t border-[color:var(--line)] pt-4"
className="tag-form"
>
<Input
<input
className="search-input"
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest"
className="w-auto max-w-[160px]"
/>
<select
className="min-h-[44px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
className="search-input"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
@@ -463,12 +405,13 @@ export function SkillHeader({
</option>
))}
</select>
<Button type="submit">Update tag</Button>
<button className="btn" type="submit">
Update tag
</button>
</form>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</Card>
</div>
</>
);
}
+173 -216
View File
@@ -1,12 +1,5 @@
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";
import { Badge } from "./ui/badge";
import { Card, CardContent } from "./ui/card";
type SkillInstallCardProps = {
clawdis: ClawdisSkillMetadata | undefined;
@@ -32,229 +25,193 @@ 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 (
<div className="border-t border-[color:var(--line)] pt-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
License
</h3>
<div className="flex flex-col gap-2">
<Badge variant="accent">{PLATFORM_SKILL_LICENSE}</Badge>
<div className="text-sm text-[color:var(--ink-soft)]">
<span>{PLATFORM_SKILL_LICENSE_SUMMARY}</span>
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Terms</strong>
<a
href={PLATFORM_SKILL_LICENSE_URL}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{PLATFORM_SKILL_LICENSE_URL}
</a>
</div>
</div>
</CardContent>
</Card>
<div className="skill-hero-content">
<div className="skill-hero-panels">
{hasRuntimeRequirements ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Runtime requirements
</h3>
<div className="flex flex-col gap-2">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
{osLabels.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>OS</strong>
<span className="ml-1">{osLabels.join(" · ")}</span>
</div>
) : null}
{requirements?.bins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Bins</strong>
<span className="ml-1">{requirements.bins.join(", ")}</span>
</div>
) : null}
{requirements?.anyBins?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Any bin</strong>
<span className="ml-1">{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Env</strong>
<span className="ml-1">{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Config</strong>
<span className="ml-1">{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Primary env</strong>
<span className="ml-1">{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Environment variables</strong>
<div className="mt-1 flex flex-col gap-1">
{envVars.map((env, index) => (
<div key={`${env.name}-${index}`} className="flex items-baseline gap-2">
<code className="text-[0.85rem]">{env.name}</code>
{env.required === false ? (
<span className="text-xs text-[color:var(--ink-soft)]">optional</span>
) : env.required === true ? (
<span className="text-xs text-[color:var(--accent)]">required</span>
) : null}
{env.description ? (
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
{env.description}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
{hasDependencies ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Dependencies
</h3>
<div className="flex flex-col gap-2">
{dependencies.map((dep, index) => (
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
Runtime requirements
</h3>
<div className="skill-panel-body">
{clawdis?.emoji ? <div className="tag">{clawdis.emoji} Clawdis</div> : null}
{osLabels.length ? (
<div className="stat">
<strong>OS</strong>
<span>{osLabels.join(" · ")}</span>
</div>
) : null}
{requirements?.bins?.length ? (
<div className="stat">
<strong>Bins</strong>
<span>{requirements.bins.join(", ")}</span>
</div>
) : null}
{requirements?.anyBins?.length ? (
<div className="stat">
<strong>Any bin</strong>
<span>{requirements.anyBins.join(", ")}</span>
</div>
) : null}
{requirements?.env?.length ? (
<div className="stat">
<strong>Env</strong>
<span>{requirements.env.join(", ")}</span>
</div>
) : null}
{requirements?.config?.length ? (
<div className="stat">
<strong>Config</strong>
<span>{requirements.config.join(", ")}</span>
</div>
) : null}
{clawdis?.primaryEnv ? (
<div className="stat">
<strong>Primary env</strong>
<span>{clawdis.primaryEnv}</span>
</div>
) : null}
{envVars.length > 0 ? (
<div className="stat">
<strong>Environment variables</strong>
<div
key={`${dep.name}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
style={{
display: "flex",
flexDirection: "column",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
<div>
<strong>{dep.name}</strong>
<span className="ml-2 text-[0.85rem] text-[color:var(--ink-soft)]">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="break-all text-[0.8rem]">
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div className="text-[0.8rem]">
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
</div>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
{hasInstallSpecs ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Install
</h3>
<div className="flex flex-col gap-2">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div
key={`${spec.id ?? spec.kind}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div className="text-[0.85rem] text-[color:var(--ink-soft)]">
Bins: {spec.bins.join(", ")}
</div>
{envVars.map((env, index) => (
<div
key={`${env.name}-${index}`}
style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}
>
<code style={{ fontSize: "0.85rem" }}>{env.name}</code>
{env.required === false ? (
<span style={{ color: "var(--ink-soft)", fontSize: "0.75rem" }}>
optional
</span>
) : env.required === true ? (
<span style={{ color: "var(--ink-accent)", fontSize: "0.75rem" }}>
required
</span>
) : null}
{command ? (
<code className="mt-0.5 block font-mono text-xs">{command}</code>
{env.description ? (
<span style={{ color: "var(--ink-soft)", fontSize: "0.8rem" }}>
{env.description}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
</div>
) : null}
{hasDependencies ? (
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
Dependencies
</h3>
<div className="skill-panel-body">
{dependencies.map((dep, index) => (
<div key={`${dep.name}-${index}`} className="stat">
<div>
<strong>{dep.name}</strong>
<span
style={{
color: "var(--ink-soft)",
fontSize: "0.85rem",
marginLeft: "0.5rem",
}}
>
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div style={{ fontSize: "0.8rem", wordBreak: "break-all" }}>
<a href={dep.url} target="_blank" rel="noopener noreferrer">
{dep.url}
</a>
</div>
) : null}
{dep.repository && dep.repository !== dep.url ? (
<div style={{ fontSize: "0.8rem" }}>
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
Source
</a>
</div>
) : null}
</div>
</div>
))}
</div>
</div>
) : null}
{hasInstallSpecs ? (
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
Install
</h3>
<div className="skill-panel-body">
{installSpecs.map((spec, index) => {
const command = formatInstallCommand(spec);
return (
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
<div>
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
{spec.bins?.length ? (
<div style={{ color: "var(--ink-soft)", fontSize: "0.85rem" }}>
Bins: {spec.bins.join(", ")}
</div>
) : null}
{command ? <code>{command}</code> : null}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
) : null}
{hasLinks ? (
<Card className="p-4">
<CardContent className="gap-2">
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
Links
</h3>
<div className="flex flex-col gap-2">
{links?.homepage ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Homepage</strong>
<a
href={links.homepage}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Repository</strong>
<a
href={links.repository}
target="_blank"
rel="noopener noreferrer"
className="ml-1 break-all"
>
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="text-sm text-[color:var(--ink-soft)]">
<strong>Docs</strong>
<a
href={links.documentation}
target="_blank"
rel="noopener noreferrer"
className="ml-1"
>
{links.documentation}
</a>
</div>
) : null}
</div>
</CardContent>
</Card>
<div className="skill-panel">
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
Links
</h3>
<div className="skill-panel-body">
{links?.homepage ? (
<div className="stat">
<strong>Homepage</strong>
<a href={links.homepage} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="stat">
<strong>Repository</strong>
<a href={links.repository} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
{links.repository}
</a>
</div>
) : null}
{links?.documentation ? (
<div className="stat">
<strong>Docs</strong>
<a href={links.documentation} target="_blank" rel="noopener noreferrer">
{links.documentation}
</a>
</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
+52
View File
@@ -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 (
<Link to={href} className="skill-list-item">
<MarketplaceIcon kind="skill" label={skill.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
{handle ? (
<>
<span className="skill-list-item-owner">@{handle}</span>
<span className="skill-list-item-sep">/</span>
</>
) : null}
<span className="skill-list-item-name">{skill.displayName}</span>
{badges.map((b) => (
<span key={b} className="tag tag-compact">
{b}
</span>
))}
</div>
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Updated {timeAgo(skill.updatedAt)}</span>
<span className="skill-list-item-meta-item">
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span className="skill-list-item-meta-item">
<Package size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
</div>
</div>
</Link>
);
}
+142
View File
@@ -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 (
<aside className="detail-sidebar">
{/* Download / Install */}
{!nixPlugin && !isMalwareBlocked && !isRemoved ? (
<div className="sidebar-card">
<h3 className="sidebar-card-title">Download</h3>
<a
className="btn btn-primary"
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
style={{ width: "100%", justifyContent: "center" }}
>
Download zip
</a>
</div>
) : null}
{/* Stats */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Stats</h3>
<div className="sidebar-stat-grid">
<div className="sidebar-stat">
<span className="sidebar-stat-value">
<Package size={14} aria-hidden="true" />
{formatCompactStat(skill.stats.downloads)}
</span>
<span className="sidebar-stat-label">Downloads</span>
</div>
<div className="sidebar-stat">
<span className="sidebar-stat-value">
<Star size={14} aria-hidden="true" />
{formatCompactStat(skill.stats.stars)}
</span>
<span className="sidebar-stat-label">Stars</span>
</div>
<div className="sidebar-stat">
<span className="sidebar-stat-value">
{formatCompactStat(skill.stats.versions ?? 0)}
</span>
<span className="sidebar-stat-label">Versions</span>
</div>
</div>
</div>
{/* Metadata */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Details</h3>
<dl className="sidebar-metadata">
<div className="sidebar-metadata-row">
<dt>Updated</dt>
<dd>{timeAgo(skill.updatedAt)}</dd>
</div>
<div className="sidebar-metadata-row">
<dt>Created</dt>
<dd>{timeAgo(skill.createdAt)}</dd>
</div>
{latestVersion?.version ? (
<div className="sidebar-metadata-row">
<dt>Version</dt>
<dd>v{latestVersion.version}</dd>
</div>
) : null}
<div className="sidebar-metadata-row">
<dt>License</dt>
<dd>{PLATFORM_SKILL_LICENSE} ({PLATFORM_SKILL_LICENSE_SUMMARY})</dd>
</div>
{osLabels.length ? (
<div className="sidebar-metadata-row">
<dt>Platforms</dt>
<dd>{osLabels.join(", ")}</dd>
</div>
) : null}
</dl>
</div>
{/* Tags */}
{tagEntries.length > 0 ? (
<div className="sidebar-card">
<h3 className="sidebar-card-title">Tags</h3>
<div className="sidebar-tags">
{tagEntries.map(([tag]) => (
<span key={tag} className="tag tag-compact">
{tag}
</span>
))}
</div>
</div>
) : null}
{/* Owner */}
<div className="sidebar-card">
<h3 className="sidebar-card-title">Publisher</h3>
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="md"
showName
/>
</div>
</aside>
);
}
+8 -12
View File
@@ -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 = {
@@ -10,18 +11,13 @@ type SoulCardProps = {
export function SoulCard({ soul, summaryFallback, meta }: SoulCardProps) {
return (
<Link
to="/souls/$slug"
params={{ slug: soul.slug }}
className="group flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)]"
>
<h3 className="font-display text-base font-bold text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
{soul.displayName}
</h3>
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
{soul.summary ?? summaryFallback}
</p>
<div className="mt-auto flex items-center gap-3 pt-2">{meta}</div>
<Link to="/souls/$slug" params={{ slug: soul.slug }} className="card skill-card">
<div className="skill-card-header">
<MarketplaceIcon kind="soul" label={soul.displayName} size="md" />
<h3 className="skill-card-title">{soul.displayName}</h3>
</div>
<p className="skill-card-summary">{soul.summary ?? summaryFallback}</p>
<div className="skill-card-footer">{meta}</div>
</Link>
);
}
+31
View File
@@ -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 (
<Link to="/u/$handle" params={{ handle }} className="skill-list-item user-list-item" aria-label={`User: ${displayName}`}>
<MarketplaceIcon kind="user" label={displayName} imageUrl={user.image} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
<span className="skill-list-item-name">{displayName}</span>
<span className="skill-list-item-owner">@{handle}</span>
</div>
<p className="skill-list-item-summary">{user.bio?.trim() || "Builder on ClawHub."}</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">User</span>
<span className="skill-list-item-meta-item">Profile</span>
</div>
</div>
</Link>
);
}
+9 -9
View File
@@ -1,19 +1,19 @@
export type SkillCategory = {
slug: string;
label: string;
icon: string;
keywords: string[];
};
export const SKILL_CATEGORIES: SkillCategory[] = [
{ slug: "mcp-tools", label: "MCP Tools", keywords: ["mcp", "tool", "server"] },
{ slug: "prompts", label: "Prompts", keywords: ["prompt", "template", "system"] },
{ slug: "workflows", label: "Workflows", keywords: ["workflow", "pipeline", "chain"] },
{ slug: "dev-tools", label: "Dev Tools", keywords: ["dev", "debug", "lint", "test", "build"] },
{ slug: "data", label: "Data & APIs", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
{ slug: "security", label: "Security", keywords: ["security", "scan", "auth", "encrypt"] },
{ slug: "automation", label: "Automation", keywords: ["auto", "cron", "schedule", "bot"] },
{ slug: "other", label: "Other", keywords: [] },
{ 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: [] },
];
export const ALL_CATEGORY_KEYWORDS = SKILL_CATEGORIES.flatMap((c) => c.keywords);
+33
View File
@@ -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`;
}
+158
View File
@@ -0,0 +1,158 @@
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<UnifiedResult[]>([]);
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) {
requestRef.current += 1;
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<unknown> | null,
Promise<{ items: PackageListItem[] }> | null,
Promise<{ items: PublicUser[] }> | null,
] = [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 settled = await Promise.allSettled(
promises.map((p) => p ?? Promise.resolve(null)),
);
if (requestId !== requestRef.current) return;
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
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 (error) {
console.error("Unified search failed:", error);
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 };
}
+21
View File
@@ -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)
+109 -155
View File
@@ -1,69 +1,63 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { AlertTriangle, Shield, ShieldAlert } from "lucide-react";
import { Container } from "../components/layout/Container";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Separator } from "../components/ui/separator";
import { getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
import { createFileRoute, Link } from '@tanstack/react-router';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
const prohibitedCategories = [
{
title: "Bypass and unauthorized access",
title: 'Bypass and unauthorized access',
examples:
"Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.",
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
},
{
title: "Platform abuse and ban evasion",
title: 'Platform abuse and ban evasion',
examples:
"Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.",
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
},
{
title: "Fraud and deception",
title: 'Fraud and deception',
examples:
"Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.",
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
},
{
title: "Privacy-invasive surveillance",
title: 'Privacy-invasive surveillance',
examples:
"Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.",
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
},
{
title: "Non-consensual impersonation",
title: 'Non-consensual impersonation',
examples:
"Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.",
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
},
{
title: "Explicit sexual content",
title: 'Explicit sexual content',
examples:
"NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.",
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
},
{
title: "Hidden or misleading execution",
title: 'Hidden or misleading execution',
examples:
"Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.",
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
},
];
const recentPatterns = [
"Create stealth seller accounts after marketplace bans.",
"Modify Telegram pairing so unapproved users automatically receive pairing codes.",
"Cultivate Reddit or Twitter accounts with undetectable automation.",
"Generate professional certificates or invoices for arbitrary use.",
"Generate NSFW content with safety checks disabled.",
"Scrape leads, enrich contacts, and launch cold outreach at scale.",
"Buy, publish, or download leaked data or breach dumps.",
"Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.",
'Create stealth seller accounts after marketplace bans.',
'Modify Telegram pairing so unapproved users automatically receive pairing codes.',
'Cultivate Reddit or Twitter accounts with undetectable automation.',
'Generate professional certificates or invoices for arbitrary use.',
'Generate NSFW content with safety checks disabled.',
'Scrape leads, enrich contacts, and launch cold outreach at scale.',
'Buy, publish, or download leaked data or breach dumps.',
'Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.',
];
export const Route = createFileRoute("/about")({
export const Route = createFileRoute('/about')({
head: () => {
const mode = getSiteMode();
const siteName = getSiteName(mode);
const siteUrl = getSiteUrlForMode(mode);
const title = `About · ${siteName}`;
const description =
"What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.";
'What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.';
return {
links: [
@@ -74,11 +68,11 @@ export const Route = createFileRoute("/about")({
],
meta: [
{ title },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${siteUrl}/about` },
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: `${siteUrl}/about` },
],
};
},
@@ -87,126 +81,86 @@ export const Route = createFileRoute("/about")({
function AboutPage() {
return (
<main className="py-10">
<Container size="wide">
<div className="flex flex-col gap-6">
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex flex-wrap gap-2">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</div>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
What ClawHub Will Not Host
</h1>
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
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.
</p>
<div className="flex items-center gap-2 rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm font-medium text-[color:var(--ink-soft)]">
<Shield className="h-4 w-4 shrink-0 text-[color:var(--accent)]" />
We moderate based on end-to-end abuse patterns, not just isolated keywords.
</div>
</CardContent>
</Card>
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
{prohibitedCategories.map((category) => (
<Card key={category.title}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlert className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
{category.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
{category.examples}
</p>
</CardContent>
</Card>
))}
<main className="section about-page">
<div className="about-hero">
<div className="about-hero-copy">
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
<span className="tag">About</span>
<span className="tag tag-accent">Policy</span>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
Recent patterns we are explicitly not okay with
</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-2">
{recentPatterns.map((pattern) => (
<li
key={pattern}
className="flex items-start gap-2 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm text-[color:var(--ink-soft)]"
>
<span className="mt-0.5 shrink-0 text-[color:var(--gold)]" aria-hidden="true">
-
</span>
{pattern}
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Enforcement</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="flex flex-col gap-2">
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
1.
</span>
We may hide, remove, or hard-delete violating skills.
</li>
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
2.
</span>
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</li>
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
3.
</span>
We do not guarantee warning-first enforcement for obvious abuse.
</li>
</ul>
<Separator />
<div className="flex flex-wrap gap-3">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
>
<Button variant="primary">Browse Skills</Button>
</Link>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
<Button variant="outline">Reviewer Doc</Button>
</a>
</div>
</CardContent>
</Card>
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
defenses, scam people, invade privacy, or enable non-consensual behavior, it does not
belong here.
</p>
</div>
</Container>
<div className="about-callout">
<span className="about-callout-label">Moderation stance</span>
<p>
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
Predatory workflows get removed.
</p>
</div>
</div>
<section className="about-section">
<div className="home-section-header">
<h2 className="home-section-title">Immediate rejection categories</h2>
</div>
<div className="about-grid">
{prohibitedCategories.map((category) => (
<article key={category.title} className="about-rule-card">
<h2>{category.title}</h2>
<p>{category.examples}</p>
</article>
))}
</div>
</section>
<section className="about-section">
<div className="home-section-header">
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
</div>
<div className="about-patterns">
{recentPatterns.map((pattern) => (
<div key={pattern} className="about-pattern">
{pattern}
</div>
))}
</div>
</section>
<section className="about-enforcement">
<div>
<span className="about-callout-label">Enforcement</span>
<div className="management-sublist">
<div className="management-subitem">
We may hide, remove, or hard-delete violating skills.
</div>
<div className="management-subitem">
We may revoke tokens, soft-delete associated content, and ban repeat or severe
offenders.
</div>
<div className="management-subitem">
We do not guarantee warning-first enforcement for obvious abuse.
</div>
</div>
</div>
<div className="skill-card-tags">
<Link className="btn btn-primary" to="/skills">
Browse Skills
</Link>
<a
className="btn"
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</div>
</section>
</main>
);
}
+201 -216
View File
@@ -16,12 +16,6 @@ import { useEffect, useState } from "react";
import semver from "semver";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { EmptyState } from "../components/EmptyState";
import { Container } from "../components/layout/Container";
import { SignInButton } from "../components/SignInButton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import type { PublicSkill } from "../lib/publicUser";
@@ -110,8 +104,7 @@ function Dashboard() {
useEffect(() => {
if (selectedPublisherId) return;
const personal =
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
if (personal?.publisher._id) {
setSelectedPublisherId(personal.publisher._id);
}
@@ -119,38 +112,69 @@ function Dashboard() {
if (!me) {
return (
<Container className="py-10">
<Card>
<CardContent className="flex flex-col items-start gap-3">
<span>Sign in to access your dashboard.</span>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</CardContent>
</Card>
</Container>
<main className="section">
<div className="card">Sign in to access your dashboard.</div>
</main>
);
}
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 (
<main className="section">
<div className="empty-state">
<h1 className="empty-state-title" style={{ fontSize: "1.4rem", fontFamily: "var(--font-display)" }}>
Welcome to ClawHub
</h1>
<p className="empty-state-body">
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
Publish a Skill
</Link>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="btn"
>
Browse Skills
</Link>
</div>
</div>
</main>
);
}
return (
<Container className="py-10">
{/* Header */}
<div className="mb-8 flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
<div className="grid gap-1.5">
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
<main className="section">
<div className="dashboard-header">
<div>
<h1 className="section-title" style={{ margin: 0 }}>
Publisher Dashboard
</h1>
<p className="text-sm text-[color:var(--ink-soft)]">
Owner-only view for skills and plugins, including security scans and verification.
<p className="section-subtitle" style={{ margin: 0 }}>
Manage your published skills and plugins.
</p>
</div>
<div className="flex flex-wrap gap-2">
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{publishers && publishers.length > 0 ? (
<select
className="min-h-[44px] rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] px-4 py-[11px] text-sm text-[color:var(--ink)] transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35"
className="input"
value={selectedPublisherId}
onChange={(event) => setSelectedPublisherId(event.target.value)}
>
@@ -161,180 +185,160 @@ function Dashboard() {
))}
</select>
) : null}
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
<Button asChild>
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
className="btn"
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</div>
</div>
{/* Owner panel */}
<Card>
<CardContent>
<div className="grid gap-10">
{/* Skills section */}
<section className="flex flex-col gap-4">
<section className="card dashboard-owner-panel">
<div className="dashboard-owner-grid">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Publisher Skills
</h2>
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
<h2 className="dashboard-collection-title">Publisher Skills</h2>
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
Hidden skill versions remain visible here while checks are pending.
</p>
</div>
{skills.length === 0 ? (
<EmptyState
icon={Upload}
title="No skills yet."
description="Publish your first skill to share it with the community."
>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</EmptyState>
) : (
<div className="flex flex-col">
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
<span>Skill</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{skills.map((skill) => (
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
))}
</div>
{skills.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
</div>
)}
</section>
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Skill</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{skills.map((skill) => (
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
{/* Plugins section */}
<section className="flex flex-col gap-4">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
Publisher Plugins
</h2>
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
Owner-only package view with VirusTotal, static scan, and verification state.
</p>
</div>
{packages.length === 0 ? (
<EmptyState
icon={Plug}
title="No plugins yet."
description="Publish your first plugin release to validate and distribute it."
>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</EmptyState>
) : (
<div className="flex flex-col">
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
<span>Plugin</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{packages.map((pkg) => (
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
))}
</div>
{packages.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
</div>
)}
</section>
</div>
</CardContent>
</Card>
</Container>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
className="btn btn-primary"
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Plugin</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{packages.map((pkg) => (
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</div>
</section>
</main>
);
}
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
return (
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
{/* Primary info */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
className="dashboard-skill-name"
>
{skill.displayName}
</Link>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">/{skill.slug}</span>
<span className="dashboard-list-id">/{skill.slug}</span>
{skill.pendingReview ? (
<Badge variant="pending">
<span className="tag tag-pending">
<Clock className="h-3 w-3" aria-hidden="true" />
Pending checks
</Badge>
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.downloads)}
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
<span>
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
</span>
</div>
</div>
{/* Summary */}
<div className="text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</div>
{/* Status */}
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
{skill.pendingReview ? (
<>
<span className="inline-flex items-center gap-1">
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />
VT pending
</span>
<span className="text-[color:var(--ink-soft)]">
<span className="dashboard-inline-status-note">
Hidden until verification checks finish.
</span>
</>
) : (
<span>Visible</span>
<span className="dashboard-inline-status-note">Visible</span>
)}
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2">
<Button asChild size="sm">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
View
</Link>
</Button>
<div className="dashboard-row-actions">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }} className="btn btn-sm">
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
</Link>
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
className="btn btn-ghost btn-sm"
>
View
</Link>
</div>
</div>
);
@@ -372,25 +376,23 @@ function PackageStatusTag({
label: string;
tone: "default" | "pending" | "warning" | "danger" | "success";
}) {
const variant =
const className =
tone === "pending"
? "pending"
? "tag tag-pending"
: tone === "warning"
? "warning"
? "tag dashboard-tag-warning"
: tone === "danger"
? "destructive"
? "tag dashboard-tag-danger"
: tone === "success"
? "success"
: "default";
return <Badge variant={variant}>{label}</Badge>;
? "tag dashboard-tag-success"
: "tag";
return <span className={className}>{label}</span>;
}
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
const scanLabel = scanStatusLabel(pkg.scanStatus);
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
const sourceLabel = pkg.sourceRepo
?.replace(/^https?:\/\/github\.com\//, "")
.replace(/\.git$/, "");
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
const scanTone =
pkg.scanStatus === "pending"
? "pending"
@@ -411,20 +413,15 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
: "default";
return (
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
{/* Primary info */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to="/plugins/$name"
params={{ name: pkg.name }}
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
>
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
{pkg.displayName}
</Link>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">{pkg.name}</span>
<span className="dashboard-list-id">{pkg.name}</span>
</div>
<div className="flex flex-wrap gap-1.5">
<div className="dashboard-inline-tags">
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
<PackageStatusTag label={pkg.channel} tone="default" />
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
@@ -438,41 +435,34 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
/>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
<span className="inline-flex items-center gap-1">
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(pkg.stats.downloads)}
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
</span>
<span className="inline-flex items-center gap-1">
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
</span>
<span className="inline-flex items-center gap-1">
<span>
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
</span>
<span className="inline-flex items-center gap-1">
<span>
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
</span>
{pkg.runtimeId ? (
<span className="inline-flex items-center gap-1">
<span>
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
</span>
) : null}
{sourceLabel ? (
<span className="inline-flex items-center gap-1">
<span>
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
</span>
) : null}
</div>
</div>
{/* Summary */}
<div className="text-sm text-[color:var(--ink-soft)]">
{pkg.summary ?? "No summary provided."}
</div>
{/* Status */}
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
<span className="inline-flex items-center gap-1">
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />{" "}
{releaseStatusLabel(
"VT",
@@ -480,39 +470,34 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
pkg.scanStatus === "pending" ? "pending" : "unknown",
)}
</span>
<span className="inline-flex items-center gap-1">
<span className="dashboard-inline-status-item">
<CheckCircle2 size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
</span>
<span className="inline-flex items-center gap-1">
<span className="dashboard-inline-status-item">
<AlertTriangle size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
</span>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2">
<Button asChild size="sm">
<Link
to="/publish-plugin"
search={{
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: nextVersion ?? undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
}}
>
<Upload className="h-3 w-3" aria-hidden="true" />
New Release
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/plugins/$name" params={{ name: pkg.name }}>
View
</Link>
</Button>
<div className="dashboard-row-actions">
<Link
to="/publish-plugin"
search={{
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: nextVersion ?? undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
}}
className="btn btn-sm"
>
<Upload className="h-3 w-3" aria-hidden="true" />
New Release
</Link>
<Link to="/plugins/$name" params={{ name: pkg.name }} className="btn btn-ghost btn-sm">
View
</Link>
</div>
</div>
);
+293 -273
View File
@@ -1,20 +1,16 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { ArrowRight, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { InstallSwitcher } from "../components/InstallSwitcher";
import { Container } from "../components/layout/Container";
import { SkillCardSkeletonGrid } from "../components/skeletons/SkillCardSkeleton";
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 { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
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";
@@ -36,35 +32,38 @@ function SkillsHome() {
};
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
const [loaded, setLoaded] = useState(false);
const totalSkills = useQuery(api.skills.countPublicSkills);
const totalSkillsText =
typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null;
const [trending, setTrending] = useState<SkillPageEntry[]>([]);
const [recent, setRecent] = useState<SkillPageEntry[]>([]);
const [skillCount, setSkillCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
void Promise.all([
convexHttp
.query(api.skills.listHighlightedPublic, { limit: 6 })
.then((r) => {
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
})
.catch(() => {}),
convexHttp
.query(api.skills.listPublicPageV4, {
numItems: 12,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
})
.then((r) => {
if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
})
.catch(() => {}),
]).then(() => {
if (!cancelled) setLoaded(true);
});
Promise.all([
convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
convexHttp.query(api.skills.listPublicPageV4, {
numItems: 8,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
}),
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;
};
@@ -72,28 +71,18 @@ function SkillsHome() {
return (
<main>
{/* Hero */}
<section className="relative overflow-hidden py-20 px-7">
<div className="mx-auto max-w-[1200px]">
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
<div className="flex flex-col gap-5 fade-up" data-delay="1">
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
A versioned registry for AI agent skills
</span>
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
ClawHub, the skill dock for sharp agents.
</h1>
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
Browse, install, and publish skill packs. Versioned like npm, searchable with
vectors, no gatekeeping.
<section className="home-hero">
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
<div className="home-hero-kicker">Discovery hub</div>
<h1 className="home-hero-title">The collaborative hub for agent skills</h1>
<p className="home-hero-subtitle">
{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."}
</p>
{/* Stats bar */}
{totalSkillsText && (
<p className="text-sm font-semibold text-[color:var(--ink-soft)]">
{totalSkillsText} skills available
</p>
)}
<div className="flex flex-wrap gap-3 pt-2">
<div className="home-hero-actions">
<Link
to="/skills"
search={{
@@ -105,159 +94,232 @@ function SkillsHome() {
view: undefined,
focus: undefined,
}}
className="btn btn-primary"
>
<Button variant="primary" size="lg">
Browse skills
<ArrowRight className="h-4 w-4" />
</Button>
Browse All Skills & Plugins
</Link>
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="outline" size="lg">
Publish Skill
</Button>
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
className="btn home-hero-publish-btn"
>
+ Publish Yours
</Link>
</div>
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
Search skills. Versioned, rollback-ready.
</div>
<InstallSwitcher exampleSlug="sonoscli" />
</div>
</div>
</div>
</div>
</section>
{/* Staff Picks */}
<section className="py-12">
<Container>
<div className="flex flex-col gap-6">
<div className="flex items-end justify-between">
<div>
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
Staff Picks
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
Curated signal highlighted for quick trust.
</p>
</div>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="hidden text-sm font-semibold text-[color:var(--accent)] hover:underline sm:block"
>
View all
</Link>
</div>
{!loaded && highlighted.length === 0 ? (
<SkillCardSkeletonGrid count={6} />
) : highlighted.length === 0 ? (
<p className="text-sm text-[color:var(--ink-soft)]">No highlighted skills yet.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{highlighted.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
badge={getSkillBadges(entry.skill)}
summaryFallback="A fresh skill bundle."
meta={
<>
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={entry.skill.stats} />
</span>
</>
}
/>
))}
</div>
)}
</div>
</Container>
</section>
{/* Popular */}
<section className="py-12">
<Container>
<div className="flex flex-col gap-6">
<div>
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
Popular skills
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
Most-downloaded, verified picks.
<p className="home-hero-explainer">
Sharp filters. Clean listings. Discovery that feels more like a real index and less
like a sad spreadsheet.
</p>
</div>
{!loaded && popular.length === 0 ? (
<SkillCardSkeletonGrid count={6} />
) : popular.length === 0 ? (
<p className="text-sm text-[color:var(--ink-soft)]">No skills yet. Be the first.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{popular.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
summaryFallback="Agent-ready skill pack."
meta={
<>
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={entry.skill.stats} />
</span>
</>
}
/>
))}
</div>
)}
<div className="flex justify-center pt-4">
<div className="home-hero-panels" id="home-discovery">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
sort: "downloads" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-hero-panel"
>
<Button variant="outline">
See all skills
<ArrowRight className="h-4 w-4" />
</Button>
<span className="home-hero-panel-label">Skills</span>
<strong>Browse ranked skill bundles</strong>
<span>Popular installs, fresh updates, staff picks.</span>
</Link>
<Link to="/plugins" className="home-hero-panel">
<span className="home-hero-panel-label">Plugins</span>
<strong>Find agent-ready packages</strong>
<span>Code plugins, bundles, and verified publishers.</span>
</Link>
<Link to="/users" search={{ q: undefined }} className="home-hero-panel">
<span className="home-hero-panel-label">Users</span>
<strong>Meet the builders</strong>
<span>Profiles, bios, and the people shipping useful stuff.</span>
</Link>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="home-hero-panel"
>
<span className="home-hero-panel-label">Souls</span>
<strong>SOUL.md discovery is coming</strong>
<span>Holding page for the next catalog surface.</span>
</Link>
</div>
</div>
</Container>
</div>
</section>
{/* Trending */}
{trending.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Trending</h2>
<Link
to="/skills"
search={{
q: undefined,
sort: "downloads" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
See all
</Link>
</div>
<div className="results-list">
{trending.map((entry) => (
<SkillListItem
key={entry.skill._id}
skill={entry.skill}
ownerHandle={entry.ownerHandle}
owner={entry.owner}
/>
))}
</div>
</section>
) : null}
{/* Recently updated */}
{recent.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Recently updated</h2>
<Link
to="/skills"
search={{
q: undefined,
sort: "updated" as const,
dir: "desc" as const,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
See all
</Link>
</div>
<div className="results-list">
{recent.map((entry) => (
<SkillListItem
key={entry.skill._id}
skill={entry.skill}
ownerHandle={entry.ownerHandle}
owner={entry.owner}
/>
))}
</div>
</section>
) : null}
{/* Staff picks */}
{highlighted.length > 0 ? (
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Staff picks</h2>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
See all
</Link>
</div>
<div className="grid">
{
highlighted.map((entry) => (
<SkillCard
key={entry.skill._id}
skill={entry.skill}
badge={getSkillBadges(entry.skill)}
summaryFallback="A fresh skill bundle."
meta={
<div className="skill-card-footer-rows">
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<div className="stat">
<SkillStatsTripletLine stats={entry.skill.stats} />
</div>
</div>
}
/>
))}
</div>
</section>
) : null}
{/* Quick links */}
<section className="home-section">
<div className="home-quick-links">
<Link
to="/skills"
search={{ q: undefined, sort: "stars" as const, dir: "desc" as const, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
className="home-quick-link"
>
Most starred
</Link>
<Link
to="/skills"
search={{ q: undefined, sort: "newest" as const, dir: undefined, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
className="home-quick-link"
>
New this week
</Link>
<Link to="/plugins" className="home-quick-link">
Browse plugins
</Link>
<Link to="/users" search={{ q: undefined }} className="home-quick-link">
Browse users
</Link>
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
className="home-quick-link"
>
Souls coming soon
</Link>
<Link
to="/skills"
search={{ q: undefined, sort: undefined, dir: undefined, highlighted: true, nonSuspicious: undefined, view: undefined, focus: undefined }}
className="home-quick-link"
>
Staff picks
</Link>
</div>
</section>
</main>
);
}
function OnlyCrabsHome() {
const navigate = Route.useNavigate();
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
@@ -274,45 +336,18 @@ function OnlyCrabsHome() {
return (
<main>
<section className="relative overflow-hidden py-20 px-7">
<div className="mx-auto max-w-[1200px]">
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
<div className="flex flex-col gap-5 fade-up" data-delay="1">
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
SOUL.md, shared.
</span>
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
SoulHub, where system lore lives.
</h1>
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
<section className="home-hero">
<div className="home-hero-inner">
<div className="home-hero-grid">
<div className="home-hero-copy">
<div className="home-hero-kicker">OnlyCrabs</div>
<h1 className="home-hero-title">SoulHub, where system lore lives.</h1>
<p className="home-hero-subtitle">
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
public place.
</p>
<div className="flex flex-wrap gap-3 pt-2">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="primary" size="lg">
Publish Soul
</Button>
</Link>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
<Button variant="outline" size="lg">
Browse souls
</Button>
</Link>
</div>
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
<form
className="search-bar"
className="home-hero-search"
onSubmit={(event) => {
event.preventDefault();
void navigate({
@@ -327,72 +362,57 @@ function OnlyCrabsHome() {
});
}}
>
<Search className="h-4 w-4 text-[color:var(--ink-soft)]" />
<Input
<input
className="home-hero-search-input"
type="text"
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="border-none bg-transparent shadow-none focus:shadow-none focus:ring-0"
/>
<button className="btn btn-primary" type="submit">
Search
</button>
</form>
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
Search souls. Versioned, readable, easy to remix.
</div>
</div>
</div>
</div>
</div>
</section>
<section className="py-12">
<Container>
<div className="flex flex-col gap-6">
<div>
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
Latest souls
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
Newest SOUL.md bundles across the hub.
</p>
</div>
{latest.length === 0 ? (
<SkillCardSkeletonGrid count={6} />
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{latest.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SoulStatsTripletLine stats={soul.stats} />
</span>
}
/>
))}
</div>
)}
<div className="flex justify-center pt-4">
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
<Button variant="outline">
See all souls
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</Container>
<section className="home-section">
<div className="home-section-header">
<h2 className="home-section-title">Latest souls</h2>
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="home-section-link"
>
See all
</Link>
</div>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No souls yet. Be the first.</div>
) : (
latest.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<div className="stat">
<SoulStatsTripletLine stats={soul.stats} />
</div>
}
/>
))
)}
</div>
</section>
</main>
);
+647 -809
View File
File diff suppressed because it is too large Load Diff
+171 -249
View File
@@ -1,20 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { AlertTriangle, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { PluginListItem } from "../../components/PluginListItem";
import {
fetchPluginCatalog,
isRateLimitedPackageApiError,
type PackageListItem,
} from "../../lib/packageApi";
import { familyLabel } from "../../lib/packageLabels";
type PluginSearchState = {
q?: string;
@@ -31,6 +24,15 @@ type PluginsLoaderData = {
retryAfterSeconds: number | null;
};
function formatRetryDelay(retryAfterSeconds: number | null) {
if (!retryAfterSeconds || retryAfterSeconds <= 0) return "in a moment";
if (retryAfterSeconds < 60) {
return `in about ${retryAfterSeconds} second${retryAfterSeconds === 1 ? "" : "s"}`;
}
const minutes = Math.ceil(retryAfterSeconds / 60);
return `in about ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
export const Route = createFileRoute("/plugins/")({
validateSearch: (search): PluginSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
@@ -44,7 +46,9 @@ export const Route = createFileRoute("/plugins/")({
? true
: undefined,
executesCode:
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
search.executesCode === true ||
search.executesCode === "true" ||
search.executesCode === "1"
? true
: undefined,
}),
@@ -60,276 +64,194 @@ export const Route = createFileRoute("/plugins/")({
limit: 50,
});
return {
items: data.items,
nextCursor: data.nextCursor,
items: data.items ?? [],
nextCursor: data.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
} satisfies PluginsLoaderData;
} catch (error) {
if (!isRateLimitedPackageApiError(error)) throw error;
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: error.retryAfterSeconds,
} satisfies PluginsLoaderData;
if (isRateLimitedPackageApiError(error)) {
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: (error as { retryAfterSeconds?: number }).retryAfterSeconds ?? null,
} satisfies PluginsLoaderData;
}
throw error;
}
},
component: PluginsIndex,
});
function VerifiedBadge() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Verified publisher"
className="inline-block shrink-0 align-middle"
>
<path
d="M8 0L9.79 1.52L12.12 1.21L12.93 3.41L15.01 4.58L14.42 6.84L15.56 8.82L14.12 10.5L14.12 12.82L11.86 13.41L10.34 15.27L8 14.58L5.66 15.27L4.14 13.41L1.88 12.82L1.88 10.5L0.44 8.82L1.58 6.84L0.99 4.58L3.07 3.41L3.88 1.21L6.21 1.52L8 0Z"
fill="#3b82f6"
/>
<path
d="M5.5 8L7 9.5L10.5 6"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function PluginsIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const { items, nextCursor, rateLimited, retryAfterSeconds } =
Route.useLoaderData() as PluginsLoaderData;
const [query, setQuery] = useState(search.q ?? "");
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
setQuery(search.q ?? "");
}, [search.q]);
return (
<main className="py-10">
<Container size="wide">
<header className="mb-6">
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Plugins</h1>
<p className="text-sm text-[color:var(--ink-soft)]">Browse the plugin catalog.</p>
</header>
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,
}),
});
}
};
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<form
className="relative flex flex-1 items-center"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
}),
});
}}
>
<Search
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
aria-hidden="true"
/>
<Input
className="pl-9"
placeholder="Search plugins..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<Link
to="/publish-plugin"
search={{
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
}}
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
>
Publish Plugin
</Link>
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 (
<main className="browse-page">
<div className="browse-page-header">
<h1 className="browse-title">Plugins</h1>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
<Link
className="btn btn-primary"
to="/publish-plugin"
search={{
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
}}
>
Publish
</Link>
</div>
</div>
<form className="browse-page-search" onSubmit={handleSearch}>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
placeholder="Search plugins..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
sortOptions={[
{ value: "all", label: "All types" },
{ value: "code-plugin", label: "Code plugins" },
{ value: "bundle-plugin", label: "Bundle plugins" },
]}
activeSort={search.family ?? "all"}
onSortChange={handleFamilySort}
filters={[
{ key: "verified", label: "Verified only", active: search.verified ?? false },
{ key: "executesCode", label: "Executes code", active: search.executesCode ?? false },
]}
onFilterToggle={handleFilterToggle}
/>
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{items.length} plugin{items.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex flex-wrap items-stretch gap-2">
<div
className="flex min-w-0 flex-wrap items-center rounded-[var(--radius-pill)] border border-[color:var(--line)]"
role="group"
aria-label="Filter by type"
>
{[
{ value: undefined, label: "All" },
{ value: "code-plugin" as const, label: "Code" },
{ value: "bundle-plugin" as const, label: "Bundles" },
].map((opt) => (
{rateLimited ? (
<div className="empty-state">
<AlertTriangle size={20} aria-hidden="true" />
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
<p className="empty-state-body">
Try again {formatRetryDelay(retryAfterSeconds)}.
</p>
</div>
) : items.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No plugins found</p>
<p className="empty-state-body">Try a different search term or remove filters.</p>
</div>
) : (
<div className="results-list">
{items.map((item) => (
<PluginListItem key={item.name} item={item} />
))}
</div>
)}
{!search.q && (search.cursor || nextCursor) ? (
<div style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 22 }}>
{search.cursor ? (
<button
key={opt.label}
className={`flex-1 px-3 py-1.5 text-sm font-semibold transition-colors first:rounded-l-[var(--radius-pill)] last:rounded-r-[var(--radius-pill)] sm:flex-none ${
(search.family ?? undefined) === opt.value
? "bg-[color:var(--accent)] text-white"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
className="btn"
type="button"
aria-pressed={(search.family ?? undefined) === opt.value}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
family: opt.value,
}),
search: (prev) => ({ ...prev, cursor: undefined }),
});
}}
>
{opt.label}
First page
</button>
))}
</div>
<Button
variant={search.verified ? "primary" : "outline"}
size="sm"
className="flex-1 sm:flex-none"
aria-pressed={search.verified ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
verified: prev.verified ? undefined : true,
}),
});
}}
>
<VerifiedBadge /> Verified
</Button>
<Button
variant={search.executesCode ? "primary" : "outline"}
size="sm"
className="flex-1 sm:flex-none"
aria-pressed={search.executesCode ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
executesCode: prev.executesCode ? undefined : true,
}),
});
}}
>
Executes code
</Button>
</div>
</div>
<div className="mt-6">
{rateLimited ? (
<EmptyState
icon={AlertTriangle}
title="Plugin catalog is temporarily unavailable"
description={`The public plugin API is rate-limited right now. Try again ${formatRetryDelay(
retryAfterSeconds,
)}.`}
action={{
label: "Try again",
onClick: () => window.location.reload(),
}}
/>
) : items.length === 0 ? (
<EmptyState
title="No plugins match that filter"
description="Try a different search or filter."
/>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(280px,1fr))] sm:gap-5">
{items.map((item) => (
<Link key={item.name} to="/plugins/$name" params={{ name: item.name }}>
<Card className="h-full cursor-pointer hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]">
<div className="flex flex-wrap gap-1.5">
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? (
<Badge variant="accent">
<VerifiedBadge /> Verified
</Badge>
) : null}
</div>
<h3 className="font-display text-lg font-bold text-[color:var(--ink)]">
{item.displayName}
</h3>
<p className="text-sm text-[color:var(--ink-soft)]">
{item.summary ?? "No summary provided."}
</p>
<div className="flex flex-col gap-1.5 pt-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 break-words text-sm text-[color:var(--ink-soft)]">
{item.ownerHandle ? `by ${item.ownerHandle}` : "community"}
</span>
{item.latestVersion ? (
<span className="text-sm text-[color:var(--ink-soft)]">
v{item.latestVersion}
</span>
) : null}
</div>
</Card>
</Link>
))}
</div>
{!search.q && (search.cursor || nextCursor) ? (
<div className="mt-6 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
{search.cursor ? (
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
}),
});
}}
>
First page
</Button>
) : null}
{nextCursor ? (
<Button
variant="primary"
className="w-full sm:w-auto"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: nextCursor,
}),
});
}}
>
Next page
</Button>
) : null}
</div>
) : null}
</>
)}
{nextCursor ? (
<button
className="btn btn-primary"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({ ...prev, cursor: nextCursor }),
});
}}
>
Next page
</button>
) : null}
</div>
) : null}
</div>
</Container>
</div>
</main>
);
}
+163 -35
View File
@@ -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,
});
}
component: UnifiedSearchPage,
});
throw redirect({
to: "/",
search: {
q: search.q || undefined,
highlighted: undefined,
search: search.q ? undefined : true,
},
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 (
<main className="browse-page">
<h1 className="browse-title" style={{ marginBottom: 16 }}>
{search.q ? (
<>
Search results for <span style={{ color: "var(--accent)" }}>"{search.q}"</span>
</>
) : (
"Search"
)}
</h1>
<form className="search-page-form" onSubmit={handleSearch}>
<div className="browse-search-bar" style={{ maxWidth: 560, flex: 1 }}>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
type="text"
placeholder="Search skills, plugins, users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
</form>
<div className="search-tabs">
<button
className={`search-tab${activeType === "all" ? " is-active" : ""}`}
type="button"
onClick={() => setType("all")}
>
All
</button>
<button
className={`search-tab${activeType === "skills" ? " is-active" : ""}`}
type="button"
onClick={() => setType("skills")}
>
Skills
{skillCount > 0 ? (
<span className="search-tab-count">{skillCount}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
type="button"
onClick={() => setType("plugins")}
>
Plugins
{pluginCount > 0 ? (
<span className="search-tab-count">{pluginCount}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setType("users")}
>
Users
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
</button>
</div>
{isSearching ? (
<div className="card">
<div className="loading-indicator">Searching...</div>
</div>
) : !search.q ? (
<div className="card" style={{ textAlign: "center", padding: 40 }}>
<p style={{ color: "var(--ink-soft)" }}>
Enter a search term to find skills, plugins, and users
</p>
</div>
) : results.length === 0 ? (
<div className="card" style={{ textAlign: "center", padding: 40 }}>
<p style={{ color: "var(--ink-soft)" }}>No results found for "{search.q}"</p>
</div>
) : (
<div className="results-list">
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : item.type === "plugin" ? (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
) : (
<UserResultRow key={`user-${item.user._id}`} result={item} />
),
)}
</div>
)}
</main>
);
}
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
const skill = result.skill as unknown as PublicSkill;
return (
<SkillListItem
skill={skill}
ownerHandle={result.ownerHandle}
/>
);
}
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
return <PluginListItem item={result.plugin} />;
}
function UserResultRow({ result }: { result: UnifiedUserResult }) {
const user = result.user as PublicUser;
return <UserListItem user={user} />;
}
+45 -84
View File
@@ -1,13 +1,8 @@
import { Link } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import type { RefObject } from "react";
import { EmptyState } from "../../components/EmptyState";
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
import { SkillCard } from "../../components/SkillCard";
import { SkillListItem } from "../../components/SkillListItem";
import { getPlatformLabels } from "../../components/skillDetailUtils";
import { SkillMetricsRow, SkillStatsTripletLine } from "../../components/SkillStats";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { SkillStatsTripletLine } from "../../components/SkillStats";
import { UserBadge } from "../../components/UserBadge";
import { getSkillBadges } from "../../lib/badges";
import { buildSkillHref, type SkillListEntry } from "./-types";
@@ -29,7 +24,7 @@ export function SkillsResults({
isLoadingSkills,
sorted,
view,
listDoneLoading,
listDoneLoading: _listDoneLoading,
hasQuery,
canLoadMore,
isLoadingMore,
@@ -40,14 +35,27 @@ export function SkillsResults({
return (
<>
{isLoadingSkills ? (
<SkillCardSkeletonGrid count={6} />
<div className="skeleton-list">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-icon" />
<div className="skeleton-row-body">
<div className="skeleton-bar skeleton-bar-lg" />
<div className="skeleton-bar skeleton-bar-sm" />
<div className="skeleton-bar skeleton-bar-xs" />
</div>
</div>
))}
</div>
) : sorted.length === 0 ? (
<EmptyState
title={listDoneLoading || hasQuery ? "No skills match that filter" : "Loading skills..."}
description={hasQuery ? "Try adjusting your search or filters." : undefined}
/>
<div className="empty-state">
<p className="empty-state-title">No skills found</p>
<p className="empty-state-body">
{hasQuery ? "Try a different search term or remove filters." : "No skills have been published yet."}
</p>
</div>
) : view === "cards" ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
<div className="grid">
{sorted.map((entry) => {
const skill = entry.skill;
const clawdis = entry.latestVersion?.parsed?.clawdis;
@@ -65,105 +73,58 @@ export function SkillsResults({
platformLabels={platforms.length ? platforms : undefined}
summaryFallback="Agent-ready skill pack."
meta={
<>
<div className="skill-card-footer-rows">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix="by"
link={false}
/>
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<div className="stat">
<SkillStatsTripletLine stats={skill.stats} />
</span>
</>
</div>
</div>
}
/>
);
})}
</div>
) : (
/* List view */
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)]">
{/* Table header */}
<div className="grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] gap-4 border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-5 py-3 text-xs font-bold uppercase tracking-wider text-[color:var(--ink-soft)]">
<span>Skill</span>
<span>Summary</span>
<span>Author</span>
<span className="text-right">Stats</span>
</div>
{sorted.map((entry, i) => {
<div className="results-list">
{sorted.map((entry) => {
const skill = entry.skill;
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
const skillHref = buildSkillHref(skill, ownerHandle);
return (
<Link
<SkillListItem
key={skill._id}
className={`grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] items-center gap-4 px-5 py-3.5 no-underline transition-colors hover:bg-[color:var(--surface-muted)] ${
i % 2 === 0 ? "bg-[color:var(--surface)]" : "bg-[color:var(--bg-soft)]"
}`}
to={skillHref}
>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<span className="font-semibold text-[color:var(--ink)]">
{skill.displayName}
</span>
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
</span>
{entry.latestVersion?.version ? (
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
v{entry.latestVersion.version}
</span>
) : null}
</span>
<span className="truncate text-sm text-[color:var(--ink-soft)]">
{skill.summary ?? "No summary provided."}
</span>
<span className="text-sm">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix=""
link={false}
/>
</span>
<span className="flex flex-wrap justify-end gap-3 text-xs text-[color:var(--ink-soft)]">
<SkillMetricsRow stats={skill.stats} />
</span>
</Link>
skill={skill}
ownerHandle={ownerHandle}
owner={entry.owner}
/>
);
})}
</div>
)}
{/* Load more */}
{(canLoadMore || isLoadingMore) && (
<div ref={canAutoLoad ? loadMoreRef : null} className="flex justify-center pt-4">
{canLoadMore || isLoadingMore ? (
<div
ref={canAutoLoad ? loadMoreRef : null}
className="card"
style={{ marginTop: 16, display: "flex", justifyContent: "center" }}
>
{canAutoLoad ? (
isLoadingMore ? (
<div className="flex items-center gap-2 text-sm font-medium text-[color:var(--ink-soft)]">
<Loader2 className="h-4 w-4 animate-spin" />
Loading more...
</div>
"Loading more..."
) : (
<div className="text-sm text-[color:var(--ink-soft)]">Scroll to load more</div>
"Scroll to load more"
)
) : (
<Button
variant="outline"
onClick={loadMore}
disabled={isLoadingMore}
loading={isLoadingMore}
>
Load more
</Button>
<button className="btn" type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</button>
)}
</div>
)}
) : null}
</>
);
}
+128 -64
View File
@@ -1,15 +1,23 @@
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 { SKILL_CAPABILITY_TAGS } from "../../../convex/lib/skillCapabilityTags";
import { Container } from "../../components/layout/Container";
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 SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
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 => {
@@ -27,10 +35,6 @@ export const Route = createFileRoute("/skills/")({
search.nonSuspicious === true
? true
: undefined,
tag:
typeof search.tag === "string" && SKILL_CAPABILITY_TAG_SET.has(search.tag)
? search.tag
: undefined,
view: search.view === "cards" || search.view === "list" ? search.view : undefined,
focus: search.focus === "search" ? "search" : undefined,
};
@@ -46,7 +50,6 @@ export const Route = createFileRoute("/skills/")({
dir: search.dir || undefined,
highlighted: search.highlighted || undefined,
nonSuspicious: search.nonSuspicious || undefined,
tag: search.tag || undefined,
view: search.view || undefined,
focus: search.focus || undefined,
},
@@ -62,7 +65,8 @@ export function SkillsIndex() {
const searchInputRef = useRef<HTMLInputElement>(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,
@@ -70,60 +74,120 @@ 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 (
<main className="py-10">
<Container size="wide">
<div className="flex flex-col gap-6">
{/* Header */}
<header>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
Skills
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
({model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly
? model.sorted.length.toLocaleString("en-US")
: totalSkillsText ?? "…"})
</span>
</h1>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
<main className="browse-page">
<div className="browse-page-header">
<h1 className="browse-title">
Skills
{totalSkillsText ? (
<span className="browse-count">{totalSkillsText}</span>
) : null}
</h1>
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
</div>
<div className="browse-page-search">
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
ref={searchInputRef}
className="browse-search-input"
value={model.query}
onChange={(event) => model.onQueryChange(event.target.value)}
placeholder="Search skills..."
/>
</div>
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
categories={SKILL_CATEGORIES}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
sortOptions={sortOptionsWithRelevance}
activeSort={model.sort}
onSortChange={model.onSortChange}
filters={[
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
]}
onFilterToggle={handleFilterToggle}
/>
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{model.isLoadingSkills
? "Loading skills..."
: `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(", ")})` : ""}.`}
</p>
</header>
{/* Toolbar */}
<SkillsToolbar
searchInputRef={searchInputRef}
query={model.query}
hasQuery={model.hasQuery}
sort={model.sort}
dir={model.dir}
view={model.view}
highlightedOnly={model.highlightedOnly}
nonSuspiciousOnly={model.nonSuspiciousOnly}
capabilityTag={model.capabilityTag}
onQueryChange={model.onQueryChange}
onToggleHighlighted={model.onToggleHighlighted}
onToggleNonSuspicious={model.onToggleNonSuspicious}
onCapabilityTagChange={model.onCapabilityTagChange}
onSortChange={model.onSortChange}
onToggleDir={model.onToggleDir}
onToggleView={model.onToggleView}
/>
{/* Results count */}
{model.sorted.length > 0 && (
<p className="text-xs font-medium text-[color:var(--ink-soft)]">
{model.sorted.length}
{!model.hasQuery && totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
{model.hasQuery ? ` matching "${model.query}"` : ""}
{model.highlightedOnly || model.nonSuspiciousOnly || model.capabilityTag
? ` (filtered)`
: ""}
</p>
)}
{/* Results */}
? "\u2014"
: `${model.sorted.length} results`}
{(model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly) ? (
<button
className="browse-clear-btn"
type="button"
onClick={() => {
model.onQueryChange("");
if (model.highlightedOnly) model.onToggleHighlighted();
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
}}
>
Clear
</button>
) : null}
</span>
<div className="browse-view-toggle">
<button
className={`browse-view-btn${model.view === "list" ? " is-active" : ""}`}
type="button"
onClick={model.view === "cards" ? model.onToggleView : undefined}
>
List
</button>
<button
className={`browse-view-btn${model.view === "cards" ? " is-active" : ""}`}
type="button"
onClick={model.view === "list" ? model.onToggleView : undefined}
>
Cards
</button>
</div>
</div>
<SkillsResults
isLoadingSkills={model.isLoadingSkills}
sorted={model.sorted}
@@ -137,7 +201,7 @@ export function SkillsIndex() {
loadMore={model.loadMore}
/>
</div>
</Container>
</div>
</main>
);
}
+46 -231
View File
@@ -1,32 +1,13 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
import { SoulCard } from "../../components/SoulCard";
import { SoulMetricsRow, SoulStatsTripletLine } from "../../components/SoulStats";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import type { PublicSoul } from "../../lib/publicUser";
const sortKeys = ["newest", "downloads", "stars", "name", "updated"] as const;
type SortKey = (typeof sortKeys)[number];
type SortDir = "asc" | "desc";
function parseSort(value: unknown): SortKey {
if (typeof value !== "string") return "newest";
if ((sortKeys as readonly string[]).includes(value)) return value as SortKey;
return "newest";
}
function parseDir(value: unknown, sort: SortKey): SortDir {
if (value === "asc" || value === "desc") return value;
return sort === "name" ? "asc" : "desc";
}
export const Route = createFileRoute("/souls/")({
validateSearch: (search) => {
return {
@@ -37,223 +18,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<HTMLInputElement>(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 (
<main className="py-10">
<Container>
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Souls</h1>
<p className="text-sm text-[color:var(--ink-soft)]">
{isLoadingSouls
? "Loading souls..."
: `${showing}${typeof total === "number" ? ` of ${total}` : ""} souls.`}
</p>
<main className="section souls-coming-page">
<section className="souls-coming-hero">
<div>
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
<span className="tag">Souls</span>
<span className="tag tag-accent">Coming soon</span>
</div>
<div className="flex flex-col gap-3 sm:items-end">
<div className="relative flex items-center">
<Search
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
aria-hidden="true"
/>
<Input
ref={searchInputRef}
className="pl-9"
value={query}
onChange={(event) => {
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..."
/>
</div>
<div className="flex items-center gap-2">
<select
className="min-h-[34px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3 py-1.5 text-sm text-[color:var(--ink)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={sort}
onChange={(event) => {
const nextSort = parseSort(event.target.value);
void navigate({
search: (prev) => ({
...prev,
sort: nextSort,
dir: parseDir(prev.dir, nextSort),
}),
replace: true,
});
}}
aria-label="Sort souls"
>
<option value="newest">Newest</option>
<option value="updated">Recently updated</option>
<option value="downloads">Downloads</option>
<option value="stars">Stars</option>
<option value="name">Name</option>
</select>
<Button
variant="ghost"
size="sm"
aria-label={`Sort direction ${dir}`}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
dir: parseDir(prev.dir, sort) === "asc" ? "desc" : "asc",
}),
replace: true,
});
}}
>
{dir === "asc" ? "\u2191" : "\u2193"}
</Button>
<Button
variant={view === "cards" ? "primary" : "ghost"}
size="sm"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
view: prev.view === "cards" ? undefined : "cards",
}),
replace: true,
});
}}
>
{view === "cards" ? "List" : "Cards"}
</Button>
</div>
</div>
</header>
<div className="mt-6">
{isLoadingSouls ? (
<SkillCardSkeletonGrid count={6} />
) : showing === 0 ? (
<EmptyState
title="No souls match that filter"
description="Try a different search term."
/>
) : view === "cards" ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{sorted.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<div className="text-sm text-[color:var(--ink-soft)]">
<SoulStatsTripletLine stats={soul.stats} />
</div>
}
/>
))}
</div>
) : (
<div className="flex flex-col gap-2">
{sorted.map((soul) => (
<Link
key={soul._id}
className="flex items-center justify-between gap-4 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 transition-all duration-200 hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
to="/souls/$slug"
params={{ slug: soul.slug }}
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-display font-bold text-[color:var(--ink)]">
{soul.displayName}
</span>
<span className="text-sm text-[color:var(--ink-soft)]">/{soul.slug}</span>
</div>
<div className="text-sm text-[color:var(--ink-soft)]">
{soul.summary ?? "SOUL.md bundle."}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<SoulMetricsRow stats={soul.stats} />
</div>
</Link>
))}
</div>
)}
<h1 className="about-title">SOUL.md discovery is on deck</h1>
<p className="about-lead">
This page is the holding area for public SOUL.md profiles youll be able to discover,
compare, and share. Were not shipping a half-baked directory just to tick a box.
</p>
</div>
</Container>
<div className="souls-coming-grid">
<article className="about-rule-card">
<h2>Discover</h2>
<p>Browse public system personas, writing voices, and full character sheets.</p>
</article>
<article className="about-rule-card">
<h2>Share</h2>
<p>Publish versioned SOUL.md files with attribution and clean history.</p>
</article>
<article className="about-rule-card">
<h2>Compare</h2>
<p>Inspect changes, stats, and adoption without the usual metadata sludge.</p>
</article>
</div>
</section>
<section className="about-enforcement">
<div>
<span className="about-callout-label">In the meantime</span>
<p className="about-lead" style={{ marginBottom: 0 }}>
ClawHub already handles skills and plugins. Souls will get the same discovery treatment
once the publishing flow is ready.
</p>
</div>
<div className="skill-card-tags">
<Link className="btn btn-primary" to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
Browse Skills
</Link>
<Link className="btn" to="/users" search={{ q: undefined }}>
Browse Users
</Link>
</div>
</section>
</main>
);
}
+145 -221
View File
@@ -1,23 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { Star, User } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
import { SkillCard } from "../../components/SkillCard";
import { SkillStatsTripletLine } from "../../components/SkillStats";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Skeleton } from "../../components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
import { getSkillBadges } from "../../lib/badges";
import { SkillListItem } from "../../components/SkillListItem";
import type { PublicSkill, PublicUser } from "../../lib/publicUser";
export const Route = createFileRoute("/u/$handle")({
@@ -51,28 +37,18 @@ function UserProfile() {
if (user === undefined) {
return (
<main className="py-10">
<Container size="narrow">
<div className="flex flex-col items-center gap-4">
<Skeleton className="h-20 w-20 rounded-full" />
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-24" />
</div>
</Container>
<main className="section">
<div className="card">
<div className="loading-indicator">Loading user</div>
</div>
</main>
);
}
if (user === null) {
return (
<main className="py-10">
<Container size="narrow">
<EmptyState
icon={User}
title="User not found"
description="This user doesn't exist or their account has been removed."
/>
</Container>
<main className="section">
<div className="card">User not found.</div>
</main>
);
}
@@ -87,134 +63,93 @@ function UserProfile() {
const published = publishedSkills ?? [];
return (
<main className="py-10">
<Container size="narrow">
<div className="flex flex-col gap-8">
{/* Profile header */}
<Card className="flex-row items-center gap-4 p-6">
<Avatar className="h-16 w-16">
{avatar && <AvatarImage src={avatar} alt={displayName} />}
<AvatarFallback className="text-xl">{initial}</AvatarFallback>
</Avatar>
<div>
<h1 className="font-display text-xl font-bold text-[color:var(--ink)]">
{displayName}
</h1>
<p className="font-mono text-sm text-[color:var(--ink-soft)]">@{displayHandle}</p>
</div>
</Card>
{/* Tabs */}
{isSelf ? (
<Tabs value={tab} onValueChange={(v) => setTab(v as "stars" | "installed")}>
<TabsList>
<TabsTrigger value="stars">Stars</TabsTrigger>
<TabsTrigger value="installed">Installed</TabsTrigger>
</TabsList>
<TabsContent value="stars" className="mt-6">
<PublishedAndStarred
published={published}
isLoadingPublished={isLoadingPublished}
skills={skills}
isLoadingSkills={isLoadingSkills}
/>
</TabsContent>
<TabsContent value="installed" className="mt-6">
<InstalledSection
includeRemoved={includeRemoved}
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
data={installed}
/>
</TabsContent>
</Tabs>
) : (
<PublishedAndStarred
published={published}
isLoadingPublished={isLoadingPublished}
skills={skills}
isLoadingSkills={isLoadingSkills}
/>
)}
<main className="browse-page">
<div className="profile-header">
<div className="profile-avatar-lg" aria-hidden="true">
{avatar ? <img src={avatar} alt="" /> : <span>{initial}</span>}
</div>
</Container>
<div className="profile-info">
<h1 className="profile-display-name">{displayName}</h1>
<span className="profile-handle">@{displayHandle}</span>
<div className="profile-stats-row">
<span className="profile-stat">{published.length} published</span>
<span className="profile-stat">{skills.length} stars</span>
</div>
</div>
</div>
{isSelf ? (
<div className="profile-tabs" role="tablist" aria-label="Profile tabs">
<button
className={tab === "stars" ? "profile-tab is-active" : "profile-tab"}
type="button"
role="tab"
aria-selected={tab === "stars"}
onClick={() => setTab("stars")}
>
Overview
</button>
<button
className={tab === "installed" ? "profile-tab is-active" : "profile-tab"}
type="button"
role="tab"
aria-selected={tab === "installed"}
onClick={() => setTab("installed")}
>
Installed
</button>
</div>
) : null}
{tab === "installed" && isSelf ? (
<InstalledSection
includeRemoved={includeRemoved}
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
data={installed}
/>
) : (
<>
{published.length > 0 ? (
<>
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
Published ({published.length})
</h2>
{isLoadingPublished ? (
<div className="card">
<div className="loading-indicator">Loading published skills...</div>
</div>
) : (
<div className="results-list" style={{ marginBottom: 24 }}>
{published.map((skill) => (
<SkillListItem key={skill._id} skill={skill} />
))}
</div>
)}
</>
) : null}
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
Stars ({skills.length})
</h2>
{isLoadingSkills ? (
<div className="card">
<div className="loading-indicator">Loading stars...</div>
</div>
) : skills.length === 0 ? (
<div className="card">No stars yet.</div>
) : (
<div className="results-list">
{skills.map((skill) => (
<SkillListItem key={skill._id} skill={skill} />
))}
</div>
)}
</>
)}
</main>
);
}
function PublishedAndStarred({
published,
isLoadingPublished,
skills,
isLoadingSkills,
}: {
published: PublicSkill[];
isLoadingPublished: boolean;
skills: PublicSkill[];
isLoadingSkills: boolean;
}) {
return (
<div className="flex flex-col gap-8">
{/* Published */}
<section>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Published</h2>
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
Skills published by this user.
</p>
{isLoadingPublished ? (
<SkillCardSkeletonGrid count={3} />
) : published.length > 0 ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{published.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge={getSkillBadges(skill)}
summaryFallback="Agent-ready skill pack."
meta={
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={skill.stats} />
</span>
}
/>
))}
</div>
) : (
<p className="text-sm text-[color:var(--ink-soft)]">No published skills yet.</p>
)}
</section>
{/* Stars */}
<section>
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Stars</h2>
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
Skills this user has starred.
</p>
{isLoadingSkills ? (
<SkillCardSkeletonGrid count={3} />
) : skills.length === 0 ? (
<EmptyState icon={Star} title="No stars yet" />
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
{skills.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge={getSkillBadges(skill)}
summaryFallback="Agent-ready skill pack."
meta={
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
<SkillStatsTripletLine stats={skill.stats} />
</span>
}
/>
))}
</div>
)}
</section>
</div>
);
}
function InstalledSection(props: {
includeRemoved: boolean;
onToggleRemoved: () => void;
@@ -223,119 +158,108 @@ function InstalledSection(props: {
const clearTelemetry = useMutation(api.telemetry.clearMyTelemetry);
const [showRaw, setShowRaw] = useState(false);
const data = props.data;
if (data === undefined) {
return (
<div className="flex flex-col gap-4">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
<SkillCardSkeletonGrid count={3} />
</div>
<>
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
Installed
</h2>
<div className="card">
<div className="loading-indicator">Loading telemetry</div>
</div>
</>
);
}
if (data === null) {
return (
<div className="flex flex-col gap-4">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
<EmptyState title="Sign in to view your installed skills">
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
</EmptyState>
</div>
<>
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
Installed
</h2>
<div className="card">Sign in to view your installed skills.</div>
</>
);
}
return (
<div className="flex flex-col gap-4">
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
<p className="max-w-2xl text-sm text-[color:var(--ink-soft)]">
<>
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
Installed
</h2>
<p className="section-subtitle" style={{ maxWidth: 760 }}>
Private view. Only you can see your folders/roots. Everyone else only sees aggregated
install counts per skill.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={props.onToggleRemoved}>
<div className="profile-actions">
<button className="btn" type="button" onClick={props.onToggleRemoved}>
{props.includeRemoved ? "Hide removed" : "Show removed"}
</Button>
<Button variant="outline" size="sm" onClick={() => setShowRaw((value) => !value)}>
</button>
<button className="btn" type="button" onClick={() => setShowRaw((value) => !value)}>
{showRaw ? "Hide JSON" : "Show JSON"}
</Button>
<Button
variant="destructive"
size="sm"
</button>
<button
className="btn"
type="button"
onClick={() => {
toast("Delete all telemetry data?", {
action: {
label: "Delete",
onClick: () => void clearTelemetry(),
},
});
if (!window.confirm("Delete all telemetry data?")) return;
void clearTelemetry();
}}
>
Delete telemetry
</Button>
</button>
</div>
{showRaw ? (
<Card className="mb-4">
<pre className="font-mono text-xs whitespace-pre-wrap break-all">
<div className="card telemetry-json" style={{ marginBottom: 18 }}>
<pre className="mono" style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{JSON.stringify(data, null, 2)}
</pre>
</Card>
</div>
) : null}
{data.roots.length === 0 ? (
<EmptyState
title="No telemetry yet"
description='Run "clawhub sync" from the CLI to start tracking.'
/>
<div className="card">No telemetry yet. Run `clawhub sync` from the CLI.</div>
) : (
<div className="grid gap-4">
<div style={{ display: "grid", gap: 16 }}>
{data.roots.map((root) => (
<Card key={root.rootId}>
<div className="flex items-center justify-between">
<div key={root.rootId} className="card telemetry-root">
<div className="telemetry-root-header">
<div>
<h3 className="font-display text-sm font-bold text-[color:var(--ink)]">
{root.label}
</h3>
<p className="text-xs text-[color:var(--ink-soft)]">
<div className="telemetry-root-title">{root.label}</div>
<div className="telemetry-root-meta">
Last sync {new Date(root.lastSeenAt).toLocaleString()}
{root.expiredAt ? " · stale" : ""}
</p>
</div>
</div>
<Badge variant="default">{root.skills.length} skills</Badge>
<div className="tag">{root.skills.length} skills</div>
</div>
{root.skills.length === 0 ? (
<p className="text-sm text-[color:var(--ink-soft)]">
No skills found in this root.
</p>
<div className="stat">No skills found in this root.</div>
) : (
<div className="flex flex-col gap-1">
<div className="telemetry-skill-list">
{root.skills.map((entry) => (
<a
key={`${root.rootId}:${entry.skill.slug}`}
className="flex items-center justify-between rounded-[var(--radius-sm)] px-3 py-2 text-sm no-underline transition-colors hover:bg-[color:var(--surface-muted)]"
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
>
<span className="flex items-center gap-2">
<span className="font-semibold text-[color:var(--ink)]">
{entry.skill.displayName}
</span>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
/{entry.skill.slug}
</span>
</span>
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
<a
className="telemetry-skill-link"
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
>
<span>{entry.skill.displayName}</span>
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
</a>
<div className="telemetry-skill-meta mono">
{entry.lastVersion ? `v${entry.lastVersion}` : "v?"}{" "}
{entry.removedAt ? "· removed" : ""}
</span>
</a>
</div>
</div>
))}
</div>
)}
</Card>
</div>
))}
</div>
)}
</div>
</>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { createFileRoute } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { UserListItem } from "../../components/UserListItem";
import { convexHttp } from "../../convex/client";
import type { PublicUser } from "../../lib/publicUser";
type UserSearchState = {
q?: string;
};
type UsersLoaderResult = { items: PublicUser[]; total: number };
export const Route = createFileRoute("/users/")({
validateSearch: (search): UserSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
}),
component: UsersIndex,
});
function UsersIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const [query, setQuery] = useState(search.q ?? "");
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
const [loading, setLoading] = useState(true);
const fetchUsers = useCallback(async (q?: string) => {
setLoading(true);
try {
const data = await convexHttp.query(api.users.listPublic, {
limit: 48,
search: q,
});
setResult(data as UsersLoaderResult);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setQuery(search.q ?? "");
void fetchUsers(search.q);
}, [search.q, fetchUsers]);
const users = result?.items ?? [];
return (
<main className="browse-page">
<div className="browse-page-header">
<h1 className="browse-title">
Users
{typeof result?.total === "number" ? (
<span className="browse-count">{result.total}</span>
) : null}
</h1>
</div>
<form
className="browse-page-search"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: {
q: query.trim() || undefined,
},
});
}}
>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
placeholder="Search users..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{loading ? "Loading users..." : `${users.length} users`}
</span>
</div>
{loading ? (
<div className="card">
<div className="loading-indicator">Loading users...</div>
</div>
) : users.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No users found</p>
<p className="empty-state-body">Try a different handle or name.</p>
</div>
) : (
<div className="results-list">
{users.map((user) => (
<UserListItem key={user._id} user={user} />
))}
</div>
)}
</div>
</main>
);
}
+5535 -237
View File
File diff suppressed because it is too large Load Diff