Compare commits

...
Author SHA1 Message Date
Val Alexander d2c856e68a fix: allow publisher id skill routes 2026-04-07 07:41:38 -05:00
Val Alexander 47822918c5 test: satisfy oxlint constructor rule 2026-04-07 07:39:25 -05:00
Val Alexander 74b834b4dd fix: resolve PR #1570 review follow-ups 2026-04-07 07:30:04 -05:00
Val AlexanderandClaude Opus 4.6 77abd47e16 fix(tests): add ConvexReactClient to convex/react mocks
All 6 test files that mock convex/react now include a stub
ConvexReactClient class so src/convex/client.ts can import
without crashing at module load time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 07:05:37 -05:00
Val AlexanderandClaude Opus 4.6 1f7de02144 fix(ci): provide dummy VITE_CONVEX_URL for test and coverage steps
Tests that import src/convex/client.ts crash at module load time
because getRequiredRuntimeEnv("VITE_CONVEX_URL") throws when the
env var is missing. Set a non-resolvable dummy URL for CI since
unit tests don't make real Convex calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 07:00:24 -05:00
Val AlexanderandClaude Opus 4.6 c8a515a785 feat: add subtle icons to Skills, Plugins, Souls navbar tabs
Each primary nav tab gets a monochrome lucide icon (Wrench, Plug, Ghost)
at 50% opacity for scanability. Icons brighten to 85% on hover and 100%
when active. Secondary tabs unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 06:51:15 -05:00
Val AlexanderandClaude Opus 4.6 cbacfb4812 fix: align plugin detail page width with skills page
Replace <Container> wrapper with .section class so plugin detail uses
the same max-width token (--page-max) and padding as skill detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 05:51:13 -05:00
Val AlexanderandClaude Opus 4.6 84a48f28f9 fix: avoid CSS named color 'gray' causing Tailwind v4 token error
The CSS minifier converts #808080 to the named color 'gray', which
Tailwind v4 rejects as an illegal theme token value. Use #818181
instead — visually identical but not a named CSS color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 05:30:50 -05:00
Val AlexanderandClaude Opus 4.6 ceebcc5bca Update branding to lobster logo with white rounded-corner backgrounds
Replace all logo/favicon assets with the new lobster logo. All variants
use a solid white background with rounded corners (12% radius).

Updated assets: clawd-logo.png (512), clawd-mark.png (256),
logo192.png, logo512.png, favicon.ico (multi-res), logo.svg.
Added explicit favicon/icon/manifest link tags to __root.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:43:05 -05:00
Val AlexanderandClaude Opus 4.6 4d880acccc fix: address PR review — button height, one-shot fetch, query cap
- Sign-in button: add size="sm" (min-h 34px) to align with the 38px
  theme toggle group in the navbar
- UserBadge tooltip: replace reactive useQuery with one-shot
  ConvexHttpClient.query() so hover stats don't leave persistent
  subscriptions on the skills table
- getHoverStats: replace unbounded .collect() with .take(500) to cap
  per-transaction reads for prolific users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:06:23 -05:00
Val AlexanderandClaude Opus 4.6 5e7e4e83bd Design system overhaul: unified tokens, component authority, standardized layout
Establish a single design token system and make React UI components the
canonical styling authority, replacing the dual CSS-class/inline-style
approach. No product behavior changes.

## Phase 1 — Design Foundation

- Add 25+ semantic CSS custom properties to :root (status colors, form
  controls, interactive states, overlay, shadows, layout widths) with
  matching light-theme overrides
- Expand @theme block from 24 → 80+ entries mapping all tokens to
  Tailwind v4 utilities (spacing, typography, status, form, layout)
- Migrate 11 ui/ primitives to use tokens instead of hardcoded rgba/hex:
  input, textarea, label, select, badge, button, card, dialog, sheet,
  dropdown-menu, toggle-group
- Introduce --accent-fg token for guaranteed text contrast on accent
  backgrounds across both themes
- Add --page-max (1536px), --page-narrow (900px), --page-min (320px)
  layout tokens; standardize all page/nav/footer max-widths
- Deprecate CSS classes (.btn, .tag, .card, .form-input, .form-label)
  with @deprecated comments — React components are now canonical

## Phase 2 — IA & Navigation

- Extract shared nav config (src/lib/nav-items.ts) consumed by Header
  and Footer, eliminating 3-place navigation duplication
- Add beforeLoad guard on /$owner/$slug catch-all route to validate
  owner handles and prevent confusing 404s on typos
- Add UserBadge hover tooltip with lazy-fetched stats (published skills,
  stars, downloads) via new getHoverStats Convex query
- Wrap app in TooltipProvider (src/components/AppProviders.tsx)

## Phase 3 — UI Sweep

- Migrate 54 .btn usages → <Button> across 13 route/component files
- Migrate 24 .tag usages → <Badge> across 12 files
- Eliminate ~89 inline style={{}} occurrences → Tailwind utilities
- Replace .card divs → <Card> component in detail/dashboard/search pages
- Fix text-white → text-accent-fg in 5 locations (SkillsToolbar,
  InstallSwitcher, toggle-group, .star-toggle, .btn-primary)
- Standardize all page max-widths to var(--page-max) token (was 6
  different values: 900–1450px)
- Add min-width: var(--page-min) to .app-shell
- Convert raw styled <a>/<button> elements to <Button asChild> pattern

## New files

- src/lib/nav-items.ts — shared navigation config
- src/lib/icon-sizes.ts — ICON_SM/MD/LG constants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:57:19 -05:00
Val Alexander b255b5865f Merge pull request #1567 from openclaw/okcode/conflict-resolution-plan
feat: marketplace UI overhaul with main security fixes
2026-04-07 02:07:33 -05:00
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
Val Alexander 9df6fe37c5 Merge pull request #1564 from openclaw/okcodes/fix-lightmode
Add sortable paginated data table components
2026-04-06 20:37:44 -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
83 changed files with 10542 additions and 4098 deletions
+4
View File
@@ -27,9 +27,13 @@ jobs:
- name: Test
run: bun run test
env:
VITE_CONVEX_URL: https://example.invalid
- name: Coverage
run: bun run coverage
env:
VITE_CONVEX_URL: https://example.invalid
- name: ClawHub CLI Verify
run: bun run --cwd packages/clawhub verify
+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 };
},
});
+79 -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);
}
@@ -442,6 +467,41 @@ export const getByHandle = query({
},
});
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
export const getHoverStats = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const skills = [];
let cursor: string | null = null;
for (;;) {
const page = await ctx.db
.query("skills")
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.userId))
.paginate({ cursor, numItems: 100 });
skills.push(...page.page);
if (page.isDone) {
break;
}
cursor = page.continueCursor;
}
const active = skills.filter((s) => !s.softDeletedAt);
let totalStars = 0;
let totalDownloads = 0;
for (const s of active) {
totalStars += s.stats?.stars ?? 0;
totalDownloads += s.stats?.downloads ?? 0;
}
return {
publishedSkills: active.length,
totalStars,
totalDownloads,
};
},
});
export const getReservedHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
@@ -860,8 +920,7 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 704 B

+45
View File
@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect width="512" height="512" rx="0" fill="none"/>
<!-- Body segments -->
<g fill="#F03020" stroke="#fff" stroke-width="6" stroke-linejoin="round">
<!-- Tail fan - center -->
<ellipse cx="256" cy="460" rx="42" ry="30"/>
<!-- Tail fan - left -->
<ellipse cx="210" cy="455" rx="32" ry="24" transform="rotate(25 210 455)"/>
<!-- Tail fan - right -->
<ellipse cx="302" cy="455" rx="32" ry="24" transform="rotate(-25 302 455)"/>
<!-- Lower body segment -->
<rect x="208" y="380" width="96" height="55" rx="12"/>
<!-- Mid-lower body -->
<rect x="204" y="335" width="104" height="55" rx="12"/>
<!-- Mid body -->
<rect x="200" y="290" width="112" height="55" rx="14"/>
<!-- Upper body / thorax -->
<ellipse cx="256" cy="255" rx="72" ry="65"/>
<!-- Head -->
<circle cx="256" cy="195" r="58"/>
<!-- Left claw arm -->
<path d="M195 230 Q150 190 120 170 Q100 155 95 140" stroke-width="18" fill="none" stroke-linecap="round"/>
<!-- Right claw arm -->
<path d="M317 230 Q362 190 392 170 Q412 155 417 140" stroke-width="18" fill="none" stroke-linecap="round"/>
<!-- Left claw -->
<ellipse cx="88" cy="115" rx="52" ry="42" transform="rotate(-20 88 115)"/>
<!-- Right claw -->
<ellipse cx="424" cy="115" rx="52" ry="42" transform="rotate(20 424 115)"/>
<!-- Left claw notch -->
<path d="M65 95 Q78 80 72 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
<!-- Right claw notch -->
<path d="M447 95 Q434 80 440 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
<!-- Left legs -->
<path d="M205 290 Q175 295 155 305" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M202 320 Q172 330 152 340" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M205 350 Q178 358 158 370" stroke-width="10" fill="none" stroke-linecap="round"/>
<!-- Right legs -->
<path d="M307 290 Q337 295 357 305" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M310 320 Q340 330 360 340" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M307 350 Q334 358 354 370" stroke-width="10" fill="none" stroke-linecap="round"/>
</g>
<!-- Eyes -->
<circle cx="236" cy="185" r="10" fill="#000"/>
<circle cx="276" cy="185" r="10" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 169 KiB

+45 -7
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", () => ({
@@ -16,12 +20,14 @@ vi.mock("@convex-dev/auth/react", () => ({
}),
}));
const authStatusMock = vi.fn(() => ({
isAuthenticated: false,
isLoading: false,
me: null,
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => ({
isAuthenticated: false,
isLoading: false,
me: null,
}),
useAuthStatus: () => authStatusMock(),
}));
vi.mock("../lib/theme", () => ({
@@ -56,7 +62,7 @@ vi.mock("../lib/roles", () => ({
vi.mock("../lib/site", () => ({
getClawHubSiteUrl: () => "https://clawhub.ai",
getSiteMode: () => "souls",
getSiteMode: () => siteModeMock(),
getSiteName: () => "OnlyCrabs",
}));
@@ -68,6 +74,24 @@ vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
vi.mock("../convex/client", () => ({
convexHttp: {
query: convexQueryMock,
},
}));
vi.mock("../../convex/_generated/api", () => ({
api: {
skills: {
countPublicSkills: "countPublicSkills",
},
},
}));
vi.mock("../lib/numberFormat", () => ({
formatCompactStat: (n: number) => String(n),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@@ -85,8 +109,22 @@ vi.mock("../components/ui/toggle-group", () => ({
describe("Header", () => {
it("hides Packages navigation in soul mode on mobile and desktop", () => {
siteModeMock.mockReturnValue("souls");
render(<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();
});});
+1
View File
@@ -17,6 +17,7 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useAction: () => {
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3];
@@ -26,6 +26,7 @@ const useAuthStatusMock = vi.fn();
const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useMutation: () => generateUploadUrl,
useAction: () => publishRelease,
useQuery: () => undefined,
+47 -72
View File
@@ -1,99 +1,74 @@
import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
__config: config,
}),
redirect: (options: unknown) => ({ redirect: options }),
Link: "a",
useNavigate: () => vi.fn(),
}));
import { Route } from "../routes/search";
function runBeforeLoad(
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
hostname = "clawdhub.com",
) {
function runValidateSearch(search: Record<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();
});
});
+53 -194
View File
@@ -6,6 +6,18 @@ import { SkillDetailPage } from "../components/SkillDetailPage";
const navigateMock = vi.fn();
const useAuthStatusMock = vi.fn();
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../components/UserBadge", () => ({
UserBadge: () => null,
}));
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: unknown }) => children,
useNavigate: () => navigateMock,
@@ -15,6 +27,7 @@ const useQueryMock = vi.fn();
const getReadmeMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
useAction: () => getReadmeMock,
@@ -24,8 +37,8 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
vi.mock("../components/SkillCommentsPanel", () => ({
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
}));
describe("SkillDetailPage", () => {
@@ -58,11 +71,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 +145,11 @@ describe("SkillDetailPage", () => {
/>,
);
// With initialData, should render content instead of skeleton
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy();
});
it("shows capability tags on the skill page without other scan findings", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return undefined;
});
render(
<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 +222,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
expect(getReadmeMock).not.toHaveBeenCalled();
});
@@ -394,17 +241,17 @@ describe("SkillDetailPage", () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: {
_id: "publishers:steipete",
_creationTime: 0,
@@ -417,9 +264,8 @@ describe("SkillDetailPage", () => {
};
});
const { container } = render(<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 +370,7 @@ describe("SkillDetailPage", () => {
/>,
);
expect(screen.queryByText(/Skill not found/i)).toBeNull();
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
expect(navigateMock).not.toHaveBeenCalled();
});
@@ -634,10 +480,24 @@ describe("SkillDetailPage", () => {
it("defers compare version query until compare tab is requested", async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (
args &&
typeof args === "object" &&
"skillId" in args &&
"limit" in args &&
(args as { limit: number }).limit === 50
) {
return [
{ _id: "skillVersions:1", version: "1.0.0", files: [] },
{ _id: "skillVersions:2", version: "1.1.0", files: [] },
];
}
if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
if ((args as { limit: number }).limit === 200) return [];
}
if (args && typeof args === "object" && "limit" in args) {
return [];
}
if (args && typeof args === "object" && "skillId" in args) return [];
if (args && typeof args === "object" && "slug" in args) {
return {
skill: {
@@ -666,6 +526,7 @@ describe("SkillDetailPage", () => {
render(<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 +540,7 @@ describe("SkillDetailPage", () => {
}),
).toBe(false);
const compareTab = screen.getByRole("tab", { name: /compare/i });
fireEvent.mouseEnter(compareTab);
fireEvent.click(compareTab);
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
await waitFor(() => {
expect(
+22
View File
@@ -17,6 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
component?: unknown;
head?: unknown;
@@ -31,6 +32,7 @@ vi.mock("../lib/skillPage", () => ({
async function loadRoute() {
return (await import("../routes/$owner/$slug")).Route as unknown as {
__config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
head?: (args: {
params: { owner: string; slug: string };
@@ -45,6 +47,14 @@ async function loadRoute() {
};
}
async function runBeforeLoad(params: { owner: string; slug: string }) {
const route = await loadRoute();
const beforeLoad = route.__config.beforeLoad as ((args: {
params: { owner: string; slug: string };
}) => unknown) | undefined;
return beforeLoad?.({ params });
}
async function runLoader(params: { owner: string; slug: string }) {
const route = await loadRoute();
const loader = route.__config.loader as (args: {
@@ -71,6 +81,18 @@ function runHead(
}
describe("skill route loader", () => {
it("allows numeric owner handles in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
});
it("allows raw owner ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
});
it("allows raw publisher ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
});
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
});
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
+16 -48
View File
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
@@ -66,7 +67,7 @@ describe("SkillsIndex", () => {
it("renders an empty state when no skills are returned", async () => {
render(<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 +75,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 +92,8 @@ describe("SkillsIndex", () => {
});
// Should show empty state, not loading
expect(screen.getByText("No skills match that filter")).toBeTruthy();
expect(screen.queryByText("Loading skills...")).toBeNull();
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.queryByText(/Loading skills/)).toBeNull();
});
it("skips list fetch and calls search when query is set", async () => {
@@ -136,7 +137,7 @@ describe("SkillsIndex", () => {
render(<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 +162,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 +252,12 @@ describe("SkillsIndex", () => {
await vi.runAllTimersAsync();
});
const links = screen.getAllByRole("link");
expect(links[0]?.textContent).toContain("Older High Score");
expect(links[1]?.textContent).toContain("Newer Low Score");
const titles = Array.from(
document.querySelectorAll(".skill-list-item-name"),
).map((node) => node.textContent);
expect(titles[0]).toBe("Older High Score");
expect(titles[1]).toBe("Newer Low Score");
});
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
@@ -288,42 +292,6 @@ describe("SkillsIndex", () => {
);
});
it("passes capabilityTag to list query when tag filter is active", async () => {
searchMock = { tag: "crypto" };
render(<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 +324,7 @@ describe("SkillsIndex", () => {
fireEvent.click(loadMoreButton);
});
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByText(/Loading/)).toBeTruthy();
});
});
+1
View File
@@ -22,6 +22,7 @@ const useAuthStatusMock = vi.fn();
let useActionCallCount = 0;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => generateUploadUrl,
useAction: () => {
+7 -4
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
import { convex } from "../convex/client";
import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { TooltipProvider } from "./ui/tooltip";
import { UserBootstrap } from "./UserBootstrap";
function getPendingAuthCode() {
@@ -82,10 +83,12 @@ export function AuthErrorHandler() {
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</TooltipProvider>
</ConvexAuthProvider>
);
}
+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>
);
}
+1 -11
View File
@@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() {
return (
<div
role="alert"
style={{
margin: "16px auto 0",
width: "min(1100px, calc(100vw - 32px))",
border: "1px solid #f59e0b",
background: "#fff7ed",
color: "#9a3412",
borderRadius: "14px",
padding: "12px 16px",
fontSize: "0.95rem",
lineHeight: 1.4,
}}
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
>
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
but Convex reports <code>{drift.actualBuildSha}</code>.
+41 -24
View File
@@ -1,34 +1,51 @@
import { Link } from "@tanstack/react-router";
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
import { getSiteName } from "../lib/site";
import { Separator } from "./ui/separator";
export function Footer() {
const siteName = getSiteName();
return (
<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">
{FOOTER_NAV_SECTIONS.map((section) => (
<div key={section.title} className="footer-col">
<h4 className="footer-col-title">{section.title}</h4>
{section.items.map((item) => {
if (item.kind === "link") {
return (
<Link key={item.label} to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
);
}
if (item.kind === "external") {
return (
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
{item.label}
</a>
);
}
// kind === "text"
return <span key={item.label}>{item.label}</span>;
})}
</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>
);
}
+257 -270
View File
@@ -1,16 +1,21 @@
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 { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
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,
@@ -19,24 +24,41 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, React.ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
plug: Plug,
ghost: Ghost,
};
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 hasResolvedUser = Boolean(me);
const navCtx = useMemo(
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
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 +73,242 @@ 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}
{primaryItems.map((item) => (
<DropdownMenuItem key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
</DropdownMenuItem>
))}
{secondaryItems.map((item) => (
<DropdownMenuItem key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}}>
{item.label}
</Link>
</DropdownMenuItem>
))}
<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 mr-2 text-[0.85rem]" role="alert">
{authError}{" "}
<button
type="button"
onClick={clearAuthError}
aria-label="Dismiss"
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
>
&times;
</button>
</div>
) : null}
<Button
variant="primary"
size="sm"
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}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{secondaryItems.map((item) => (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
))}
</div>
</nav>
</div>
</header>
);
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
type="button"
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
pm === entry.id
? "bg-[color:var(--accent)] text-white shadow-sm"
? "bg-accent text-accent-fg shadow-sm"
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
role="tab"
+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>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
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>
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : 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>
);
}
+11 -28
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import type { ReactNode } from "react";
import type { PublicSkill } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import type { PublicSkill } from "../lib/publicUser";
type SkillCardProps = {
skill: PublicSkill;
@@ -12,7 +12,6 @@ type SkillCardProps = {
summaryFallback: string;
meta: ReactNode;
href?: string;
verified?: boolean;
};
export function SkillCard({
@@ -23,7 +22,6 @@ export function SkillCard({
summaryFallback,
meta,
href,
verified,
}: SkillCardProps) {
const owner = encodeURIComponent(String(skill.ownerUserId));
const link = href ?? `/${owner}/${skill.slug}`;
@@ -31,43 +29,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">
<Badge key={label}>
{label}
</Badge>
))}
{chip ? (
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
{chip}
</Badge>
) : null}
{chip ? <Badge variant="accent">{chip}</Badge> : null}
{platformLabels?.map((label) => (
<Badge key={label} variant="compact">
{label}
</Badge>
))}
{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>
);
}
+151 -144
View File
@@ -2,19 +2,16 @@ 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 { Card } from "./ui/card";
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 +22,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 +33,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 +95,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 +212,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 +295,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 +317,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 +329,19 @@ export function SkillDetailPage({
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return <SkillDetailSkeleton />;
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
</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">
<Card>Skill not found.</Card>
</main>
);
}
@@ -356,128 +349,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 ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{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="section-title text-[1.2rem] m-0">
Comments
</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
</p>
</Card>
}
>
<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 px-[var(--space-4)] py-[var(--space-6)]">
<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 p-4">
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");
});
});
+97 -130
View File
@@ -13,12 +13,8 @@ import {
selectDefaultFilePath,
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";
import { ClientOnly } from "./ClientOnly";
type SkillDiffCardProps = {
skill: Doc<"skills">;
@@ -277,174 +273,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 text-[1.2rem] m-0">
Compare versions
</h2>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
<p className="section-subtitle m-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="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 text-[1.05rem] m-0">
Files
</h3>
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
<span className="section-subtitle m-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>
+156 -209
View File
@@ -1,21 +1,14 @@
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 +110,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 +126,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 +137,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 +147,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 +170,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 m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : 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 className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span className="text-ink-soft opacity-40">·</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 className="text-ink-soft opacity-40">·</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 className="text-ink-soft opacity-40">·</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) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : 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,18 +281,16 @@ export function SkillHeader({
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={onOpenReport}>
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
Report
</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"
>
Manage
</Link>
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
</div>
</div>
@@ -341,13 +298,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 +311,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 m-0">
No tags yet.
</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag} className="gap-1.5">
<Badge key={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}"`}
@@ -444,16 +390,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 +409,13 @@ export function SkillHeader({
</option>
))}
</select>
<Button type="submit">Update tag</Button>
<Button type="submit">
Update tag
</Button>
</form>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</Card>
</div>
</>
);
}
+163 -218
View File
@@ -1,12 +1,6 @@
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";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
type SkillInstallCardProps = {
clawdis: ClawdisSkillMetadata | undefined;
@@ -32,229 +26,180 @@ 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
key={`${dep.name}-${index}`}
className="text-sm text-[color:var(--ink-soft)]"
>
<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>
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-0">
Runtime requirements
</h3>
<div className="skill-panel-body">
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : 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 className="flex flex-col gap-1 mt-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-ink-soft text-[0.75rem]">
optional
</span>
) : env.required === true ? (
<span className="text-ink-accent text-[0.75rem]">
required
</span>
) : null}
{command ? (
<code className="mt-0.5 block font-mono text-xs">{command}</code>
{env.description ? (
<span className="text-ink-soft text-[0.8rem]">
{env.description}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
</div>
) : null}
{hasDependencies ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-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 className="text-ink-soft text-[0.85rem] ml-2">
{dep.type}
{dep.version ? ` ${dep.version}` : ""}
</span>
{dep.url ? (
<div className="text-[0.8rem] break-all">
<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>
</div>
) : null}
{hasInstallSpecs ? (
<div className="skill-panel">
<h3 className="section-title text-[1rem] m-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 className="text-ink-soft text-[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 text-[1rem] m-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" className="break-all">
{links.homepage}
</a>
</div>
) : null}
{links?.repository ? (
<div className="stat">
<strong>Repository</strong>
<a href={links.repository} target="_blank" rel="noopener noreferrer" className="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>
+53
View File
@@ -0,0 +1,53 @@
import { Link } from "@tanstack/react-router";
import { Package, Star } from "lucide-react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
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) => (
<Badge key={b} variant="compact">
{b}
</Badge>
))}
</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 { Badge } from "./ui/badge";
import { Button } from "./ui/button";
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>
<Button asChild variant="primary" className="w-full justify-center">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
Download zip
</a>
</Button>
</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]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</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>
);
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { Badge } from "./ui/badge";
type LlmAnalysisDimension = {
name: string;
@@ -388,9 +389,9 @@ export function SecurityScanResults({
<div className="scan-findings-title">Capability signals</div>
<div className="scan-capability-tags">
{visibleCapabilityTags.map((tag) => (
<span key={tag} className="tag scan-capability-tag">
<Badge key={tag} className="scan-capability-tag">
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
</span>
</Badge>
))}
</div>
<div className="scan-capability-note">
+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>
);
}
+88 -1
View File
@@ -1,5 +1,12 @@
import { Package, Star, Download } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { convexHttp } from "../convex/client";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type UserBadgeProps = {
user: PublicUser | PublicPublisher | null | undefined;
@@ -40,7 +47,14 @@ export function UserBadge({
displayName!.toLowerCase() !== handle!.toLowerCase();
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
return (
// Resolve userId for stats query — PublicUser has _id directly,
// PublicPublisher has linkedUserId
const userId =
user && hasOwnProperty(user, "kind")
? (user as PublicPublisher).linkedUserId ?? null
: user?._id ?? null;
const badge = (
<span className={`user-badge user-badge-${size}`}>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
<span className="user-avatar" aria-hidden="true">
@@ -67,4 +81,77 @@ export function UserBadge({
)}
</span>
);
if (!userId) return badge;
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<UserStatsTooltipContent userId={userId} displayName={displayName} handle={handle} />
</Tooltip>
);
}
type HoverStats = { publishedSkills: number; totalStars: number; totalDownloads: number };
function UserStatsTooltipContent({
userId,
displayName,
handle,
}: {
userId: string;
displayName: string | null;
handle: string | null;
}) {
const [stats, setStats] = useState<HoverStats | null>(null);
const [fetched, setFetched] = useState(false);
// One-shot fetch on mount (tooltip content only mounts when open)
useEffect(() => {
if (fetched) return;
setFetched(true);
void convexHttp
.query(api.users.getHoverStats, { userId: userId as Id<"users"> })
.then(setStats)
.catch(() => {});
}, [userId, fetched]);
return (
<TooltipContent
side="top"
className="min-w-[140px] p-0"
onPointerDownOutside={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-space-1 px-3 py-2">
{displayName && (
<span className="text-fs-sm font-semibold text-ink truncate max-w-[180px]">
{displayName}
</span>
)}
{handle && (
<span className="text-fs-xs text-ink-soft">@{handle}</span>
)}
</div>
<div className="border-t border-line flex items-center gap-space-3 px-3 py-2">
{stats === null ? (
<span className="text-fs-xs text-ink-soft">Loading...</span>
) : (
<>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Published skills">
<Package size={12} />
{formatCompactStat(stats.publishedSkills)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Stars received">
<Star size={12} />
{formatCompactStat(stats.totalStars)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Total downloads">
<Download size={12} />
{formatCompactStat(stats.totalDownloads)}
</span>
</>
)}
</div>
</TooltipContent>
);
}
+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>
);
}
+3 -3
View File
@@ -11,9 +11,9 @@ const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-[1200px]",
size === "narrow" && "max-w-[900px]",
size === "wide" && "max-w-[1400px]",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "max-w-page-max",
className,
)}
{...props}
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-[1200px] px-7 py-10">
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<Skeleton className="h-8 w-52" />
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
export function SkillDetailSkeleton() {
return (
<div className="mx-auto max-w-[1200px] px-7 py-10">
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
+10 -31
View File
@@ -10,37 +10,16 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
<span
ref={ref}
className={cn(
// Base styles matching .tag
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-[0.8rem] font-semibold",
// Variant styles
variant === "default" && [
"bg-[rgba(43,198,164,0.16)] px-3 py-1 text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "accent" && [
"bg-[rgba(255,107,74,0.16)] px-3 py-1 text-[color:var(--accent-deep)]",
"dark:bg-[rgba(232,106,71,0.24)] dark:text-[#ffd0bf]",
],
variant === "compact" && [
"bg-[rgba(43,198,164,0.16)] px-2.5 py-0.5 text-[0.72rem] text-[#1a6b5b]",
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
],
variant === "pending" && [
"bg-[rgba(240,196,106,0.2)] px-3 py-1 text-[#8a6914]",
"dark:bg-[rgba(243,201,122,0.18)] dark:text-[color:var(--gold)]",
],
variant === "success" && [
"bg-emerald-100 px-3 py-1 text-emerald-700",
"dark:bg-emerald-900/30 dark:text-emerald-300",
],
variant === "warning" && [
"bg-amber-100 px-3 py-1 text-amber-700",
"dark:bg-amber-900/30 dark:text-amber-300",
],
variant === "destructive" && [
"bg-red-100 px-3 py-1 text-red-700",
"dark:bg-red-900/30 dark:text-red-300",
],
// Base styles
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-fs-sm font-semibold",
// Variant styles — all token-driven, no dark: overrides needed
variant === "default" && "bg-hover-bg px-3 py-1 text-ink-soft border border-line",
variant === "accent" && "bg-active-bg px-3 py-1 text-accent-deep border border-line",
variant === "compact" && "bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
variant === "pending" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "success" && "bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
variant === "warning" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "destructive" && "bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
className,
)}
{...props}
+4 -4
View File
@@ -33,15 +33,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:pointer-events-none disabled:opacity-60",
// Hover lift (matches .btn:hover)
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-[0_10px_20px_rgba(29,26,23,0.12)]",
// Hover lift
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
// Variant styles
variant === "default" &&
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
variant === "primary" &&
"border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white dark:from-[#c35640] dark:to-[#953827] dark:shadow-[0_10px_22px_rgba(58,23,16,0.42),inset_0_1px_0_rgba(255,201,184,0.18)]",
"border border-accent bg-accent/10 text-[color:var(--ink)]",
variant === "destructive" &&
"border border-red-300/40 bg-red-50 text-red-700 hover:not-disabled:bg-red-100 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300",
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
variant === "ghost" &&
"border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0",
variant === "outline" &&
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
ref={ref}
className={cn(
// Matches .card
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] transition-all duration-200 ease-out",
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5 transition-all duration-200 ease-out",
className,
)}
{...props}
+2 -2
View File
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog-backdrop
"fixed inset-0 z-80 grid place-items-center bg-[rgba(21,24,35,0.42)] p-5 backdrop-blur-[3px]",
"fixed inset-0 z-80 grid place-items-center bg-overlay-bg p-5 backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
// Matches .report-dialog
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-[0_24px_50px_rgba(18,22,34,0.24)]",
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
+2 -2
View File
@@ -18,7 +18,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[180px] rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
className,
)}
{...props}
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+6 -10
View File
@@ -7,16 +7,12 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
ref={ref}
type={type}
className={cn(
// Base styles matching .form-input
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
// Focus styles matching .form-input:focus
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
// Dark mode matching [data-theme="dark"] .form-input
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
// Base styles
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+2 -3
View File
@@ -10,9 +10,8 @@ const Label = React.forwardRef<
ref={ref}
className={cn(
// Matches .form-label
"text-[0.74rem] font-bold uppercase tracking-[0.14em]",
"text-[rgba(70,95,113,0.9)]",
"dark:text-[rgba(206,227,238,0.76)]",
"text-fs-xs font-bold uppercase tracking-[0.14em]",
"text-label-fg",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
+7 -8
View File
@@ -14,12 +14,11 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
// Matches .form-input styling for consistency
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
// Matches form input token styling
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
@@ -69,7 +68,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -115,7 +114,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"relative flex w-full cursor-pointer select-none items-center rounded-[var(--radius-sm)] py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
"focus:bg-[color:var(--surface-muted)] focus:text-[color:var(--ink)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
+1 -1
View File
@@ -15,7 +15,7 @@ const SheetOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-80 bg-[rgba(21,24,35,0.42)] backdrop-blur-[3px]",
"fixed inset-0 z-80 bg-overlay-bg backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
+5 -9
View File
@@ -8,16 +8,12 @@ const Textarea = React.forwardRef<
<textarea
ref={ref}
className={cn(
// Base styles matching .form-input
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
"placeholder:text-[rgba(88,115,133,0.72)]",
// Base styles
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
// Dark mode
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+2 -2
View File
@@ -9,7 +9,7 @@ const ToggleGroup = React.forwardRef<
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(
"inline-flex h-[38px] items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
className,
)}
{...props}
@@ -24,7 +24,7 @@ const ToggleGroupItem = React.forwardRef<
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white",
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-accent data-[state=on]:text-accent-fg",
className,
)}
{...props}
+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);
+4
View File
@@ -0,0 +1,4 @@
/** Standardized icon sizes for consistent sizing across the app */
export const ICON_SM = 14;
export const ICON_MD = 16;
export const ICON_LG = 24;
+250
View File
@@ -0,0 +1,250 @@
/**
* Shared navigation configuration used by Header and Footer to eliminate
* triple duplication of nav link definitions.
*/
/** Lucide icon name used as a key to look up the component at render time. */
export type NavIconName = "wrench" | "plug" | "ghost";
export interface NavItem {
/** Visible link text */
label: string;
/** Route path passed to `<Link to>` */
to: string;
/** Optional search params object passed to `<Link search>` */
search?: Record<string, unknown>;
/** Optional lucide icon name shown beside the label in navbar tabs */
icon?: NavIconName;
/** Link only shown when user is authenticated */
authRequired: boolean;
/** Link only shown for staff / moderator users */
staffOnly: boolean;
/** Link only shown when siteMode === "souls" */
soulModeOnly: boolean;
/** Link hidden when siteMode === "souls" */
soulModeHide: boolean;
}
// ---------------------------------------------------------------------------
// Search-param shapes (kept here so Header, Footer, and mobile menu all agree)
// ---------------------------------------------------------------------------
const SKILLS_SEARCH = {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
} as const;
const SOULS_SEARCH = {
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
} as const;
const USERS_SEARCH = { q: undefined } as const;
const MANAGEMENT_SEARCH = { skill: undefined } as const;
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the "content-type" tabs: Skills | Plugins | Souls
// In soul-mode the order is: ClawHub (external), Souls
// In skills-mode: Skills, Plugins, Souls
// ---------------------------------------------------------------------------
export const PRIMARY_NAV_ITEMS: NavItem[] = [
{
label: "Skills",
to: "/skills",
search: SKILLS_SEARCH,
icon: "wrench",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Plugins",
to: "/plugins",
icon: "plug",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Souls",
to: "/souls",
search: SOULS_SEARCH,
icon: "ghost",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
// In soul-mode this is the primary tab; in skills-mode it is also shown.
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
// ---------------------------------------------------------------------------
export const SECONDARY_NAV_ITEMS: NavItem[] = [
{
label: "Users",
to: "/users",
search: USERS_SEARCH,
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "About",
to: "/about",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Stars",
to: "/stars",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Dashboard",
to: "/dashboard",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Management",
to: "/management",
search: MANAGEMENT_SEARCH,
authRequired: true,
staffOnly: true,
soulModeOnly: false,
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Footer sections
// ---------------------------------------------------------------------------
export interface FooterNavSection {
title: string;
items: FooterNavItem[];
}
export type FooterNavItem =
| { kind: "link"; label: string; to: string; search?: Record<string, unknown> }
| { kind: "external"; label: string; href: string }
| { kind: "text"; label: string };
export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{
title: "Browse",
items: [
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
{ kind: "link", label: "Plugins", to: "/plugins" },
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH },
{ kind: "link", label: "Users", to: "/users" },
{
kind: "link",
label: "Staff Picks",
to: "/skills",
search: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: true,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
},
},
{
kind: "link",
label: "Search",
to: "/search",
search: { q: undefined, type: undefined },
},
],
},
{
title: "Publish",
items: [
{
kind: "link",
label: "Publish Skill",
to: "/publish-skill",
search: { updateSlug: undefined },
},
{
kind: "link",
label: "Publish Plugin",
to: "/publish-plugin",
search: {
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
},
},
{
kind: "external",
label: "Documentation",
href: "https://github.com/openclaw/clawhub",
},
],
},
{
title: "Community",
items: [
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
{ kind: "link", label: "About", to: "/about" },
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
],
},
{
title: "Platform",
items: [
{ kind: "text", label: "MIT Licensed" },
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Filter a nav item array based on current mode/auth/staff context. */
export function filterNavItems(
items: NavItem[],
ctx: { isSoulMode: boolean; isAuthenticated: boolean; isStaff: boolean },
): NavItem[] {
return items.filter((item) => {
if (item.soulModeOnly && !ctx.isSoulMode) return false;
if (item.soulModeHide && ctx.isSoulMode) return false;
if (item.authRequired && !ctx.isAuthenticated) return false;
if (item.staffOnly && !ctx.isStaff) return false;
return true;
});
}
+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)
+8 -1
View File
@@ -1,9 +1,16 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
+18
View File
@@ -92,6 +92,24 @@ export const Route = createRootRoute({
rel: "stylesheet",
href: appCss,
},
{
rel: "icon",
href: "/favicon.ico",
type: "image/x-icon",
},
{
rel: "icon",
href: "/logo.svg",
type: "image/svg+xml",
},
{
rel: "apple-touch-icon",
href: "/logo192.png",
},
{
rel: "manifest",
href: "/manifest.json",
},
],
};
},
+114 -155
View File
@@ -1,69 +1,65 @@
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 { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
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 +70,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 +83,89 @@ 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 mb-3">
<Badge>About</Badge>
<Badge variant="accent">Policy</Badge>
</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">
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
</Button>
<Button asChild>
<a
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
target="_blank"
rel="noreferrer"
>
Reviewer Doc
</a>
</Button>
</div>
</section>
</main>
);
}
+165 -162
View File
@@ -16,12 +16,9 @@ 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 { Card } from "../components/ui/card";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import type { PublicSkill } from "../lib/publicUser";
@@ -110,8 +107,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 +115,72 @@ 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">
<Card>Sign in to access your dashboard.</Card>
</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 text-[1.4rem] font-[family-name: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 className="flex gap-3 justify-center">
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
Publish a Skill
</Link>
</Button>
<Button asChild>
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse Skills
</Link>
</Button>
</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 m-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 m-0">
Manage your published skills and plugins.
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex gap-2 flex-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)}
>
@@ -168,7 +198,10 @@ function Dashboard() {
</Link>
</Button>
<Button asChild>
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
@@ -176,109 +209,101 @@ function Dashboard() {
</div>
</div>
{/* Owner panel */}
<Card>
<CardContent>
<div className="grid gap-10">
{/* Skills section */}
<section className="flex flex-col gap-4">
<Card className="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 mt-1.5 mb-0 mx-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>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</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 mt-1.5 mb-0 mx-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>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</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>
</Card>
</Container>
</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">
<Clock className="h-3 w-3" aria-hidden="true" />
@@ -286,44 +311,35 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
</Badge>
) : 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">
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload className="h-3 w-3" aria-hidden="true" />
@@ -331,7 +347,10 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
>
View
</Link>
</Button>
@@ -388,9 +407,7 @@ function PackageStatusTag({
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 +428,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 +450,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,18 +485,16 @@ 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">
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link
to="/publish-plugin"
+310 -286
View File
@@ -1,20 +1,18 @@
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 { Card } from "../components/ui/card";
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 +34,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,192 +73,257 @@ 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">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
<Button variant="primary" size="lg">
Browse skills
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Button variant="outline" size="lg">
Publish Skill
</Button>
</Link>
<div className="home-hero-actions">
<Button asChild variant="primary">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse All Skills & Plugins
</Link>
</Button>
<Button asChild className="home-hero-publish-btn">
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
>
+ Publish Yours
</Link>
</Button>
</div>
<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>
<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>
<div className="home-hero-panels" id="home-discovery">
<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>
</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">
<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 +340,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 +366,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 variant="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 ? (
<Card>No souls yet. Be the first.</Card>
) : (
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>
);
+640 -796
View File
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -296,8 +296,7 @@ function PluginDetailRoute() {
: [];
return (
<main className="py-10">
<Container>
<main className="section">
<div className="flex flex-col gap-5">
{/* Header card */}
<Card>
@@ -373,13 +372,12 @@ function PluginDetailRoute() {
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<a
href={getPackageDownloadPath(name, pkg.latestVersion)}
className="inline-flex min-h-[34px] w-full items-center justify-center gap-2 rounded-[var(--radius-pill)] border border-[color:var(--border-ui)] bg-transparent px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)] transition-all duration-200 no-underline hover:border-[color:var(--border-ui-hover)] hover:bg-[color:var(--surface)] sm:w-auto sm:whitespace-nowrap"
>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
</div>
) : null}
</CardContent>
@@ -579,7 +577,6 @@ function PluginDetailRoute() {
</Card>
) : null}
</div>
</Container>
</main>
);
}
+161 -238
View File
@@ -1,20 +1,14 @@
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 { BrowseSidebar } from "../../components/BrowseSidebar";
import { PluginListItem } from "../../components/PluginListItem";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import {
fetchPluginCatalog,
isRateLimitedPackageApiError,
type PackageListItem,
} from "../../lib/packageApi";
import { familyLabel } from "../../lib/packageLabels";
type PluginSearchState = {
q?: string;
@@ -31,6 +25,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 +47,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,95 +65,95 @@ 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>
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 className="flex items-center gap-2">
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{
@@ -159,177 +164,95 @@ export function PluginsIndex() {
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
Publish
</Link>
</Button>
</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) => (
<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)]"
}`}
{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 className="mt-5 flex justify-center gap-3">
{search.cursor ? (
<Button
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}
</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>
First page
</Button>
) : null}
</>
)}
{nextCursor ? (
<Button
variant="primary"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({ ...prev, cursor: nextCursor }),
});
}}
>
Next page
</Button>
) : null}
</div>
) : null}
</div>
</Container>
</div>
</main>
);
}
+164 -35
View File
@@ -1,42 +1,171 @@
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 { Card } from "../components/ui/card";
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 mb-4">
{search.q ? (
<>
Search results for <span className="text-[color:var(--accent)]">"{search.q}"</span>
</>
) : (
"Search"
)}
</h1>
<form className="search-page-form" onSubmit={handleSearch}>
<div className="browse-search-bar max-w-[560px] 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 ? (
<Card>
<div className="loading-indicator">Searching...</div>
</Card>
) : !search.q ? (
<Card className="text-center p-10">
<p className="text-ink-soft">
Enter a search term to find skills, plugins, and users
</p>
</Card>
) : results.length === 0 ? (
<Card className="text-center p-10">
<p className="text-ink-soft">No results found for "{search.q}"</p>
</Card>
) : (
<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} />;
}
+43 -82
View File
@@ -1,12 +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 { SkillStatsTripletLine } from "../../components/SkillStats";
import { Button } from "../../components/ui/button";
import { UserBadge } from "../../components/UserBadge";
import { getSkillBadges } from "../../lib/badges";
@@ -29,7 +25,7 @@ export function SkillsResults({
isLoadingSkills,
sorted,
view,
listDoneLoading,
listDoneLoading: _listDoneLoading,
hasQuery,
canLoadMore,
isLoadingMore,
@@ -40,14 +36,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 +74,57 @@ 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 mt-4 flex justify-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 type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
)}
</div>
)}
) : null}
</>
);
}
+2 -2
View File
@@ -219,7 +219,7 @@ export function SkillsToolbar({
onClick={view === "list" ? onToggleView : undefined}
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
view === "cards"
? "bg-[color:var(--accent)] text-white"
? "bg-accent text-accent-fg"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-label="Grid view"
@@ -231,7 +231,7 @@ export function SkillsToolbar({
onClick={view === "cards" ? onToggleView : undefined}
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
view === "list"
? "bg-[color:var(--accent)] text-white"
? "bg-accent text-accent-fg"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-label="List view"
+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>
);
}
+51 -230
View File
@@ -1,32 +1,15 @@
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 { Badge } from "../../components/ui/badge";
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 +20,61 @@ 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 mb-3">
<Badge>Souls</Badge>
<Badge variant="accent">Coming soon</Badge>
</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 mb-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">
<Button asChild variant="primary">
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
Browse Skills
</Link>
</Button>
<Button asChild>
<Link to="/users" search={{ q: undefined }}>
Browse Users
</Link>
</Button>
</div>
</section>
</main>
);
}
+137 -211
View File
@@ -1,23 +1,12 @@
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 { SkillListItem } from "../../components/SkillListItem";
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 type { PublicSkill, PublicUser } from "../../lib/publicUser";
export const Route = createFileRoute("/u/$handle")({
@@ -51,28 +40,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">
<Card>
<div className="loading-indicator">Loading user</div>
</Card>
</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">
<Card>User not found.</Card>
</main>
);
}
@@ -87,134 +66,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 mb-3">
Published ({published.length})
</h2>
{isLoadingPublished ? (
<Card>
<div className="loading-indicator">Loading published skills...</div>
</Card>
) : (
<div className="results-list mb-6">
{published.map((skill) => (
<SkillListItem key={skill._id} skill={skill} />
))}
</div>
)}
</>
) : null}
<h2 className="home-section-title mb-3">
Stars ({skills.length})
</h2>
{isLoadingSkills ? (
<Card>
<div className="loading-indicator">Loading stars...</div>
</Card>
) : skills.length === 0 ? (
<Card>No stars yet.</Card>
) : (
<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,51 +161,51 @@ 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 text-xl">
Installed
</h2>
<Card>
<div className="loading-indicator">Loading telemetry</div>
</Card>
</>
);
}
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 text-xl">
Installed
</h2>
<Card>Sign in to view your installed skills.</Card>
</>
);
}
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 text-xl">
Installed
</h2>
<p className="section-subtitle max-w-[760px]">
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 type="button" onClick={props.onToggleRemoved}>
{props.includeRemoved ? "Hide removed" : "Show removed"}
</Button>
<Button variant="outline" size="sm" onClick={() => setShowRaw((value) => !value)}>
<Button type="button" onClick={() => setShowRaw((value) => !value)}>
{showRaw ? "Hide JSON" : "Show JSON"}
</Button>
<Button
variant="destructive"
size="sm"
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
@@ -275,59 +213,47 @@ function InstalledSection(props: {
</div>
{showRaw ? (
<Card className="mb-4">
<pre className="font-mono text-xs whitespace-pre-wrap break-all">
<Card className="telemetry-json mb-4">
<pre className="mono m-0 whitespace-pre-wrap">
{JSON.stringify(data, null, 2)}
</pre>
</Card>
) : null}
{data.roots.length === 0 ? (
<EmptyState
title="No telemetry yet"
description='Run "clawhub sync" from the CLI to start tracking.'
/>
<Card>No telemetry yet. Run `clawhub sync` from the CLI.</Card>
) : (
<div className="grid gap-4">
{data.roots.map((root) => (
<Card key={root.rootId}>
<div className="flex items-center justify-between">
<Card key={root.rootId} className="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>
<Badge>{root.skills.length} skills</Badge>
</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>
)}
@@ -335,7 +261,7 @@ function InstalledSection(props: {
))}
</div>
)}
</div>
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
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 { Card } from "../../components/ui/card";
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 ? (
<Card>
<div className="loading-indicator">Loading users...</div>
</Card>
) : 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>
);
}
+5672 -241
View File
File diff suppressed because it is too large Load Diff