mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5003c1bec8 | ||
|
|
a8a6242f87 | ||
|
|
39c0fa2531 | ||
|
|
9df6fe37c5 | ||
|
|
051b1dafcd | ||
|
|
383844cacf | ||
|
|
b7923edbfd | ||
|
|
522fa22026 | ||
|
|
e41d5d6314 | ||
|
|
c62ab8bc96 | ||
|
|
6df3a3b9ef | ||
|
|
7452cf6f69 | ||
|
|
b4e8a26eb4 | ||
|
|
7ff601bcb8 | ||
|
|
70af109cb2 | ||
|
|
1504708208 |
@@ -24,3 +24,7 @@ coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright
|
||||
convex/_generated/
|
||||
skills-lock.json
|
||||
*/skills/*
|
||||
skills/*
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Vendored
+6
@@ -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,6 +1,6 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
extractWorkflowFilenameFromWorkflowRef,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
|
||||
+2
-1
@@ -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")),
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalMutation } from "./functions";
|
||||
|
||||
const DEMO_SKILLS = [
|
||||
{
|
||||
slug: "mcp-github",
|
||||
displayName: "MCP GitHub",
|
||||
summary: "Full GitHub API integration via MCP — issues, PRs, repos, code search, and actions.",
|
||||
downloads: 14200,
|
||||
stars: 342,
|
||||
installs: 8100,
|
||||
},
|
||||
{
|
||||
slug: "claude-memory",
|
||||
displayName: "Claude Memory",
|
||||
summary: "Persistent memory layer for Claude — stores context across conversations with vector recall.",
|
||||
downloads: 11800,
|
||||
stars: 287,
|
||||
installs: 6400,
|
||||
},
|
||||
{
|
||||
slug: "web-scraper-pro",
|
||||
displayName: "Web Scraper Pro",
|
||||
summary: "Intelligent web scraping with automatic pagination, JS rendering, and structured data extraction.",
|
||||
downloads: 9400,
|
||||
stars: 198,
|
||||
installs: 5200,
|
||||
},
|
||||
{
|
||||
slug: "sql-analyst",
|
||||
displayName: "SQL Analyst",
|
||||
summary: "Natural language to SQL with schema introspection, query optimization, and result visualization.",
|
||||
downloads: 8700,
|
||||
stars: 221,
|
||||
installs: 4800,
|
||||
},
|
||||
{
|
||||
slug: "pytest-agent",
|
||||
displayName: "Pytest Agent",
|
||||
summary: "Automated test generation and execution for Python — coverage analysis, mutation testing, fixtures.",
|
||||
downloads: 7200,
|
||||
stars: 156,
|
||||
installs: 3900,
|
||||
},
|
||||
{
|
||||
slug: "docker-compose-helper",
|
||||
displayName: "Docker Compose Helper",
|
||||
summary: "Generate, validate, and debug Docker Compose configurations with multi-service orchestration.",
|
||||
downloads: 6800,
|
||||
stars: 134,
|
||||
installs: 3600,
|
||||
},
|
||||
{
|
||||
slug: "api-docs-generator",
|
||||
displayName: "API Docs Generator",
|
||||
summary: "Auto-generate OpenAPI specs and beautiful documentation from any codebase or endpoint.",
|
||||
downloads: 5900,
|
||||
stars: 178,
|
||||
installs: 3100,
|
||||
},
|
||||
{
|
||||
slug: "slack-bot-builder",
|
||||
displayName: "Slack Bot Builder",
|
||||
summary: "Build and deploy Slack bots with natural language — slash commands, modals, and event handlers.",
|
||||
downloads: 5400,
|
||||
stars: 112,
|
||||
installs: 2800,
|
||||
},
|
||||
{
|
||||
slug: "terraform-assistant",
|
||||
displayName: "Terraform Assistant",
|
||||
summary: "Infrastructure as code helper — plan reviews, drift detection, module generation for AWS/GCP/Azure.",
|
||||
downloads: 4800,
|
||||
stars: 145,
|
||||
installs: 2400,
|
||||
},
|
||||
{
|
||||
slug: "regex-wizard",
|
||||
displayName: "Regex Wizard",
|
||||
summary: "Natural language to regex with live testing, explanation, and edge case generation.",
|
||||
downloads: 4200,
|
||||
stars: 89,
|
||||
installs: 2100,
|
||||
},
|
||||
{
|
||||
slug: "git-history-explorer",
|
||||
displayName: "Git History Explorer",
|
||||
summary: "Semantic search through git history — find commits by intent, trace code evolution, blame analysis.",
|
||||
downloads: 3900,
|
||||
stars: 102,
|
||||
installs: 1900,
|
||||
},
|
||||
{
|
||||
slug: "cron-scheduler",
|
||||
displayName: "Cron Scheduler",
|
||||
summary: "Natural language to cron expressions with timezone handling, overlap protection, and monitoring.",
|
||||
downloads: 3400,
|
||||
stars: 67,
|
||||
installs: 1600,
|
||||
},
|
||||
{
|
||||
slug: "jwt-debugger",
|
||||
displayName: "JWT Debugger",
|
||||
summary: "Decode, verify, and generate JWTs with visual payload inspection and expiry tracking.",
|
||||
downloads: 3100,
|
||||
stars: 78,
|
||||
installs: 1400,
|
||||
},
|
||||
{
|
||||
slug: "graphql-builder",
|
||||
displayName: "GraphQL Builder",
|
||||
summary: "Schema-first GraphQL development — type generation, resolver scaffolding, and playground integration.",
|
||||
downloads: 2800,
|
||||
stars: 94,
|
||||
installs: 1200,
|
||||
},
|
||||
{
|
||||
slug: "security-scanner",
|
||||
displayName: "Security Scanner",
|
||||
summary: "OWASP-aware security scanning for codebases — dependency audit, secret detection, SAST patterns.",
|
||||
downloads: 2500,
|
||||
stars: 156,
|
||||
installs: 1100,
|
||||
},
|
||||
{
|
||||
slug: "markdown-slides",
|
||||
displayName: "Markdown Slides",
|
||||
summary: "Turn markdown into presentation decks with themes, speaker notes, and PDF export.",
|
||||
downloads: 2200,
|
||||
stars: 45,
|
||||
installs: 900,
|
||||
},
|
||||
{
|
||||
slug: "env-manager",
|
||||
displayName: "Env Manager",
|
||||
summary: "Environment variable management across projects — sync .env files, validate schemas, rotate secrets.",
|
||||
downloads: 1800,
|
||||
stars: 56,
|
||||
installs: 800,
|
||||
},
|
||||
{
|
||||
slug: "csv-transform",
|
||||
displayName: "CSV Transform",
|
||||
summary: "Powerful CSV/TSV manipulation — column transforms, joins, pivots, and format conversion.",
|
||||
downloads: 1500,
|
||||
stars: 34,
|
||||
installs: 600,
|
||||
},
|
||||
{
|
||||
slug: "ssh-config-manager",
|
||||
displayName: "SSH Config Manager",
|
||||
summary: "Manage SSH configs, keys, and tunnels with natural language — jump hosts, port forwarding, agent setup.",
|
||||
downloads: 1200,
|
||||
stars: 42,
|
||||
installs: 500,
|
||||
},
|
||||
{
|
||||
slug: "changelog-writer",
|
||||
displayName: "Changelog Writer",
|
||||
summary: "Generate changelogs from git history with conventional commit parsing and release note formatting.",
|
||||
downloads: 980,
|
||||
stars: 28,
|
||||
installs: 400,
|
||||
},
|
||||
];
|
||||
|
||||
const DEMO_OWNERS = [
|
||||
{ handle: "anthropic", displayName: "Anthropic", highlighted: true },
|
||||
{ handle: "openai-labs", displayName: "OpenAI Labs", highlighted: false },
|
||||
{ handle: "devtools-co", displayName: "DevTools Co", highlighted: false },
|
||||
{ handle: "securityfirst", displayName: "SecurityFirst", highlighted: true },
|
||||
{ handle: "dataflow", displayName: "DataFlow", highlighted: false },
|
||||
];
|
||||
|
||||
export const seedDemoSkills = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Check if we already seeded
|
||||
const existingSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", "mcp-github"))
|
||||
.first();
|
||||
if (existingSkill) {
|
||||
return { seeded: false, reason: "already seeded" };
|
||||
}
|
||||
|
||||
// Create a seed user
|
||||
const seedUserId = await ctx.db.insert("users", {
|
||||
name: "ClawHub Demo",
|
||||
displayName: "ClawHub Demo",
|
||||
handle: "clawhub-demo",
|
||||
image: undefined,
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
// Create publisher accounts
|
||||
const publisherIds: string[] = [];
|
||||
for (const owner of DEMO_OWNERS) {
|
||||
const pubId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle: owner.handle,
|
||||
displayName: owner.displayName,
|
||||
linkedUserId: seedUserId,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
publisherIds.push(pubId);
|
||||
|
||||
// Add membership
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId: pubId as Id<"publishers">,
|
||||
userId: seedUserId,
|
||||
role: "owner",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const DAY = 86400000;
|
||||
|
||||
for (let i = 0; i < DEMO_SKILLS.length; i++) {
|
||||
const s = DEMO_SKILLS[i];
|
||||
const ownerIdx = i % publisherIds.length;
|
||||
const createdDaysAgo = Math.floor(Math.random() * 90) + 7;
|
||||
const updatedDaysAgo = Math.floor(Math.random() * createdDaysAgo);
|
||||
const createdAt = now - createdDaysAgo * DAY;
|
||||
const updatedAt = now - updatedDaysAgo * DAY;
|
||||
const version = `${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 20)}`;
|
||||
|
||||
const isHighlighted = i < 6;
|
||||
const badges = isHighlighted
|
||||
? { highlighted: { byUserId: seedUserId, at: now } }
|
||||
: undefined;
|
||||
|
||||
const numVersions = Math.floor(Math.random() * 8) + 1;
|
||||
const numComments = Math.floor(Math.random() * 15);
|
||||
|
||||
// Create skill first (without latestVersionId)
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: s.slug,
|
||||
displayName: s.displayName,
|
||||
summary: s.summary,
|
||||
ownerUserId: seedUserId,
|
||||
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
|
||||
tags: {},
|
||||
badges,
|
||||
moderationStatus: "active",
|
||||
moderationVerdict: "clean",
|
||||
stats: {
|
||||
downloads: s.downloads,
|
||||
installsCurrent: Math.floor(s.installs * 0.3),
|
||||
installsAllTime: s.installs,
|
||||
stars: s.stars,
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
},
|
||||
statsDownloads: s.downloads,
|
||||
statsStars: s.stars,
|
||||
statsInstallsCurrent: Math.floor(s.installs * 0.3),
|
||||
statsInstallsAllTime: s.installs,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
// Create skillBadges entry for highlighted skills
|
||||
if (isHighlighted) {
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId,
|
||||
kind: "highlighted",
|
||||
byUserId: seedUserId,
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Now create version with real skillId
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version,
|
||||
changelog: `Release ${version} — improvements and bug fixes.`,
|
||||
files: [],
|
||||
parsed: { frontmatter: {} },
|
||||
createdBy: seedUserId,
|
||||
createdAt: updatedAt,
|
||||
});
|
||||
|
||||
// Patch skill with version info
|
||||
await ctx.db.patch(skillId, {
|
||||
latestVersionId: versionId,
|
||||
latestVersionSummary: {
|
||||
version,
|
||||
createdAt: updatedAt,
|
||||
changelog: `Release ${version}`,
|
||||
},
|
||||
tags: { latest: versionId },
|
||||
});
|
||||
|
||||
// Create digest for search
|
||||
await ctx.db.insert("skillSearchDigest", {
|
||||
skillId,
|
||||
slug: s.slug,
|
||||
displayName: s.displayName,
|
||||
summary: s.summary,
|
||||
ownerUserId: seedUserId,
|
||||
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
|
||||
ownerHandle: DEMO_OWNERS[ownerIdx].handle,
|
||||
ownerName: DEMO_OWNERS[ownerIdx].displayName,
|
||||
ownerDisplayName: DEMO_OWNERS[ownerIdx].displayName,
|
||||
ownerImage: undefined,
|
||||
latestVersionId: versionId,
|
||||
latestVersionSummary: {
|
||||
version,
|
||||
createdAt: updatedAt,
|
||||
changelog: `Release ${version}`,
|
||||
},
|
||||
tags: { latest: versionId },
|
||||
badges,
|
||||
stats: {
|
||||
downloads: s.downloads,
|
||||
installsCurrent: Math.floor(s.installs * 0.3),
|
||||
installsAllTime: s.installs,
|
||||
stars: s.stars,
|
||||
versions: Math.floor(Math.random() * 8) + 1,
|
||||
comments: Math.floor(Math.random() * 15),
|
||||
},
|
||||
statsDownloads: s.downloads,
|
||||
statsStars: s.stars,
|
||||
statsInstallsCurrent: Math.floor(s.installs * 0.3),
|
||||
statsInstallsAllTime: s.installs,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
isSuspicious: false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { seeded: true, count: DEMO_SKILLS.length };
|
||||
},
|
||||
});
|
||||
|
||||
// Repair globalStats count to match actual seeded data
|
||||
export const repairGlobalStats = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Count active digests — push filter server-side
|
||||
const digests = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.filter((q) => q.eq(q.field("moderationStatus"), "active"))
|
||||
.collect();
|
||||
|
||||
const count = digests.length;
|
||||
|
||||
// Update globalStats
|
||||
const stats = await ctx.db
|
||||
.query("globalStats")
|
||||
.filter((q) => q.eq(q.field("key"), "default"))
|
||||
.first();
|
||||
|
||||
if (stats) {
|
||||
await ctx.db.patch(stats._id, { activeSkillsCount: count, updatedAt: Date.now() });
|
||||
} else {
|
||||
await ctx.db.insert("globalStats", {
|
||||
key: "default",
|
||||
activeSkillsCount: count,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return { count };
|
||||
},
|
||||
});
|
||||
|
||||
// Repair function to add missing skillBadges for already-seeded data
|
||||
export const repairHighlightedBadges = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const highlightedSlugs = DEMO_SKILLS.slice(0, 6).map((s) => s.slug);
|
||||
let fixed = 0;
|
||||
|
||||
for (const slug of highlightedSlugs) {
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.first();
|
||||
if (!skill) continue;
|
||||
|
||||
// Check if badge already exists
|
||||
const existing = await ctx.db
|
||||
.query("skillBadges")
|
||||
.withIndex("by_skill_kind", (q) =>
|
||||
q.eq("skillId", skill._id).eq("kind", "highlighted"),
|
||||
)
|
||||
.first();
|
||||
if (existing) continue;
|
||||
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId: skill._id,
|
||||
kind: "highlighted",
|
||||
byUserId: skill.ownerUserId,
|
||||
at: Date.now(),
|
||||
});
|
||||
fixed++;
|
||||
}
|
||||
|
||||
return { fixed };
|
||||
},
|
||||
});
|
||||
+44
-20
@@ -1,22 +1,17 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
requireUser,
|
||||
} from "./lib/access";
|
||||
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
isHandleReservedForAnotherUser,
|
||||
@@ -301,9 +296,7 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
const ensuredUser = hasUpdates
|
||||
? ({ ...user, ...updates } as Doc<"users">)
|
||||
: ((await ctx.db.get(userId)) ?? user);
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
@@ -393,6 +386,23 @@ export const list = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 40, 1, 100);
|
||||
const result = await queryUsersForPublicList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
.map((user) => toPublicUser(user))
|
||||
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
|
||||
total: result.total,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeSearchQuery(search?: string) {
|
||||
const trimmed = search?.trim().toLowerCase();
|
||||
return trimmed ? trimmed : undefined;
|
||||
@@ -403,13 +413,7 @@ function computeUserSearchScanLimit(limit: number) {
|
||||
}
|
||||
|
||||
async function queryUsersForAdminList(
|
||||
ctx: {
|
||||
db: {
|
||||
query: (table: "users") => {
|
||||
order: (order: "desc") => { take: (n: number) => Promise<Doc<"users">[]> };
|
||||
};
|
||||
};
|
||||
},
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search);
|
||||
@@ -431,6 +435,27 @@ async function queryUsersForAdminList(
|
||||
};
|
||||
}
|
||||
|
||||
async function queryUsersForPublicList(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
args: { limit: number; search?: string },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search);
|
||||
const scanLimit = normalizedSearch
|
||||
? computeUserSearchScanLimit(args.limit)
|
||||
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
|
||||
const scannedUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
|
||||
.order("desc")
|
||||
.take(scanLimit);
|
||||
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
|
||||
const result = buildUserSearchResults(activeUsers, normalizedSearch);
|
||||
return {
|
||||
items: result.items.slice(0, args.limit),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(Math.trunc(value), min), max);
|
||||
}
|
||||
@@ -860,8 +885,7 @@ async function ensurePublisherHandleWithActor(
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() &&
|
||||
(!existing.displayName || existing.displayName === existing.handle)
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
|
||||
@@ -5,8 +5,12 @@ import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Header from "../components/Header";
|
||||
|
||||
const siteModeMock = vi.fn(() => "souls");
|
||||
const convexQueryMock = vi.fn().mockResolvedValue(0);
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
@@ -56,7 +60,7 @@ vi.mock("../lib/roles", () => ({
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => "souls",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
@@ -68,6 +72,24 @@ vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: {
|
||||
query: convexQueryMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
skills: {
|
||||
countPublicSkills: "countPublicSkills",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../lib/numberFormat", () => ({
|
||||
formatCompactStat: (n: number) => String(n),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
@@ -85,8 +107,23 @@ vi.mock("../components/ui/toggle-group", () => ({
|
||||
|
||||
describe("Header", () => {
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a plain Skills tab without fetching a count", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
convexQueryMock.mockClear();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Souls")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
|
||||
expect(convexQueryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,99 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
|
||||
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
|
||||
__config: config,
|
||||
}),
|
||||
redirect: (options: unknown) => ({ redirect: options }),
|
||||
Link: "a",
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
import { Route } from "../routes/search";
|
||||
|
||||
function runBeforeLoad(
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
|
||||
hostname = "clawdhub.com",
|
||||
) {
|
||||
function runValidateSearch(search: Record<string, unknown>) {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: {
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
|
||||
location: { url: URL };
|
||||
}) => void;
|
||||
validateSearch?: (search: Record<string, unknown>) => unknown;
|
||||
};
|
||||
};
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
|
||||
location: { url: URL };
|
||||
}) => void;
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
return thrown;
|
||||
const validateSearch = route.__config.validateSearch;
|
||||
return validateSearch ? validateSearch(search) : {};
|
||||
}
|
||||
|
||||
describe("search route", () => {
|
||||
it("redirects skills host to the skills index", () => {
|
||||
expect(runBeforeLoad({ q: "crab", highlighted: true }, "clawdhub.com")).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: "crab",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("validates search with query", () => {
|
||||
expect(runValidateSearch({ q: "crab" })).toEqual({
|
||||
q: "crab",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards nonSuspicious filter to skills index", () => {
|
||||
expect(runBeforeLoad({ q: "crab", nonSuspicious: true }, "clawdhub.com")).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: "crab",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("validates search with type filter", () => {
|
||||
expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
|
||||
q: "crab",
|
||||
type: "skills",
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects souls host with query to home search", () => {
|
||||
expect(runBeforeLoad({ q: "crab", highlighted: true }, "onlycrabs.ai")).toEqual({
|
||||
redirect: {
|
||||
to: "/",
|
||||
search: {
|
||||
q: "crab",
|
||||
highlighted: undefined,
|
||||
search: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("ignores invalid type filter", () => {
|
||||
expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
|
||||
q: "crab",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects souls host without query to home with search mode", () => {
|
||||
expect(runBeforeLoad({}, "onlycrabs.ai")).toEqual({
|
||||
redirect: {
|
||||
to: "/",
|
||||
search: {
|
||||
q: undefined,
|
||||
highlighted: undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("accepts the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips empty query", () => {
|
||||
expect(runValidateSearch({ q: " " })).toEqual({
|
||||
q: undefined,
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("has a component (not a redirect-only route)", () => {
|
||||
const route = Route as unknown as {
|
||||
__config: { component?: unknown };
|
||||
};
|
||||
expect(route.__config.component).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,8 +24,8 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/SkillDiffCard", () => ({
|
||||
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
|
||||
vi.mock("../components/SkillCommentsPanel", () => ({
|
||||
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
|
||||
}));
|
||||
|
||||
describe("SkillDetailPage", () => {
|
||||
@@ -58,11 +58,8 @@ describe("SkillDetailPage", () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { container } = render(<SkillDetailPage slug="weather" />);
|
||||
// Loading state now renders a skeleton, not text
|
||||
expect(
|
||||
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
|
||||
).toBeTruthy();
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -135,174 +132,11 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// With initialData, should render content instead of skeleton
|
||||
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
|
||||
expect(screen.queryByText(/Loading skill/i)).toBeNull();
|
||||
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows capability tags on the skill page without other scan findings", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="skill-pay"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "skill-pay",
|
||||
displayName: "SkillPay",
|
||||
summary: "Crypto payments for AI skills.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: { license: "MIT-0", frontmatter: {} },
|
||||
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
|
||||
sha256hash: "abc123",
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 10,
|
||||
storageId,
|
||||
sha256: "abc",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# SkillPay",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
|
||||
expect(screen.getByText("Capability signals")).toBeTruthy();
|
||||
expect(screen.getByText("Crypto")).toBeTruthy();
|
||||
expect(screen.getByText("Requires wallet")).toBeTruthy();
|
||||
expect(screen.getByText("Can make purchases")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("prefers the full frontmatter description over the shortened summary in the header", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const fullDescription =
|
||||
"Add credit-based payments to any OpenClaw skill. Register paid skills, charge users per call, track earnings, and withdraw USDC. Use when a user wants to monetize a skill.";
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="skill-pay"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "skill-pay",
|
||||
displayName: "SkillPay",
|
||||
summary: "Add credit-based payments to any OpenClaw skill. Register paid skills...",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: {
|
||||
license: "MIT-0",
|
||||
frontmatter: {
|
||||
description: fullDescription,
|
||||
},
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 10,
|
||||
storageId,
|
||||
sha256: "abc",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# SkillPay",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
|
||||
// The header now always shows skill.summary (not frontmatter.description)
|
||||
expect(
|
||||
screen.getByText("Add credit-based payments to any OpenClaw skill. Register paid skills..."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not refetch readme when SSR data already matches the latest version", async () => {
|
||||
@@ -375,7 +209,7 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
|
||||
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
|
||||
expect(getReadmeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -394,17 +228,17 @@ describe("SkillDetailPage", () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
@@ -417,9 +251,8 @@ describe("SkillDetailPage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
// Loading state now renders a skeleton, not text
|
||||
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
@@ -524,7 +357,7 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
expect(screen.queryByText(/Loading skill/i)).toBeNull();
|
||||
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -634,10 +467,24 @@ describe("SkillDetailPage", () => {
|
||||
it("defers compare version query until compare tab is requested", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"skillId" in args &&
|
||||
"limit" in args &&
|
||||
(args as { limit: number }).limit === 50
|
||||
) {
|
||||
return [
|
||||
{ _id: "skillVersions:1", version: "1.0.0", files: [] },
|
||||
{ _id: "skillVersions:2", version: "1.1.0", files: [] },
|
||||
];
|
||||
}
|
||||
if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
|
||||
if ((args as { limit: number }).limit === 200) return [];
|
||||
}
|
||||
if (args && typeof args === "object" && "limit" in args) {
|
||||
return [];
|
||||
}
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
if (args && typeof args === "object" && "slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
@@ -666,6 +513,7 @@ describe("SkillDetailPage", () => {
|
||||
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(await screen.findByText("Weather")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
|
||||
|
||||
expect(
|
||||
useQueryMock.mock.calls.some((call) => {
|
||||
@@ -679,9 +527,7 @@ describe("SkillDetailPage", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
const compareTab = screen.getByRole("tab", { name: /compare/i });
|
||||
fireEvent.mouseEnter(compareTab);
|
||||
fireEvent.click(compareTab);
|
||||
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("SkillsIndex", () => {
|
||||
it("renders an empty state when no skills are returned", async () => {
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("No skills match that filter")).toBeTruthy();
|
||||
expect(screen.getByText("No skills found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading state before fetch completes", async () => {
|
||||
@@ -74,9 +74,9 @@ describe("SkillsIndex", () => {
|
||||
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
// Header subtitle shows "Loading skills..."
|
||||
expect(screen.getAllByText("Loading skills...").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText("No skills match that filter")).toBeNull();
|
||||
// Results area shows skeleton or dash while loading
|
||||
expect(screen.getByText("\u2014")).toBeTruthy();
|
||||
expect(screen.queryByText("No skills found")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state immediately when search returns no results", async () => {
|
||||
@@ -91,8 +91,8 @@ describe("SkillsIndex", () => {
|
||||
});
|
||||
|
||||
// Should show empty state, not loading
|
||||
expect(screen.getByText("No skills match that filter")).toBeTruthy();
|
||||
expect(screen.queryByText("Loading skills...")).toBeNull();
|
||||
expect(screen.getByText("No skills found")).toBeTruthy();
|
||||
expect(screen.queryByText(/Loading skills/)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips list fetch and calls search when query is set", async () => {
|
||||
@@ -136,7 +136,7 @@ describe("SkillsIndex", () => {
|
||||
|
||||
render(<SkillsIndex />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
|
||||
const input = screen.getByPlaceholderText("Search skills...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "cli-design-framework" } });
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -161,7 +161,7 @@ describe("SkillsIndex", () => {
|
||||
|
||||
render(<SkillsIndex />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
|
||||
const input = screen.getByPlaceholderText("Search skills...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "cli-design-framework" } });
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -251,9 +251,12 @@ describe("SkillsIndex", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links[0]?.textContent).toContain("Older High Score");
|
||||
expect(links[1]?.textContent).toContain("Newer Low Score");
|
||||
const titles = Array.from(
|
||||
document.querySelectorAll(".skill-list-item-name"),
|
||||
).map((node) => node.textContent);
|
||||
|
||||
expect(titles[0]).toBe("Older High Score");
|
||||
expect(titles[1]).toBe("Newer Low Score");
|
||||
});
|
||||
|
||||
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
|
||||
@@ -288,42 +291,6 @@ describe("SkillsIndex", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes capabilityTag to list query when tag filter is active", async () => {
|
||||
searchMock = { tag: "crypto" };
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(convexHttpMock.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
capabilityTag: "crypto",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows and clears the active capability tag filter", async () => {
|
||||
searchMock = { tag: "crypto" };
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
|
||||
expect(capabilityChip).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(capabilityChip);
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
replace?: boolean;
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.replace).toBe(true);
|
||||
expect(lastCall.search({ tag: "crypto" })).toEqual({
|
||||
tag: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows load-more button when more results are available", async () => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
convexHttpMock.query.mockResolvedValue({
|
||||
@@ -356,7 +323,7 @@ describe("SkillsIndex", () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByText(/Loading/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Database,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Plug,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { SkillCategory } from "../lib/categories";
|
||||
|
||||
type FilterItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type SortOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type BrowseSidebarProps = {
|
||||
categories?: SkillCategory[];
|
||||
activeCategory?: string;
|
||||
onCategoryChange?: (slug: string | undefined) => void;
|
||||
sortOptions: SortOption[];
|
||||
activeSort: string;
|
||||
onSortChange: (value: string) => void;
|
||||
filters: FilterItem[];
|
||||
onFilterToggle: (key: string) => void;
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
"mcp-tools": <Plug size={15} />,
|
||||
prompts: <MessageSquare size={15} />,
|
||||
workflows: <GitBranch size={15} />,
|
||||
"dev-tools": <Wrench size={15} />,
|
||||
data: <Database size={15} />,
|
||||
security: <Shield size={15} />,
|
||||
automation: <Zap size={15} />,
|
||||
other: <Package size={15} />,
|
||||
};
|
||||
|
||||
export function BrowseSidebar({
|
||||
categories,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
sortOptions,
|
||||
activeSort,
|
||||
onSortChange,
|
||||
filters,
|
||||
onFilterToggle,
|
||||
}: BrowseSidebarProps) {
|
||||
return (
|
||||
<aside className="browse-sidebar" aria-label="Browse filters">
|
||||
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
|
||||
<legend className="sidebar-title">Sort by</legend>
|
||||
{sortOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeSort === opt.value}
|
||||
onClick={() => onSortChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{categories && onCategoryChange ? (
|
||||
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
|
||||
<legend className="sidebar-title">Categories</legend>
|
||||
<button
|
||||
className={`sidebar-option${!activeCategory ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={!activeCategory}
|
||||
onClick={() => onCategoryChange(undefined)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.slug}
|
||||
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeCategory === cat.slug}
|
||||
onClick={() => onCategoryChange(cat.slug)}
|
||||
>
|
||||
<span className="sidebar-option-icon" aria-hidden="true">
|
||||
{CATEGORY_ICONS[cat.slug]}
|
||||
</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<fieldset className="sidebar-section" aria-label="Toggle filters">
|
||||
<legend className="sidebar-title">Filters</legend>
|
||||
{filters.map((f) => (
|
||||
<label key={f.key} className="sidebar-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.active}
|
||||
onChange={() => onFilterToggle(f.key)}
|
||||
aria-label={f.label}
|
||||
/>
|
||||
<span>{f.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
+101
-24
@@ -1,34 +1,111 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { getSiteName } from "../lib/site";
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
export function Footer() {
|
||||
const siteName = getSiteName();
|
||||
return (
|
||||
<footer className="mt-auto px-7 pb-8 pt-12">
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<Separator className="mb-6" />
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.82rem] text-[color:var(--ink-soft)]">
|
||||
<span className="font-semibold text-[color:var(--ink)]">{siteName}</span>
|
||||
<FooterLink href="https://openclaw.ai">OpenClaw</FooterLink>
|
||||
<FooterLink href="https://vercel.com">Vercel</FooterLink>
|
||||
<FooterLink href="https://www.convex.dev">Convex</FooterLink>
|
||||
<FooterLink href="https://github.com/openclaw/clawhub">Open source (MIT)</FooterLink>
|
||||
<FooterLink href="https://steipete.me">Peter Steinberger</FooterLink>
|
||||
<footer className="site-footer" role="contentinfo">
|
||||
<div className="site-footer-inner">
|
||||
<div className="site-footer-divider" aria-hidden="true" />
|
||||
<div className="footer-grid">
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Browse</h4>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
<Link to="/plugins">Plugins</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
<Link to="/users">Users</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Staff Picks
|
||||
</Link>
|
||||
<Link to="/search" search={{ q: undefined, type: undefined }}>
|
||||
Search
|
||||
</Link>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Publish</h4>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish Plugin
|
||||
</Link>
|
||||
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Community</h4>
|
||||
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
<Link to="/about">About</Link>
|
||||
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
|
||||
OpenClaw
|
||||
</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4 className="footer-col-title">Platform</h4>
|
||||
<span>MIT Licensed</span>
|
||||
<a href="https://vercel.com" target="_blank" rel="noreferrer">
|
||||
Deployed on Vercel
|
||||
</a>
|
||||
<a href="https://www.convex.dev" target="_blank" rel="noreferrer">
|
||||
Powered by Convex
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>
|
||||
{siteName} — An{" "}
|
||||
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
|
||||
OpenClaw
|
||||
</a>{" "}
|
||||
project by{" "}
|
||||
<a href="https://steipete.me" target="_blank" rel="noreferrer">
|
||||
Peter Steinberger
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[color:var(--ink-soft)] transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
+366
-271
@@ -1,17 +1,15 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Github, Menu, Monitor, Moon, Search, Sun } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { startThemeTransition } from "../lib/theme-transition";
|
||||
import { useAuthError } from "../lib/useAuthError";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,24 +17,28 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signOut } = useAuthActions();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const siteMode = getSiteMode();
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
|
||||
const isSoulMode = siteMode === "souls";
|
||||
const clawHubUrl = getClawHubSiteUrl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined);
|
||||
const handle = me?.handle ?? me?.displayName ?? "user";
|
||||
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
|
||||
const isStaff = isModerator(me);
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const [navSearchQuery, setNavSearchQuery] = useState("");
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
|
||||
const setTheme = (next: "system" | "light" | "dark") => {
|
||||
startThemeTransition({
|
||||
@@ -51,277 +53,370 @@ export default function Header() {
|
||||
});
|
||||
};
|
||||
|
||||
const navLinks = (
|
||||
<>
|
||||
{isSoulMode ? (
|
||||
<a
|
||||
href={clawHubUrl}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/plugins"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Plugins
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={isSoulMode ? "/souls" : "/skills"}
|
||||
search={
|
||||
isSoulMode
|
||||
? { q: undefined, sort: undefined, dir: undefined, view: undefined, focus: "search" }
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: "search",
|
||||
}
|
||||
}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)] inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Search
|
||||
</Link>
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{me ? (
|
||||
<Link
|
||||
to="/stars"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Stars
|
||||
</Link>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link
|
||||
to="/management"
|
||||
search={{ skill: undefined }}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Management
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const handleNavSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = navSearchQuery.trim();
|
||||
if (!q) return;
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q, type: undefined },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[color:var(--line)] bg-[color:var(--nav-bg)] backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-4 px-5">
|
||||
{/* Brand */}
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="flex items-center gap-2.5 font-display text-lg font-bold text-[color:var(--ink)] no-underline transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] p-0.5">
|
||||
<img
|
||||
src="/clawd-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
{/* Row 1: Brand + Search + Actions */}
|
||||
<div className="navbar-top">
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="brand"
|
||||
>
|
||||
<span className="brand-mark">
|
||||
<img src="/clawd-logo.png" alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="brand-name">{siteName}</span>
|
||||
</Link>
|
||||
|
||||
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
aria-label="Search"
|
||||
/>
|
||||
</span>
|
||||
<span>{siteName}</span>
|
||||
</Link>
|
||||
</form>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-6 md:flex">{navLinks}</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Publish CTA (desktop, authenticated) */}
|
||||
{isAuthenticated && me && (
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
className="hidden sm:block"
|
||||
<div className="nav-actions">
|
||||
<button
|
||||
className="navbar-search-mobile-trigger"
|
||||
type="button"
|
||||
aria-label="Search"
|
||||
onClick={() => setMobileSearchOpen(!mobileSearchOpen)}
|
||||
>
|
||||
<Button variant="primary" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Publish
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile nav trigger */}
|
||||
<div className="md:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Open menu">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-72">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{siteName}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-4">{navLinks}</nav>
|
||||
{/* Mobile theme toggle */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-[color:var(--ink-soft)]">
|
||||
Theme
|
||||
</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{/* Mobile publish link */}
|
||||
{isAuthenticated && me && (
|
||||
<div className="mt-6">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Button variant="primary" className="w-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
Publish Skill
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Desktop theme toggle */}
|
||||
<div className="theme-toggle hidden md:block" ref={toggleRef}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{/* User menu / Sign in */}
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] px-2 py-1.5 text-sm font-semibold text-[color:var(--ink)] transition-colors hover:border-[color:var(--border-ui-hover)]"
|
||||
>
|
||||
<Avatar className="h-7 w-7">
|
||||
{avatar && (
|
||||
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden font-mono text-xs sm:inline">@{handle}</span>
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">▾</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{authError ? (
|
||||
<div
|
||||
className="flex items-center gap-1 text-[0.85rem] text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{authError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAuthError}
|
||||
aria-label="Dismiss"
|
||||
className="ml-1 cursor-pointer border-none bg-transparent p-0.5 text-inherit opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="nav-mobile">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="nav-mobile-trigger" type="button" aria-label="Open menu">
|
||||
<Menu className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<SignInButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isSoulMode ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={clawHubUrl}>ClawHub</a>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/plugins">Plugins</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/users" search={{ q: undefined }}>
|
||||
Users
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about">About</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{me ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stars">Stars</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{me ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/management" search={{ skill: undefined }}>
|
||||
Management
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="theme-toggle" ref={toggleRef}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<span>Sign in</span>
|
||||
<span className="hidden text-white/70 sm:inline">with GitHub</span>
|
||||
</SignInButton>
|
||||
</>
|
||||
)}
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">System</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Light</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Dark</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="user-trigger" type="button">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
|
||||
) : (
|
||||
<span className="user-menu-fallback">{initial}</span>
|
||||
)}
|
||||
<span className="mono">@{handle}</span>
|
||||
<span className="user-menu-chevron">▾</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{authError ? (
|
||||
<div className="error" role="alert" style={{ fontSize: "0.85rem", marginRight: 8 }}>
|
||||
{authError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAuthError}
|
||||
aria-label="Dismiss"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
void signIn(
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Github size={16} aria-hidden="true" />
|
||||
<span className="sign-in-label">Sign in</span>
|
||||
<span className="sign-in-provider">with GitHub</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile search bar (expandable) */}
|
||||
{mobileSearchOpen ? (
|
||||
<form className="navbar-search-mobile" onSubmit={handleNavSearch}>
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="text"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Content type tabs */}
|
||||
<nav className="navbar-tabs" aria-label="Content types">
|
||||
<div className="navbar-tabs-primary">
|
||||
{isSoulMode ? (
|
||||
<a href={clawHubUrl} className="navbar-tab">
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
className="navbar-tab"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
className="navbar-tab"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link to="/plugins" className="navbar-tab">
|
||||
Plugins
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/souls"
|
||||
className="navbar-tab"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="navbar-tabs-secondary">
|
||||
{isSoulMode ? null : (
|
||||
<Link to="/users" search={{ q: undefined }} className="navbar-tab navbar-tab-secondary">
|
||||
Users
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link to="/about" className="navbar-tab navbar-tab-secondary">
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{me ? (
|
||||
<Link to="/stars" className="navbar-tab navbar-tab-secondary">
|
||||
Stars
|
||||
</Link>
|
||||
) : null}
|
||||
{me ? (
|
||||
<Link to="/dashboard" className="navbar-tab navbar-tab-secondary">
|
||||
Dashboard
|
||||
</Link>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link
|
||||
to="/management"
|
||||
search={{ skill: undefined }}
|
||||
className="navbar-tab navbar-tab-secondary"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
|
||||
type PluginListItemProps = {
|
||||
item: PackageListItem;
|
||||
};
|
||||
|
||||
export function PluginListItem({ item }: PluginListItemProps) {
|
||||
return (
|
||||
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
|
||||
<MarketplaceIcon kind="plugin" label={item.displayName} />
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
{item.ownerHandle ? (
|
||||
<>
|
||||
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
|
||||
<span className="skill-list-item-sep">/</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="skill-list-item-name">{item.displayName}</span>
|
||||
<span className="tag tag-compact">{familyLabel(item.family)}</span>
|
||||
{item.isOfficial ? <span className="tag tag-compact tag-accent">Verified</span> : null}
|
||||
</div>
|
||||
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
|
||||
<div className="skill-list-item-meta">
|
||||
<span className="skill-list-item-meta-item">Plugin</span>
|
||||
{item.latestVersion ? (
|
||||
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
|
||||
) : null}
|
||||
<span className="skill-list-item-meta-item">
|
||||
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
type SkillCardProps = {
|
||||
skill: PublicSkill;
|
||||
@@ -12,7 +11,6 @@ type SkillCardProps = {
|
||||
summaryFallback: string;
|
||||
meta: ReactNode;
|
||||
href?: string;
|
||||
verified?: boolean;
|
||||
};
|
||||
|
||||
export function SkillCard({
|
||||
@@ -23,7 +21,6 @@ export function SkillCard({
|
||||
summaryFallback,
|
||||
meta,
|
||||
href,
|
||||
verified,
|
||||
}: SkillCardProps) {
|
||||
const owner = encodeURIComponent(String(skill.ownerUserId));
|
||||
const link = href ?? `/${owner}/${skill.slug}`;
|
||||
@@ -31,43 +28,28 @@ export function SkillCard({
|
||||
const hasTags = badges.length || chip || platformLabels?.length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="group flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)] hover:border-[color:var(--border-ui-hover)]"
|
||||
>
|
||||
<Link to={link} className="card skill-card">
|
||||
{hasTags ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="skill-card-tags">
|
||||
{badges.map((label) => (
|
||||
<Badge key={label} variant="default">
|
||||
<div key={label} className="tag">
|
||||
{label}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{chip ? (
|
||||
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
|
||||
{chip}
|
||||
</Badge>
|
||||
) : null}
|
||||
{chip ? <div className="tag tag-accent tag-compact">{chip}</div> : null}
|
||||
{platformLabels?.map((label) => (
|
||||
<Badge key={label} variant="compact">
|
||||
<div key={label} className="tag tag-compact">
|
||||
{label}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{verified && (
|
||||
<span className="inline-flex items-center gap-1 text-[0.72rem] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="font-display text-base font-bold leading-tight text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? summaryFallback}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-col gap-2 pt-1 text-[0.82rem] text-[color:var(--ink-soft)]">
|
||||
{meta}
|
||||
<div className="skill-card-header">
|
||||
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
|
||||
<h3 className="skill-card-title">{skill.displayName}</h3>
|
||||
</div>
|
||||
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
|
||||
<div className="skill-card-footer">{meta}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+150
-144
@@ -2,19 +2,15 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { canManageSkill, isModerator } from "../lib/roles";
|
||||
import { hasOwnProperty } from "../lib/hasOwnProperty";
|
||||
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Container } from "./layout/Container";
|
||||
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
|
||||
import { SkillCommentsPanel } from "./SkillCommentsPanel";
|
||||
import { SkillDetailTabs } from "./SkillDetailTabs";
|
||||
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
|
||||
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
|
||||
import {
|
||||
buildSkillHref,
|
||||
formatConfigSnippet,
|
||||
@@ -25,8 +21,6 @@ import {
|
||||
import { SkillHeader } from "./SkillHeader";
|
||||
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
|
||||
import { SkillReportDialog } from "./SkillReportDialog";
|
||||
import { Card } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
slug: string;
|
||||
@@ -38,11 +32,13 @@ type SkillDetailPageProps = {
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
if (hasOwnProperty(error, "data")) {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (typeof data === "string" && data.trim()) return data.trim();
|
||||
if (
|
||||
hasOwnProperty(data, "message") &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"message" in data &&
|
||||
typeof (data as { message?: unknown }).message === "string"
|
||||
) {
|
||||
const message = (data as { message?: string }).message?.trim();
|
||||
@@ -98,7 +94,7 @@ export function SkillDetailPage({
|
||||
);
|
||||
const [tagName, setTagName] = useState("latest");
|
||||
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
|
||||
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
|
||||
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
|
||||
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState("");
|
||||
@@ -215,7 +211,6 @@ export function SkillDetailPage({
|
||||
?.clawdis;
|
||||
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
|
||||
const nixPlugin = clawdis?.nix?.plugin;
|
||||
const nixSystems = clawdis?.nix?.systems ?? [];
|
||||
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null;
|
||||
const configRequirements = clawdis?.config;
|
||||
const configExample = configRequirements?.example
|
||||
@@ -299,13 +294,10 @@ export function SkillDetailPage({
|
||||
|
||||
const deleteTag = (tag: string) => {
|
||||
if (!skill) return;
|
||||
toast(`Delete tag "${tag}"?`, {
|
||||
action: {
|
||||
label: "Delete",
|
||||
onClick: () => {
|
||||
void deleteTags({ skillId: skill._id, tags: [tag] });
|
||||
},
|
||||
},
|
||||
if (!window.confirm(`Delete tag "${tag}"?`)) return;
|
||||
void deleteTags({
|
||||
skillId: skill._id,
|
||||
tags: [tag],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -324,9 +316,9 @@ export function SkillDetailPage({
|
||||
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
|
||||
closeReportDialog();
|
||||
if (submission.reported) {
|
||||
toast.success("Thanks — your report has been submitted.");
|
||||
window.alert("Thanks — your report has been submitted.");
|
||||
} else {
|
||||
toast.info("You have already reported this skill.");
|
||||
window.alert("You have already reported this skill.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to report skill", error);
|
||||
@@ -336,19 +328,19 @@ export function SkillDetailPage({
|
||||
};
|
||||
|
||||
if (isLoadingSkill || wantsCanonicalRedirect) {
|
||||
return <SkillDetailSkeleton />;
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading skill…</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null || !skill) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<EmptyState
|
||||
title="Skill not found"
|
||||
description="The skill you're looking for doesn't exist or may have been removed."
|
||||
action={{ label: "Browse skills", href: "/skills" }}
|
||||
/>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<div className="card">Skill not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -356,128 +348,142 @@ export function SkillDetailPage({
|
||||
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SkillHeader
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<SkillHeader
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={() => void toggleStar({ skillId: skill._id })}
|
||||
onOpenReport={openReportDialog}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffModerationNote={staffModerationNote}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={tagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
/>
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
{nixSnippet ? (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code" style={{ marginTop: 8 }}>
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{configExample ? (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code" style={{ marginTop: 8 }}>
|
||||
{configExample}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<div className="card">
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
Comments
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
Loading comments...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<SkillMetadataSidebar
|
||||
skill={skill}
|
||||
latestVersion={latestVersion}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={() => void toggleStar({ skillId: skill._id })}
|
||||
onOpenReport={openReportDialog}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffModerationNote={staffModerationNote}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={tagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
tagEntries={tagEntries}
|
||||
isMalwareBlocked={modInfo?.isMalwareBlocked}
|
||||
isRemoved={modInfo?.isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
/>
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Install via Nix
|
||||
</h2>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"}
|
||||
</p>
|
||||
<pre className="hero-install-code mt-3">{nixSnippet}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Config example
|
||||
</h2>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Starter config for this plugin bundle.
|
||||
</p>
|
||||
<pre className="hero-install-code mt-3">{configExample}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Comments</h2>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
/>
|
||||
</Container>
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { SkillVersionsPanel } from "./SkillVersionsPanel";
|
||||
import { Card } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
|
||||
const SkillDiffCard = lazy(() =>
|
||||
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
|
||||
@@ -15,9 +14,11 @@ const SkillFilesPanel = lazy(() =>
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
export type DetailTab = "readme" | "files" | "compare" | "versions";
|
||||
|
||||
type SkillDetailTabsProps = {
|
||||
activeTab: "files" | "compare" | "versions";
|
||||
setActiveTab: (tab: "files" | "compare" | "versions") => void;
|
||||
activeTab: DetailTab;
|
||||
setActiveTab: (tab: DetailTab) => void;
|
||||
onCompareIntent: () => void;
|
||||
readmeContent: string | null;
|
||||
readmeError: string | null;
|
||||
@@ -46,13 +47,30 @@ export function SkillDetailTabs({
|
||||
suppressVersionScanResults,
|
||||
scanResultsSuppressedMessage,
|
||||
}: SkillDetailTabsProps) {
|
||||
const compareEnabled = (versions?.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="compare"
|
||||
<div className="card tab-card">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-button${activeTab === "readme" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("readme")}
|
||||
>
|
||||
README
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button${activeTab === "files" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("files")}
|
||||
>
|
||||
Files
|
||||
</button>
|
||||
{compareEnabled ? (
|
||||
<button
|
||||
className={`tab-button${activeTab === "compare" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("compare")}
|
||||
onMouseEnter={() => {
|
||||
onCompareIntent();
|
||||
void import("./SkillDiffCard");
|
||||
@@ -63,37 +81,64 @@ export function SkillDetailTabs({
|
||||
}}
|
||||
>
|
||||
Compare
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="versions">Versions</TabsTrigger>
|
||||
</TabsList>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("versions")}
|
||||
>
|
||||
Versions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="files">
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
|
||||
<SkillFilesPanel
|
||||
versionId={latestVersionId}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
{activeTab === "readme" ? (
|
||||
<div className="tab-body">
|
||||
{readmeContent ? (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
|
||||
</div>
|
||||
) : readmeError ? (
|
||||
<div className="empty-state" style={{ padding: "var(--space-6) var(--space-4)" }}>
|
||||
<p className="empty-state-title">No README available</p>
|
||||
<p className="empty-state-body">
|
||||
This skill doesn't have a SKILL.md file yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat" style={{ padding: 16 }}>
|
||||
Loading README...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="compare">
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
|
||||
{activeTab === "files" ? (
|
||||
<Suspense fallback={<div className="tab-body stat">Loading file viewer...</div>}>
|
||||
<SkillFilesPanel
|
||||
versionId={latestVersionId}
|
||||
latestFiles={latestFiles}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{activeTab === "compare" ? (
|
||||
<div className="tab-body">
|
||||
<Suspense fallback={<div className="stat">Loading diff viewer...</div>}>
|
||||
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="versions">
|
||||
<SkillVersionsPanel
|
||||
versions={versions}
|
||||
nixPlugin={nixPlugin}
|
||||
skillSlug={skill.slug}
|
||||
suppressScanResults={suppressVersionScanResults}
|
||||
suppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
{activeTab === "versions" ? (
|
||||
<SkillVersionsPanel
|
||||
versions={versions}
|
||||
nixPlugin={nixPlugin}
|
||||
skillSlug={skill.slug}
|
||||
suppressScanResults={suppressVersionScanResults}
|
||||
suppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,6 @@ import {
|
||||
sortVersionsBySemver,
|
||||
} from "../lib/diffing";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card } from "./ui/card";
|
||||
import { Label } from "./ui/label";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
type SkillDiffCardProps = {
|
||||
skill: Doc<"skills">;
|
||||
@@ -277,174 +272,145 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
const fileSelected = Boolean(selectedItem);
|
||||
const diffOptions = useMemo(() => buildDiffOptions(viewMode), [viewMode]);
|
||||
|
||||
const Wrapper = variant === "card" ? Card : "div";
|
||||
const wrapperClassName = variant === "card" ? "flex flex-col gap-4" : "flex flex-col gap-4";
|
||||
const containerClass = variant === "card" ? "card diff-card" : "diff-card diff-card-embedded";
|
||||
|
||||
return (
|
||||
<Wrapper className={wrapperClassName}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className={containerClass}>
|
||||
<div className="diff-header">
|
||||
<div>
|
||||
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
|
||||
<h2 className="section-title" style={{ fontSize: "1.2rem", margin: 0 }}>
|
||||
Compare versions
|
||||
</h2>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
Inline or side-by-side diff for any file.
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="inline-flex items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-[3px]">
|
||||
<legend className="sr-only">Diff layout</legend>
|
||||
<button
|
||||
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
|
||||
viewMode === "split"
|
||||
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
|
||||
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("split")}
|
||||
>
|
||||
Side-by-side
|
||||
</button>
|
||||
<button
|
||||
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
|
||||
viewMode === "inline"
|
||||
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
|
||||
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("inline")}
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
</fieldset>
|
||||
{!diffUnavailable ? (
|
||||
<fieldset className="diff-toggle-group">
|
||||
<legend className="sr-only">Diff layout</legend>
|
||||
<button
|
||||
className={`diff-toggle${viewMode === "split" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("split")}
|
||||
>
|
||||
Side-by-side
|
||||
</button>
|
||||
<button
|
||||
className={`diff-toggle${viewMode === "inline" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("inline")}
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
|
||||
<Label htmlFor="diff-left">Left</Label>
|
||||
<select
|
||||
id="diff-left"
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={leftVersionId ?? ""}
|
||||
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLeftVersionId(rightVersionId);
|
||||
setRightVersionId(leftVersionId);
|
||||
}}
|
||||
disabled={!leftVersionId || !rightVersionId}
|
||||
>
|
||||
Swap
|
||||
</Button>
|
||||
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
|
||||
<Label htmlFor="diff-right">Right</Label>
|
||||
<select
|
||||
id="diff-right"
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={rightVersionId ?? ""}
|
||||
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{!diffUnavailable ? (
|
||||
<>
|
||||
<div className="diff-controls">
|
||||
<div className="diff-select">
|
||||
<label htmlFor="diff-left">Left</label>
|
||||
<select
|
||||
id="diff-left"
|
||||
className="search-input"
|
||||
value={leftVersionId ?? ""}
|
||||
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="btn diff-swap"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLeftVersionId(rightVersionId);
|
||||
setRightVersionId(leftVersionId);
|
||||
}}
|
||||
disabled={!leftVersionId || !rightVersionId}
|
||||
>
|
||||
Swap
|
||||
</button>
|
||||
<div className="diff-select">
|
||||
<label htmlFor="diff-right">Right</label>
|
||||
<select
|
||||
id="diff-right"
|
||||
className="search-input"
|
||||
value={rightVersionId ?? ""}
|
||||
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-[color:var(--ink-soft)]">
|
||||
<span>
|
||||
Left {leftLabel} • Right {rightLabel}
|
||||
</span>
|
||||
{diffUnavailable ? <span>Need at least 2 versions.</span> : null}
|
||||
</div>
|
||||
<div className="diff-meta">
|
||||
<span>
|
||||
Left {leftLabel} • Right {rightLabel}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(160px,220px)_1fr]">
|
||||
<div className="flex max-h-[500px] flex-col overflow-y-auto border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] md:border-r md:border-b-0">
|
||||
{fileDiffItems.length === 0 ? (
|
||||
<div className="p-3 text-sm text-[color:var(--ink-soft)]">No files to compare.</div>
|
||||
<div className="diff-layout">
|
||||
<div className="diff-files">
|
||||
{diffUnavailable ? (
|
||||
<div className="diff-empty">
|
||||
Publish another version to compare changes side by side.
|
||||
</div>
|
||||
) : fileDiffItems.length === 0 ? (
|
||||
<div className="diff-empty">No files to compare.</div>
|
||||
) : (
|
||||
fileDiffItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer items-center gap-2 border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface)] ${
|
||||
item.path === selectedPath
|
||||
? "bg-[color:var(--surface)] font-semibold text-[color:var(--ink)]"
|
||||
: "bg-transparent text-[color:var(--ink)]"
|
||||
}`}
|
||||
className={`diff-file${item.path === selectedPath ? " is-active" : ""}`}
|
||||
onClick={() => setSelectedPath(item.path)}
|
||||
>
|
||||
<Badge
|
||||
variant={
|
||||
item.status === "added"
|
||||
? "success"
|
||||
: item.status === "removed"
|
||||
? "destructive"
|
||||
: item.status === "changed"
|
||||
? "warning"
|
||||
: "compact"
|
||||
}
|
||||
className="shrink-0 text-[0.65rem]"
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-xs">{item.path}</span>
|
||||
<span className={`diff-pill diff-pill-${item.status}`}>{item.status}</span>
|
||||
<span className="diff-file-name">{item.path}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-h-[300px]">
|
||||
<div className="diff-view">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
{error}
|
||||
</div>
|
||||
<div className="diff-empty">{error}</div>
|
||||
) : sizeWarning ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
<div className="diff-empty">
|
||||
{sizeWarning.side === "left" ? "Left" : "Right"} file exceeds 200KB:{" "}
|
||||
{sizeWarning.path}
|
||||
</div>
|
||||
) : diffUnavailable ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Publish another version to compare.
|
||||
</div>
|
||||
<div className="diff-empty">Publish another version to compare.</div>
|
||||
) : !selectionReady ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Select two versions to compare.
|
||||
</div>
|
||||
<div className="diff-empty">Select two versions to compare.</div>
|
||||
) : !fileSelected ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Select a file to compare.
|
||||
</div>
|
||||
<div className="diff-empty">Select a file to compare.</div>
|
||||
) : (
|
||||
<ClientOnly fallback={<Skeleton className="h-full w-full" />}>
|
||||
<ClientOnly fallback={<div className="diff-empty">Preparing diff…</div>}>
|
||||
<DiffEditor
|
||||
key={`diff-${viewMode}`}
|
||||
className={`h-full min-h-[400px] w-full ${viewMode === "inline" ? "max-w-full" : ""}`}
|
||||
className={`diff-monaco diff-monaco-${viewMode}`}
|
||||
original={leftText}
|
||||
modified={rightText}
|
||||
theme={getMonacoThemeName()}
|
||||
loading={<Skeleton className="h-full w-full" />}
|
||||
loading={<div className="diff-empty">Loading diff…</div>}
|
||||
options={diffOptions}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--surface)]/80">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? <div className="diff-loading">Loading…</div> : null}
|
||||
</ClientOnly>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -2,23 +2,17 @@ import { useAction } from "convex/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { MarkdownPreview } from "./MarkdownPreview";
|
||||
import { formatBytes } from "./skillDetailUtils";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
type SkillFilesPanelProps = {
|
||||
versionId: Id<"skillVersions"> | null;
|
||||
readmeContent: string | null;
|
||||
readmeError: string | null;
|
||||
latestFiles: SkillFile[];
|
||||
};
|
||||
|
||||
export function SkillFilesPanel({
|
||||
versionId,
|
||||
readmeContent,
|
||||
readmeError,
|
||||
latestFiles,
|
||||
}: SkillFilesPanelProps) {
|
||||
const getFileText = useAction(api.skills.getFileText);
|
||||
@@ -91,82 +85,56 @@ export function SkillFilesPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid max-w-full gap-5 overflow-x-auto">
|
||||
<div>
|
||||
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
|
||||
SKILL.md
|
||||
</h2>
|
||||
<div>
|
||||
{readmeContent ? (
|
||||
<MarkdownPreview>{readmeContent}</MarkdownPreview>
|
||||
) : readmeError ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
Failed to load SKILL.md: {readmeError}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(180px,280px)_1fr]">
|
||||
<div className="flex flex-col border-b border-[color:var(--line)] md:border-r md:border-b-0">
|
||||
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
|
||||
<h3 className="m-0 font-display text-[1.05rem] font-bold text-[color:var(--ink)]">
|
||||
<div className="tab-body">
|
||||
<div className="file-browser">
|
||||
<div className="file-list">
|
||||
<div className="file-list-header">
|
||||
<h3 className="section-title" style={{ fontSize: "1.05rem", margin: 0 }}>
|
||||
Files
|
||||
</h3>
|
||||
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="section-subtitle" style={{ margin: 0 }}>
|
||||
{latestFiles.length} total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex max-h-[400px] flex-col overflow-y-auto">
|
||||
<div className="file-list-body">
|
||||
{latestFiles.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-[color:var(--ink-soft)]">
|
||||
No files available.
|
||||
</div>
|
||||
<div className="stat">No files available.</div>
|
||||
) : (
|
||||
latestFiles.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
className={`flex w-full cursor-pointer items-center justify-between border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-muted)] ${
|
||||
selectedPath === file.path
|
||||
? "bg-[color:var(--surface-muted)] font-semibold text-[color:var(--ink)]"
|
||||
: "bg-transparent text-[color:var(--ink)]"
|
||||
className={`file-row file-row-button${
|
||||
selectedPath === file.path ? " is-active" : ""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => handleSelect(file.path)}
|
||||
aria-current={selectedPath === file.path ? "true" : undefined}
|
||||
>
|
||||
<span className="truncate font-mono text-xs">{file.path}</span>
|
||||
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
<span className="file-path">{file.path}</span>
|
||||
<span className="file-meta">{formatBytes(file.size)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
|
||||
<div className="truncate font-mono text-xs">{selectedPath ?? "Select a file"}</div>
|
||||
<div className="file-viewer">
|
||||
<div className="file-viewer-header">
|
||||
<div className="file-path">{selectedPath ?? "Select a file"}</div>
|
||||
{fileMeta ? (
|
||||
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="file-meta">
|
||||
{formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-[200px] p-3">
|
||||
<div className="file-viewer-body">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<div className="stat">Loading…</div>
|
||||
) : fileError ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
Failed to load file: {fileError}
|
||||
</div>
|
||||
<div className="stat">Failed to load file: {fileError}</div>
|
||||
) : fileContent ? (
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
|
||||
{fileContent}
|
||||
</pre>
|
||||
<pre className="file-viewer-code">{fileContent}</pre>
|
||||
) : (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">Select a file to preview.</div>
|
||||
<div className="stat">Select a file to preview.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+154
-211
@@ -1,21 +1,12 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Package } from "lucide-react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { SkillInstallCard } from "./SkillInstallCard";
|
||||
|
||||
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { Input } from "./ui/input";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
export type SkillModerationInfo = {
|
||||
@@ -117,10 +108,9 @@ export function SkillHeader({
|
||||
onTagSubmit,
|
||||
onTagDelete,
|
||||
tagVersions,
|
||||
clawdis,
|
||||
osLabels,
|
||||
clawdis: _clawdis,
|
||||
osLabels: _osLabels,
|
||||
}: SkillHeaderProps) {
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
const formattedStats = formatSkillStatsTriplet(skill.stats);
|
||||
const suppressScanResults =
|
||||
!isStaff &&
|
||||
@@ -134,8 +124,8 @@ export function SkillHeader({
|
||||
return (
|
||||
<>
|
||||
{modInfo?.isPendingScan ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Security scan in progress</strong>
|
||||
<p>
|
||||
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
|
||||
@@ -145,8 +135,8 @@ export function SkillHeader({
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isMalwareBlocked ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill blocked — malicious content detected</strong>
|
||||
<p>
|
||||
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
|
||||
@@ -155,15 +145,15 @@ export function SkillHeader({
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isSuspicious ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-warning">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill flagged — suspicious patterns detected</strong>
|
||||
<p>
|
||||
ClawHub Security flagged this skill as suspicious. Review the scan results before
|
||||
using.
|
||||
</p>
|
||||
{canManage ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
<p className="pending-banner-appeal">
|
||||
If you believe this skill has been incorrectly flagged, please{" "}
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/issues"
|
||||
@@ -178,142 +168,109 @@ export function SkillHeader({
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isRemoved ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill removed by moderator</strong>
|
||||
<p>This skill has been removed and is not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isHiddenByMod ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill hidden</strong>
|
||||
<p>This skill is currently hidden and not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<div className={`flex flex-col gap-5${hasPluginBundle ? " pb-2" : ""}`}>
|
||||
<div className="flex flex-col gap-5 md:flex-row md:gap-8">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
{latestVersion?.version ? (
|
||||
<Badge variant="compact">v{latestVersion.version}</Badge>
|
||||
<div className="card skill-hero">
|
||||
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
|
||||
<div className="skill-hero-header">
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
{latestVersion?.version ? (
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <span className="tag tag-accent">Plugin bundle (nix)</span> : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="skill-hero-note">{staffModerationNote}</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat">⭐ {formattedStats.stars}</span>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="stat">{formattedStats.installsAllTime} all-time</span>
|
||||
</div>
|
||||
<div className="skill-hero-meta-row">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? "No summary provided."}
|
||||
</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
|
||||
{staffModerationNote}
|
||||
</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
⭐ {formattedStats.stars}
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="flex items-center gap-1 text-sm text-[color:var(--ink-soft)]">
|
||||
<Package size={14} aria-hidden="true" /> {formattedStats.downloads}
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
{formattedStats.installsAllTime} all-time
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
<Badge variant="compact">{PLATFORM_SKILL_LICENSE}</Badge>
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</Badge>
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span style={{ color: "var(--ink-soft)", opacity: 0.4 }}>·</span>
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<span key={badge} className="tag tag-compact">
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<span className={`tag tag-compact${isAutoHidden || isRemoved ? " tag-accent" : ""}`}>
|
||||
{staffVisibilityTag}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 md:w-[220px] md:shrink-0">
|
||||
{!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
|
||||
<a
|
||||
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
|
||||
className="inline-flex w-full items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
|
||||
>
|
||||
Download zip
|
||||
</a>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold text-[color:var(--ink-soft)]">
|
||||
License
|
||||
</span>
|
||||
<span className="text-sm text-[color:var(--ink)]">
|
||||
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_SUMMARY}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="skill-hero-sidebar">
|
||||
<div className="skill-actions">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full border transition-all duration-200 ${isStarred ? "border-amber-400/60 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-950/40" : "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:text-amber-500"}`}
|
||||
className={`star-toggle${isStarred ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? "Unstar skill" : "Star skill"}
|
||||
@@ -322,16 +279,12 @@ export function SkillHeader({
|
||||
</button>
|
||||
) : null}
|
||||
{isAuthenticated ? (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenReport}>
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={onOpenReport}>
|
||||
Report
|
||||
</Button>
|
||||
</button>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link
|
||||
to="/management"
|
||||
search={{ skill: skill.slug }}
|
||||
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-xs min-h-[34px] rounded-[var(--radius-pill)] px-3 py-1.5 border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] transition-all duration-200 no-underline"
|
||||
>
|
||||
<Link className="btn btn-sm" to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
) : null}
|
||||
@@ -341,13 +294,12 @@ export function SkillHeader({
|
||||
|
||||
{/* Security scan — full width below the header columns */}
|
||||
{suppressScanResults ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
|
||||
{overrideScanMessage}
|
||||
</div>
|
||||
<div className="skill-hero-note">{overrideScanMessage}</div>
|
||||
) : latestVersion?.sha256hash ||
|
||||
latestVersion?.llmAnalysis ||
|
||||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
|
||||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
|
||||
<div className="skill-hero-scan-row">
|
||||
<SecurityScanResults
|
||||
sha256hash={latestVersion?.sha256hash}
|
||||
vtAnalysis={latestVersion?.vtAnalysis}
|
||||
@@ -355,77 +307,67 @@ export function SkillHeader({
|
||||
staticFindings={latestVersion?.staticScan?.findings}
|
||||
capabilityTags={latestVersion?.capabilityTags}
|
||||
/>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
<p className="scan-disclaimer">
|
||||
Like a lobster shell, security has layers — review code before you run it.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{hasPluginBundle ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Plugin bundle (nix)
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
Skill pack · CLI binary · Config
|
||||
<div className="skill-panel bundle-card">
|
||||
<div className="bundle-header">
|
||||
<div className="bundle-title">Plugin bundle (nix)</div>
|
||||
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
|
||||
</div>
|
||||
<div className="bundle-includes">
|
||||
<span>SKILL.md</span>
|
||||
<span>CLI</span>
|
||||
<span>Config</span>
|
||||
</div>
|
||||
{configRequirements ? (
|
||||
<div className="bundle-section">
|
||||
<div className="bundle-section-title">Config requirements</div>
|
||||
<div className="bundle-meta">
|
||||
{configRequirements.requiredEnv?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Required env</strong>
|
||||
<span>{configRequirements.requiredEnv.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{configRequirements.stateDirs?.length ? (
|
||||
<div className="stat">
|
||||
<strong>State dirs</strong>
|
||||
<span>{configRequirements.stateDirs.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge>SKILL.md</Badge>
|
||||
<Badge>CLI</Badge>
|
||||
<Badge>Config</Badge>
|
||||
</div>
|
||||
{configRequirements ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Config requirements
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{configRequirements.requiredEnv?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Required env</strong>
|
||||
<span>{configRequirements.requiredEnv.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{configRequirements.stateDirs?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>State dirs</strong>
|
||||
<span>{configRequirements.stateDirs.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{cliHelp ? (
|
||||
<details className="flex flex-col gap-2">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-[color:var(--ink)]">
|
||||
CLI help (from plugin)
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] p-3 font-mono text-xs">
|
||||
{cliHelp}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{cliHelp ? (
|
||||
<details className="bundle-section bundle-details">
|
||||
<summary>CLI help (from plugin)</summary>
|
||||
<pre className="hero-install-code mono">{cliHelp}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-[color:var(--line)] pt-4">
|
||||
<div className="skill-tag-row">
|
||||
{tagEntries.length === 0 ? (
|
||||
<span className="m-0 text-sm text-[color:var(--ink-soft)]">No tags yet.</span>
|
||||
<span className="section-subtitle" style={{ margin: 0 }}>
|
||||
No tags yet.
|
||||
</span>
|
||||
) : (
|
||||
tagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag} className="gap-1.5">
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
<span className="text-[0.68rem] opacity-70">
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{canManage && tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 cursor-pointer border-none bg-transparent p-0 text-current opacity-60 hover:opacity-100"
|
||||
className="tag-delete"
|
||||
onClick={() => onTagDelete(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
@@ -433,7 +375,7 @@ export function SkillHeader({
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -444,16 +386,16 @@ export function SkillHeader({
|
||||
event.preventDefault();
|
||||
onTagSubmit();
|
||||
}}
|
||||
className="flex flex-wrap items-end gap-2 border-t border-[color:var(--line)] pt-4"
|
||||
className="tag-form"
|
||||
>
|
||||
<Input
|
||||
<input
|
||||
className="search-input"
|
||||
value={tagName}
|
||||
onChange={(event) => onTagNameChange(event.target.value)}
|
||||
placeholder="latest"
|
||||
className="w-auto max-w-[160px]"
|
||||
/>
|
||||
<select
|
||||
className="min-h-[44px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
className="search-input"
|
||||
value={tagVersionId ?? ""}
|
||||
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
@@ -463,12 +405,13 @@ export function SkillHeader({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">Update tag</Button>
|
||||
<button className="btn" type="submit">
|
||||
Update tag
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+173
-216
@@ -1,12 +1,5 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
|
||||
type SkillInstallCardProps = {
|
||||
clawdis: ClawdisSkillMetadata | undefined;
|
||||
@@ -32,229 +25,193 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
const hasInstallSpecs = installSpecs.length > 0;
|
||||
const hasDependencies = dependencies.length > 0;
|
||||
const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation);
|
||||
const hasLicense = true;
|
||||
|
||||
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks && !hasLicense) {
|
||||
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] pt-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
License
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Badge variant="accent">{PLATFORM_SKILL_LICENSE}</Badge>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<span>{PLATFORM_SKILL_LICENSE_SUMMARY}</span>
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Terms</strong>
|
||||
<a
|
||||
href={PLATFORM_SKILL_LICENSE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1"
|
||||
>
|
||||
{PLATFORM_SKILL_LICENSE_URL}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="skill-hero-content">
|
||||
<div className="skill-hero-panels">
|
||||
{hasRuntimeRequirements ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Runtime requirements
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
|
||||
{osLabels.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>OS</strong>
|
||||
<span className="ml-1">{osLabels.join(" · ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.bins?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Bins</strong>
|
||||
<span className="ml-1">{requirements.bins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.anyBins?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Any bin</strong>
|
||||
<span className="ml-1">{requirements.anyBins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.env?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Env</strong>
|
||||
<span className="ml-1">{requirements.env.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.config?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Config</strong>
|
||||
<span className="ml-1">{requirements.config.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{clawdis?.primaryEnv ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Primary env</strong>
|
||||
<span className="ml-1">{clawdis.primaryEnv}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{envVars.length > 0 ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Environment variables</strong>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{envVars.map((env, index) => (
|
||||
<div key={`${env.name}-${index}`} className="flex items-baseline gap-2">
|
||||
<code className="text-[0.85rem]">{env.name}</code>
|
||||
{env.required === false ? (
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">optional</span>
|
||||
) : env.required === true ? (
|
||||
<span className="text-xs text-[color:var(--accent)]">required</span>
|
||||
) : null}
|
||||
{env.description ? (
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
— {env.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{hasDependencies ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Dependencies
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dependencies.map((dep, index) => (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
Runtime requirements
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{clawdis?.emoji ? <div className="tag">{clawdis.emoji} Clawdis</div> : null}
|
||||
{osLabels.length ? (
|
||||
<div className="stat">
|
||||
<strong>OS</strong>
|
||||
<span>{osLabels.join(" · ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.bins?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Bins</strong>
|
||||
<span>{requirements.bins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.anyBins?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Any bin</strong>
|
||||
<span>{requirements.anyBins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.env?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Env</strong>
|
||||
<span>{requirements.env.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.config?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Config</strong>
|
||||
<span>{requirements.config.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{clawdis?.primaryEnv ? (
|
||||
<div className="stat">
|
||||
<strong>Primary env</strong>
|
||||
<span>{clawdis.primaryEnv}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{envVars.length > 0 ? (
|
||||
<div className="stat">
|
||||
<strong>Environment variables</strong>
|
||||
<div
|
||||
key={`${dep.name}-${index}`}
|
||||
className="text-sm text-[color:var(--ink-soft)]"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{dep.name}</strong>
|
||||
<span className="ml-2 text-[0.85rem] text-[color:var(--ink-soft)]">
|
||||
{dep.type}
|
||||
{dep.version ? ` ${dep.version}` : ""}
|
||||
</span>
|
||||
{dep.url ? (
|
||||
<div className="break-all text-[0.8rem]">
|
||||
<a href={dep.url} target="_blank" rel="noopener noreferrer">
|
||||
{dep.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{dep.repository && dep.repository !== dep.url ? (
|
||||
<div className="text-[0.8rem]">
|
||||
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{hasInstallSpecs ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Install
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{installSpecs.map((spec, index) => {
|
||||
const command = formatInstallCommand(spec);
|
||||
return (
|
||||
<div
|
||||
key={`${spec.id ?? spec.kind}-${index}`}
|
||||
className="text-sm text-[color:var(--ink-soft)]"
|
||||
>
|
||||
<div>
|
||||
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
|
||||
{spec.bins?.length ? (
|
||||
<div className="text-[0.85rem] text-[color:var(--ink-soft)]">
|
||||
Bins: {spec.bins.join(", ")}
|
||||
</div>
|
||||
{envVars.map((env, index) => (
|
||||
<div
|
||||
key={`${env.name}-${index}`}
|
||||
style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}
|
||||
>
|
||||
<code style={{ fontSize: "0.85rem" }}>{env.name}</code>
|
||||
{env.required === false ? (
|
||||
<span style={{ color: "var(--ink-soft)", fontSize: "0.75rem" }}>
|
||||
optional
|
||||
</span>
|
||||
) : env.required === true ? (
|
||||
<span style={{ color: "var(--ink-accent)", fontSize: "0.75rem" }}>
|
||||
required
|
||||
</span>
|
||||
) : null}
|
||||
{command ? (
|
||||
<code className="mt-0.5 block font-mono text-xs">{command}</code>
|
||||
{env.description ? (
|
||||
<span style={{ color: "var(--ink-soft)", fontSize: "0.8rem" }}>
|
||||
— {env.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasDependencies ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
Dependencies
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{dependencies.map((dep, index) => (
|
||||
<div key={`${dep.name}-${index}`} className="stat">
|
||||
<div>
|
||||
<strong>{dep.name}</strong>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ink-soft)",
|
||||
fontSize: "0.85rem",
|
||||
marginLeft: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{dep.type}
|
||||
{dep.version ? ` ${dep.version}` : ""}
|
||||
</span>
|
||||
{dep.url ? (
|
||||
<div style={{ fontSize: "0.8rem", wordBreak: "break-all" }}>
|
||||
<a href={dep.url} target="_blank" rel="noopener noreferrer">
|
||||
{dep.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{dep.repository && dep.repository !== dep.url ? (
|
||||
<div style={{ fontSize: "0.8rem" }}>
|
||||
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasInstallSpecs ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
Install
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{installSpecs.map((spec, index) => {
|
||||
const command = formatInstallCommand(spec);
|
||||
return (
|
||||
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
|
||||
<div>
|
||||
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
|
||||
{spec.bins?.length ? (
|
||||
<div style={{ color: "var(--ink-soft)", fontSize: "0.85rem" }}>
|
||||
Bins: {spec.bins.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
{command ? <code>{command}</code> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasLinks ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Links
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{links?.homepage ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Homepage</strong>
|
||||
<a
|
||||
href={links.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 break-all"
|
||||
>
|
||||
{links.homepage}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.repository ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Repository</strong>
|
||||
<a
|
||||
href={links.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 break-all"
|
||||
>
|
||||
{links.repository}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.documentation ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Docs</strong>
|
||||
<a
|
||||
href={links.documentation}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1"
|
||||
>
|
||||
{links.documentation}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title" style={{ fontSize: "1rem", margin: 0 }}>
|
||||
Links
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{links?.homepage ? (
|
||||
<div className="stat">
|
||||
<strong>Homepage</strong>
|
||||
<a href={links.homepage} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
|
||||
{links.homepage}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.repository ? (
|
||||
<div className="stat">
|
||||
<strong>Repository</strong>
|
||||
<a href={links.repository} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all" }}>
|
||||
{links.repository}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.documentation ? (
|
||||
<div className="stat">
|
||||
<strong>Docs</strong>
|
||||
<a href={links.documentation} target="_blank" rel="noopener noreferrer">
|
||||
{links.documentation}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Package, Star } from "lucide-react";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
|
||||
type SkillListItemProps = {
|
||||
skill: PublicSkill;
|
||||
ownerHandle?: string | null;
|
||||
owner?: PublicPublisher | null;
|
||||
};
|
||||
|
||||
export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) {
|
||||
const handle = ownerHandle ?? owner?.handle ?? null;
|
||||
const ownerSegment = handle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
|
||||
const href = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}`;
|
||||
const badges = getSkillBadges(skill);
|
||||
|
||||
return (
|
||||
<Link to={href} className="skill-list-item">
|
||||
<MarketplaceIcon kind="skill" label={skill.displayName} />
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
{handle ? (
|
||||
<>
|
||||
<span className="skill-list-item-owner">@{handle}</span>
|
||||
<span className="skill-list-item-sep">/</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="skill-list-item-name">{skill.displayName}</span>
|
||||
{badges.map((b) => (
|
||||
<span key={b} className="tag tag-compact">
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
|
||||
<div className="skill-list-item-meta">
|
||||
<span className="skill-list-item-meta-item">Updated {timeAgo(skill.updatedAt)}</span>
|
||||
<span className="skill-list-item-meta-item">
|
||||
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span className="skill-list-item-meta-item">
|
||||
<Package size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { Package, Star } from "lucide-react";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
type SkillMetadataSidebarProps = {
|
||||
skill: PublicSkill;
|
||||
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
clawdis?: ClawdisSkillMetadata;
|
||||
osLabels: string[];
|
||||
tagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
isMalwareBlocked?: boolean;
|
||||
isRemoved?: boolean;
|
||||
nixPlugin?: string;
|
||||
};
|
||||
|
||||
export function SkillMetadataSidebar({
|
||||
skill,
|
||||
latestVersion,
|
||||
owner,
|
||||
ownerHandle,
|
||||
clawdis: _clawdis,
|
||||
osLabels,
|
||||
tagEntries,
|
||||
isMalwareBlocked,
|
||||
isRemoved,
|
||||
nixPlugin,
|
||||
}: SkillMetadataSidebarProps) {
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
|
||||
return (
|
||||
<aside className="detail-sidebar">
|
||||
{/* Download / Install */}
|
||||
{!nixPlugin && !isMalwareBlocked && !isRemoved ? (
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Download</h3>
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
|
||||
style={{ width: "100%", justifyContent: "center" }}
|
||||
>
|
||||
Download zip
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Stats</h3>
|
||||
<div className="sidebar-stat-grid">
|
||||
<div className="sidebar-stat">
|
||||
<span className="sidebar-stat-value">
|
||||
<Package size={14} aria-hidden="true" />
|
||||
{formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span className="sidebar-stat-label">Downloads</span>
|
||||
</div>
|
||||
<div className="sidebar-stat">
|
||||
<span className="sidebar-stat-value">
|
||||
<Star size={14} aria-hidden="true" />
|
||||
{formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span className="sidebar-stat-label">Stars</span>
|
||||
</div>
|
||||
<div className="sidebar-stat">
|
||||
<span className="sidebar-stat-value">
|
||||
{formatCompactStat(skill.stats.versions ?? 0)}
|
||||
</span>
|
||||
<span className="sidebar-stat-label">Versions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Details</h3>
|
||||
<dl className="sidebar-metadata">
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Updated</dt>
|
||||
<dd>{timeAgo(skill.updatedAt)}</dd>
|
||||
</div>
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Created</dt>
|
||||
<dd>{timeAgo(skill.createdAt)}</dd>
|
||||
</div>
|
||||
{latestVersion?.version ? (
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Version</dt>
|
||||
<dd>v{latestVersion.version}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>License</dt>
|
||||
<dd>{PLATFORM_SKILL_LICENSE} ({PLATFORM_SKILL_LICENSE_SUMMARY})</dd>
|
||||
</div>
|
||||
{osLabels.length ? (
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Platforms</dt>
|
||||
<dd>{osLabels.join(", ")}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{tagEntries.length > 0 ? (
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Tags</h3>
|
||||
<div className="sidebar-tags">
|
||||
{tagEntries.map(([tag]) => (
|
||||
<span key={tag} className="tag tag-compact">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Owner */}
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Publisher</h3>
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,6 +21,7 @@ import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as UsersIndexRouteImport } from './routes/users/index'
|
||||
import { Route as SoulsIndexRouteImport } from './routes/souls/index'
|
||||
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
|
||||
import { Route as PluginsIndexRouteImport } from './routes/plugins/index'
|
||||
@@ -95,6 +96,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const UsersIndexRoute = UsersIndexRouteImport.update({
|
||||
id: '/users/',
|
||||
path: '/users/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SoulsIndexRoute = SoulsIndexRouteImport.update({
|
||||
id: '/souls/',
|
||||
path: '/souls/',
|
||||
@@ -187,6 +193,7 @@ export interface FileRoutesByFullPath {
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -214,6 +221,7 @@ export interface FileRoutesByTo {
|
||||
'/plugins': typeof PluginsIndexRoute
|
||||
'/skills': typeof SkillsIndexRoute
|
||||
'/souls': typeof SoulsIndexRoute
|
||||
'/users': typeof UsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -242,6 +250,7 @@ export interface FileRoutesById {
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -271,6 +280,7 @@ export interface FileRouteTypes {
|
||||
| '/plugins/'
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -298,6 +308,7 @@ export interface FileRouteTypes {
|
||||
| '/plugins'
|
||||
| '/skills'
|
||||
| '/souls'
|
||||
| '/users'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -325,6 +336,7 @@ export interface FileRouteTypes {
|
||||
| '/plugins/'
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -353,6 +365,7 @@ export interface RootRouteChildren {
|
||||
PluginsIndexRoute: typeof PluginsIndexRoute
|
||||
SkillsIndexRoute: typeof SkillsIndexRoute
|
||||
SoulsIndexRoute: typeof SoulsIndexRoute
|
||||
UsersIndexRoute: typeof UsersIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -441,6 +454,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/users/': {
|
||||
id: '/users/'
|
||||
path: '/users'
|
||||
fullPath: '/users/'
|
||||
preLoaderRoute: typeof UsersIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/souls/': {
|
||||
id: '/souls/'
|
||||
path: '/souls'
|
||||
@@ -561,6 +581,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
PluginsIndexRoute: PluginsIndexRoute,
|
||||
SkillsIndexRoute: SkillsIndexRoute,
|
||||
SoulsIndexRoute: SoulsIndexRoute,
|
||||
UsersIndexRoute: UsersIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
+109
-155
@@ -1,69 +1,63 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Shield, ShieldAlert } from "lucide-react";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import { getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
|
||||
|
||||
const prohibitedCategories = [
|
||||
{
|
||||
title: "Bypass and unauthorized access",
|
||||
title: 'Bypass and unauthorized access',
|
||||
examples:
|
||||
"Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.",
|
||||
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
|
||||
},
|
||||
{
|
||||
title: "Platform abuse and ban evasion",
|
||||
title: 'Platform abuse and ban evasion',
|
||||
examples:
|
||||
"Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.",
|
||||
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
|
||||
},
|
||||
{
|
||||
title: "Fraud and deception",
|
||||
title: 'Fraud and deception',
|
||||
examples:
|
||||
"Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.",
|
||||
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
|
||||
},
|
||||
{
|
||||
title: "Privacy-invasive surveillance",
|
||||
title: 'Privacy-invasive surveillance',
|
||||
examples:
|
||||
"Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.",
|
||||
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
|
||||
},
|
||||
{
|
||||
title: "Non-consensual impersonation",
|
||||
title: 'Non-consensual impersonation',
|
||||
examples:
|
||||
"Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.",
|
||||
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
|
||||
},
|
||||
{
|
||||
title: "Explicit sexual content",
|
||||
title: 'Explicit sexual content',
|
||||
examples:
|
||||
"NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.",
|
||||
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
|
||||
},
|
||||
{
|
||||
title: "Hidden or misleading execution",
|
||||
title: 'Hidden or misleading execution',
|
||||
examples:
|
||||
"Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.",
|
||||
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
|
||||
},
|
||||
];
|
||||
|
||||
const recentPatterns = [
|
||||
"Create stealth seller accounts after marketplace bans.",
|
||||
"Modify Telegram pairing so unapproved users automatically receive pairing codes.",
|
||||
"Cultivate Reddit or Twitter accounts with undetectable automation.",
|
||||
"Generate professional certificates or invoices for arbitrary use.",
|
||||
"Generate NSFW content with safety checks disabled.",
|
||||
"Scrape leads, enrich contacts, and launch cold outreach at scale.",
|
||||
"Buy, publish, or download leaked data or breach dumps.",
|
||||
"Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.",
|
||||
'Create stealth seller accounts after marketplace bans.',
|
||||
'Modify Telegram pairing so unapproved users automatically receive pairing codes.',
|
||||
'Cultivate Reddit or Twitter accounts with undetectable automation.',
|
||||
'Generate professional certificates or invoices for arbitrary use.',
|
||||
'Generate NSFW content with safety checks disabled.',
|
||||
'Scrape leads, enrich contacts, and launch cold outreach at scale.',
|
||||
'Buy, publish, or download leaked data or breach dumps.',
|
||||
'Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.',
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/about")({
|
||||
export const Route = createFileRoute('/about')({
|
||||
head: () => {
|
||||
const mode = getSiteMode();
|
||||
const siteName = getSiteName(mode);
|
||||
const siteUrl = getSiteUrlForMode(mode);
|
||||
const title = `About · ${siteName}`;
|
||||
const description =
|
||||
"What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.";
|
||||
'What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.';
|
||||
|
||||
return {
|
||||
links: [
|
||||
@@ -74,11 +68,11 @@ export const Route = createFileRoute("/about")({
|
||||
],
|
||||
meta: [
|
||||
{ title },
|
||||
{ name: "description", content: description },
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: `${siteUrl}/about` },
|
||||
{ name: 'description', content: description },
|
||||
{ property: 'og:title', content: title },
|
||||
{ property: 'og:description', content: description },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: `${siteUrl}/about` },
|
||||
],
|
||||
};
|
||||
},
|
||||
@@ -87,126 +81,86 @@ export const Route = createFileRoute("/about")({
|
||||
|
||||
function AboutPage() {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="wide">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 pt-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge>About</Badge>
|
||||
<Badge variant="accent">Policy</Badge>
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
What ClawHub Will Not Host
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
|
||||
evade defenses, abuse platforms, scam people, invade privacy, or enable
|
||||
non-consensual behavior, it does not belong here.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm font-medium text-[color:var(--ink-soft)]">
|
||||
<Shield className="h-4 w-4 shrink-0 text-[color:var(--accent)]" />
|
||||
We moderate based on end-to-end abuse patterns, not just isolated keywords.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
|
||||
{prohibitedCategories.map((category) => (
|
||||
<Card key={category.title}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
|
||||
{category.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
{category.examples}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<main className="section about-page">
|
||||
<div className="about-hero">
|
||||
<div className="about-hero-copy">
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">About</span>
|
||||
<span className="tag tag-accent">Policy</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
|
||||
Recent patterns we are explicitly not okay with
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<li
|
||||
key={pattern}
|
||||
className="flex items-start gap-2 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm text-[color:var(--ink-soft)]"
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--gold)]" aria-hidden="true">
|
||||
-
|
||||
</span>
|
||||
{pattern}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Enforcement</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
|
||||
1.
|
||||
</span>
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
|
||||
2.
|
||||
</span>
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
|
||||
3.
|
||||
</span>
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</li>
|
||||
</ul>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
<Button variant="primary">Browse Skills</Button>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="outline">Reviewer Doc</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<h1 className="about-title">What ClawHub will not host</h1>
|
||||
<p className="about-lead">
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
|
||||
defenses, scam people, invade privacy, or enable non-consensual behavior, it does not
|
||||
belong here.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="about-callout">
|
||||
<span className="about-callout-label">Moderation stance</span>
|
||||
<p>
|
||||
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
|
||||
Predatory workflows get removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="about-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Immediate rejection categories</h2>
|
||||
</div>
|
||||
<div className="about-grid">
|
||||
{prohibitedCategories.map((category) => (
|
||||
<article key={category.title} className="about-rule-card">
|
||||
<h2>{category.title}</h2>
|
||||
<p>{category.examples}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
|
||||
</div>
|
||||
<div className="about-patterns">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<div key={pattern} className="about-pattern">
|
||||
{pattern}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-enforcement">
|
||||
<div>
|
||||
<span className="about-callout-label">Enforcement</span>
|
||||
<div className="management-sublist">
|
||||
<div className="management-subitem">
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Link className="btn btn-primary" to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
<a
|
||||
className="btn"
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+201
-216
@@ -16,12 +16,6 @@ import { useEffect, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
@@ -110,8 +104,7 @@ function Dashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPublisherId) return;
|
||||
const personal =
|
||||
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher._id) {
|
||||
setSelectedPublisherId(personal.publisher._id);
|
||||
}
|
||||
@@ -119,38 +112,69 @@ function Dashboard() {
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<Container className="py-10">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<span>Sign in to access your dashboard.</span>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<div className="card">Sign in to access your dashboard.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const skills = mySkills ?? [];
|
||||
const packages = myPackages ?? [];
|
||||
const isLoading = mySkills === undefined;
|
||||
const ownerHandle =
|
||||
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
|
||||
|
||||
// Welcome state for new users with no content
|
||||
if (!isLoading && skills.length === 0 && packages.length === 0) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="empty-state">
|
||||
<h1 className="empty-state-title" style={{ fontSize: "1.4rem", fontFamily: "var(--font-display)" }}>
|
||||
Welcome to ClawHub
|
||||
</h1>
|
||||
<p className="empty-state-body">
|
||||
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="btn"
|
||||
>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="grid gap-1.5">
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
<main className="section">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
Publisher Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Owner-only view for skills and plugins, including security scans and verification.
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
Manage your published skills and plugins.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{publishers && publishers.length > 0 ? (
|
||||
<select
|
||||
className="min-h-[44px] rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] px-4 py-[11px] text-sm text-[color:var(--ink)] transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35"
|
||||
className="input"
|
||||
value={selectedPublisherId}
|
||||
onChange={(event) => setSelectedPublisherId(event.target.value)}
|
||||
>
|
||||
@@ -161,180 +185,160 @@ function Dashboard() {
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner panel */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="grid gap-10">
|
||||
{/* Skills section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<section className="card dashboard-owner-panel">
|
||||
<div className="dashboard-owner-grid">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Publisher Skills
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
|
||||
<h2 className="dashboard-collection-title">Publisher Skills</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
Hidden skill versions remain visible here while checks are pending.
|
||||
</p>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Upload}
|
||||
title="No skills yet."
|
||||
description="Publish your first skill to share it with the community."
|
||||
>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Plugins section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Publisher Plugins
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
|
||||
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
Owner-only package view with VirusTotal, static scan, and verification state.
|
||||
</p>
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Plug}
|
||||
title="No plugins yet."
|
||||
description="Publish your first plugin release to validate and distribute it."
|
||||
>
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
|
||||
<span>Plugin</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{packages.map((pkg) => (
|
||||
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Plugin</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{packages.map((pkg) => (
|
||||
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
return (
|
||||
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
|
||||
{/* Primary info */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
|
||||
className="dashboard-skill-name"
|
||||
>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">/{skill.slug}</span>
|
||||
<span className="dashboard-list-id">/{skill.slug}</span>
|
||||
{skill.pendingReview ? (
|
||||
<Badge variant="pending">
|
||||
<span className="tag tag-pending">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
Pending checks
|
||||
</Badge>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.downloads)}
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? "No summary provided."}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
|
||||
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
{skill.pendingReview ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
VT pending
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)]">
|
||||
<span className="dashboard-inline-status-note">
|
||||
Hidden until verification checks finish.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Visible</span>
|
||||
<span className="dashboard-inline-status-note">Visible</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }} className="btn btn-sm">
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -372,25 +376,23 @@ function PackageStatusTag({
|
||||
label: string;
|
||||
tone: "default" | "pending" | "warning" | "danger" | "success";
|
||||
}) {
|
||||
const variant =
|
||||
const className =
|
||||
tone === "pending"
|
||||
? "pending"
|
||||
? "tag tag-pending"
|
||||
: tone === "warning"
|
||||
? "warning"
|
||||
? "tag dashboard-tag-warning"
|
||||
: tone === "danger"
|
||||
? "destructive"
|
||||
? "tag dashboard-tag-danger"
|
||||
: tone === "success"
|
||||
? "success"
|
||||
: "default";
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
? "tag dashboard-tag-success"
|
||||
: "tag";
|
||||
return <span className={className}>{label}</span>;
|
||||
}
|
||||
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const scanLabel = scanStatusLabel(pkg.scanStatus);
|
||||
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
|
||||
const sourceLabel = pkg.sourceRepo
|
||||
?.replace(/^https?:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/, "");
|
||||
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
|
||||
const scanTone =
|
||||
pkg.scanStatus === "pending"
|
||||
? "pending"
|
||||
@@ -411,20 +413,15 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
|
||||
{/* Primary info */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to="/plugins/$name"
|
||||
params={{ name: pkg.name }}
|
||||
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
|
||||
>
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
|
||||
{pkg.displayName}
|
||||
</Link>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">{pkg.name}</span>
|
||||
<span className="dashboard-list-id">{pkg.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="dashboard-inline-tags">
|
||||
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
|
||||
<PackageStatusTag label={pkg.channel} tone="default" />
|
||||
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
|
||||
@@ -438,41 +435,34 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(pkg.stats.downloads)}
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
|
||||
</span>
|
||||
{pkg.runtimeId ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
|
||||
</span>
|
||||
) : null}
|
||||
{sourceLabel ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel(
|
||||
"VT",
|
||||
@@ -480,39 +470,34 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
pkg.scanStatus === "pending" ? "pending" : "unknown",
|
||||
)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<CheckCircle2 size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<AlertTriangle size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
}}
|
||||
>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Release
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Release
|
||||
</Link>
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="btn btn-ghost btn-sm">
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+293
-273
@@ -1,20 +1,16 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { ArrowRight, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { InstallSwitcher } from "../components/InstallSwitcher";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SkillCardSkeletonGrid } from "../components/skeletons/SkillCardSkeleton";
|
||||
import { SkillCard } from "../components/SkillCard";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { SkillStatsTripletLine } from "../components/SkillStats";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { UserBadge } from "../components/UserBadge";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill, PublicSoul } from "../lib/publicUser";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
|
||||
@@ -36,35 +32,38 @@ function SkillsHome() {
|
||||
};
|
||||
|
||||
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
|
||||
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills);
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null;
|
||||
const [trending, setTrending] = useState<SkillPageEntry[]>([]);
|
||||
const [recent, setRecent] = useState<SkillPageEntry[]>([]);
|
||||
const [skillCount, setSkillCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
convexHttp
|
||||
.query(api.skills.listHighlightedPublic, { limit: 6 })
|
||||
.then((r) => {
|
||||
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
|
||||
})
|
||||
.catch(() => {}),
|
||||
convexHttp
|
||||
.query(api.skills.listPublicPageV4, {
|
||||
numItems: 12,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
|
||||
})
|
||||
.catch(() => {}),
|
||||
]).then(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
|
||||
convexHttp.query(api.skills.listPublicPageV4, {
|
||||
numItems: 8,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
}),
|
||||
convexHttp.query(api.skills.listPublicPageV4, {
|
||||
numItems: 8,
|
||||
sort: "updated",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
}),
|
||||
convexHttp.query(api.skills.countPublicSkills, {}),
|
||||
])
|
||||
.then(([h, t, r, c]) => {
|
||||
if (cancelled) return;
|
||||
setHighlighted(h as SkillPageEntry[]);
|
||||
setTrending((t as { page: SkillPageEntry[] }).page);
|
||||
setRecent((r as { page: SkillPageEntry[] }).page);
|
||||
setSkillCount(c as number);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -72,28 +71,18 @@ function SkillsHome() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden py-20 px-7">
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
|
||||
<div className="flex flex-col gap-5 fade-up" data-delay="1">
|
||||
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
|
||||
A versioned registry for AI agent skills
|
||||
</span>
|
||||
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
|
||||
ClawHub, the skill dock for sharp agents.
|
||||
</h1>
|
||||
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
|
||||
Browse, install, and publish skill packs. Versioned like npm, searchable with
|
||||
vectors, no gatekeeping.
|
||||
<section className="home-hero">
|
||||
<div className="home-hero-inner">
|
||||
<div className="home-hero-grid">
|
||||
<div className="home-hero-copy">
|
||||
<div className="home-hero-kicker">Discovery hub</div>
|
||||
<h1 className="home-hero-title">The collaborative hub for agent skills</h1>
|
||||
<p className="home-hero-subtitle">
|
||||
{skillCount != null
|
||||
? `${formatCompactStat(skillCount)} public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own.`
|
||||
: "Public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own."}
|
||||
</p>
|
||||
{/* Stats bar */}
|
||||
{totalSkillsText && (
|
||||
<p className="text-sm font-semibold text-[color:var(--ink-soft)]">
|
||||
{totalSkillsText} skills available
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<div className="home-hero-actions">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
@@ -105,159 +94,232 @@ function SkillsHome() {
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Button variant="primary" size="lg">
|
||||
Browse skills
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
Browse All Skills & Plugins
|
||||
</Link>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Button variant="outline" size="lg">
|
||||
Publish Skill
|
||||
</Button>
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
className="btn home-hero-publish-btn"
|
||||
>
|
||||
+ Publish Yours
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
|
||||
Search skills. Versioned, rollback-ready.
|
||||
</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Staff Picks */}
|
||||
<section className="py-12">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Staff Picks
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Curated signal — highlighted for quick trust.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="hidden text-sm font-semibold text-[color:var(--accent)] hover:underline sm:block"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
{!loaded && highlighted.length === 0 ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : highlighted.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">No highlighted skills yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{highlighted.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
badge={getSkillBadges(entry.skill)}
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<>
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={entry.skill.stats} />
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Popular */}
|
||||
<section className="py-12">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Popular skills
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Most-downloaded, verified picks.
|
||||
<p className="home-hero-explainer">
|
||||
Sharp filters. Clean listings. Discovery that feels more like a real index and less
|
||||
like a sad spreadsheet.
|
||||
</p>
|
||||
</div>
|
||||
{!loaded && popular.length === 0 ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : popular.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">No skills yet. Be the first.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{popular.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<>
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={entry.skill.stats} />
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center pt-4">
|
||||
|
||||
<div className="home-hero-panels" id="home-discovery">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
sort: "downloads" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-hero-panel"
|
||||
>
|
||||
<Button variant="outline">
|
||||
See all skills
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="home-hero-panel-label">Skills</span>
|
||||
<strong>Browse ranked skill bundles</strong>
|
||||
<span>Popular installs, fresh updates, staff picks.</span>
|
||||
</Link>
|
||||
<Link to="/plugins" className="home-hero-panel">
|
||||
<span className="home-hero-panel-label">Plugins</span>
|
||||
<strong>Find agent-ready packages</strong>
|
||||
<span>Code plugins, bundles, and verified publishers.</span>
|
||||
</Link>
|
||||
<Link to="/users" search={{ q: undefined }} className="home-hero-panel">
|
||||
<span className="home-hero-panel-label">Users</span>
|
||||
<strong>Meet the builders</strong>
|
||||
<span>Profiles, bios, and the people shipping useful stuff.</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-hero-panel"
|
||||
>
|
||||
<span className="home-hero-panel-label">Souls</span>
|
||||
<strong>SOUL.md discovery is coming</strong>
|
||||
<span>Holding page for the next catalog surface.</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trending */}
|
||||
{trending.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Trending</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "downloads" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="results-list">
|
||||
{trending.map((entry) => (
|
||||
<SkillListItem
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
ownerHandle={entry.ownerHandle}
|
||||
owner={entry.owner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Recently updated */}
|
||||
{recent.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Recently updated</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "updated" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="results-list">
|
||||
{recent.map((entry) => (
|
||||
<SkillListItem
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
ownerHandle={entry.ownerHandle}
|
||||
owner={entry.owner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Staff picks */}
|
||||
{highlighted.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Staff picks</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{
|
||||
highlighted.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
badge={getSkillBadges(entry.skill)}
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="skill-card-footer-rows">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<div className="stat">
|
||||
<SkillStatsTripletLine stats={entry.skill.stats} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Quick links */}
|
||||
<section className="home-section">
|
||||
<div className="home-quick-links">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{ q: undefined, sort: "stars" as const, dir: "desc" as const, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
|
||||
className="home-quick-link"
|
||||
>
|
||||
Most starred
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{ q: undefined, sort: "newest" as const, dir: undefined, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
|
||||
className="home-quick-link"
|
||||
>
|
||||
New this week
|
||||
</Link>
|
||||
<Link to="/plugins" className="home-quick-link">
|
||||
Browse plugins
|
||||
</Link>
|
||||
<Link to="/users" search={{ q: undefined }} className="home-quick-link">
|
||||
Browse users
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
|
||||
className="home-quick-link"
|
||||
>
|
||||
Souls coming soon
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, highlighted: true, nonSuspicious: undefined, view: undefined, focus: undefined }}
|
||||
className="home-quick-link"
|
||||
>
|
||||
Staff picks
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function OnlyCrabsHome() {
|
||||
const navigate = Route.useNavigate();
|
||||
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
|
||||
@@ -274,45 +336,18 @@ function OnlyCrabsHome() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="relative overflow-hidden py-20 px-7">
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
|
||||
<div className="flex flex-col gap-5 fade-up" data-delay="1">
|
||||
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
|
||||
SOUL.md, shared.
|
||||
</span>
|
||||
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
|
||||
SoulHub, where system lore lives.
|
||||
</h1>
|
||||
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
|
||||
<section className="home-hero">
|
||||
<div className="home-hero-inner">
|
||||
<div className="home-hero-grid">
|
||||
<div className="home-hero-copy">
|
||||
<div className="home-hero-kicker">OnlyCrabs</div>
|
||||
<h1 className="home-hero-title">SoulHub, where system lore lives.</h1>
|
||||
<p className="home-hero-subtitle">
|
||||
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
|
||||
public place.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Button variant="primary" size="lg">
|
||||
Publish Soul
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="lg">
|
||||
Browse souls
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<form
|
||||
className="search-bar"
|
||||
className="home-hero-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
@@ -327,72 +362,57 @@ function OnlyCrabsHome() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4 text-[color:var(--ink-soft)]" />
|
||||
<Input
|
||||
<input
|
||||
className="home-hero-search-input"
|
||||
type="text"
|
||||
placeholder="Search souls, prompts, or lore"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="border-none bg-transparent shadow-none focus:shadow-none focus:ring-0"
|
||||
/>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
|
||||
Search souls. Versioned, readable, easy to remix.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Latest souls
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Newest SOUL.md bundles across the hub.
|
||||
</p>
|
||||
</div>
|
||||
{latest.length === 0 ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{latest.map((soul) => (
|
||||
<SoulCard
|
||||
key={soul._id}
|
||||
soul={soul}
|
||||
summaryFallback="A SOUL.md bundle."
|
||||
meta={
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SoulStatsTripletLine stats={soul.stats} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center pt-4">
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
<Button variant="outline">
|
||||
See all souls
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Latest souls</h2>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No souls yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((soul) => (
|
||||
<SoulCard
|
||||
key={soul._id}
|
||||
soul={soul}
|
||||
summaryFallback="A SOUL.md bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
<SoulStatsTripletLine stats={soul.stats} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
+647
-809
File diff suppressed because it is too large
Load Diff
+171
-249
@@ -1,20 +1,13 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { formatRetryDelay } from "../../lib/formatRetryDelay";
|
||||
import { BrowseSidebar } from "../../components/BrowseSidebar";
|
||||
import { PluginListItem } from "../../components/PluginListItem";
|
||||
import {
|
||||
fetchPluginCatalog,
|
||||
isRateLimitedPackageApiError,
|
||||
type PackageListItem,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PluginSearchState = {
|
||||
q?: string;
|
||||
@@ -31,6 +24,15 @@ type PluginsLoaderData = {
|
||||
retryAfterSeconds: number | null;
|
||||
};
|
||||
|
||||
function formatRetryDelay(retryAfterSeconds: number | null) {
|
||||
if (!retryAfterSeconds || retryAfterSeconds <= 0) return "in a moment";
|
||||
if (retryAfterSeconds < 60) {
|
||||
return `in about ${retryAfterSeconds} second${retryAfterSeconds === 1 ? "" : "s"}`;
|
||||
}
|
||||
const minutes = Math.ceil(retryAfterSeconds / 60);
|
||||
return `in about ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/plugins/")({
|
||||
validateSearch: (search): PluginSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
@@ -44,7 +46,9 @@ export const Route = createFileRoute("/plugins/")({
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
@@ -60,276 +64,194 @@ export const Route = createFileRoute("/plugins/")({
|
||||
limit: 50,
|
||||
});
|
||||
return {
|
||||
items: data.items,
|
||||
nextCursor: data.nextCursor,
|
||||
items: data.items ?? [],
|
||||
nextCursor: data.nextCursor ?? null,
|
||||
rateLimited: false,
|
||||
retryAfterSeconds: null,
|
||||
} satisfies PluginsLoaderData;
|
||||
} catch (error) {
|
||||
if (!isRateLimitedPackageApiError(error)) throw error;
|
||||
return {
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
} satisfies PluginsLoaderData;
|
||||
if (isRateLimitedPackageApiError(error)) {
|
||||
return {
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: (error as { retryAfterSeconds?: number }).retryAfterSeconds ?? null,
|
||||
} satisfies PluginsLoaderData;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
component: PluginsIndex,
|
||||
});
|
||||
|
||||
function VerifiedBadge() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Verified publisher"
|
||||
className="inline-block shrink-0 align-middle"
|
||||
>
|
||||
<path
|
||||
d="M8 0L9.79 1.52L12.12 1.21L12.93 3.41L15.01 4.58L14.42 6.84L15.56 8.82L14.12 10.5L14.12 12.82L11.86 13.41L10.34 15.27L8 14.58L5.66 15.27L4.14 13.41L1.88 12.82L1.88 10.5L0.44 8.82L1.58 6.84L0.99 4.58L3.07 3.41L3.88 1.21L6.21 1.52L8 0Z"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 8L7 9.5L10.5 6"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const { items, nextCursor, rateLimited, retryAfterSeconds } =
|
||||
Route.useLoaderData() as PluginsLoaderData;
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="wide">
|
||||
<header className="mb-6">
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Plugins</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">Browse the plugin catalog.</p>
|
||||
</header>
|
||||
const handleFilterToggle = (key: string) => {
|
||||
if (key === "verified") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
verified: prev.verified ? undefined : true,
|
||||
}),
|
||||
});
|
||||
} else if (key === "executesCode") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
executesCode: prev.executesCode ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<form
|
||||
className="relative flex flex-1 items-center"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search plugins..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
|
||||
>
|
||||
Publish Plugin
|
||||
</Link>
|
||||
const handleFamilySort = (value: string) => {
|
||||
const family =
|
||||
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
family: family as "code-plugin" | "bundle-plugin" | undefined,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<h1 className="browse-title">Plugins</h1>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<Link
|
||||
className="btn btn-primary"
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<form className="browse-page-search" onSubmit={handleSearch}>
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
placeholder="Search plugins..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<BrowseSidebar
|
||||
sortOptions={[
|
||||
{ value: "all", label: "All types" },
|
||||
{ value: "code-plugin", label: "Code plugins" },
|
||||
{ value: "bundle-plugin", label: "Bundle plugins" },
|
||||
]}
|
||||
activeSort={search.family ?? "all"}
|
||||
onSortChange={handleFamilySort}
|
||||
filters={[
|
||||
{ key: "verified", label: "Verified only", active: search.verified ?? false },
|
||||
{ key: "executesCode", label: "Executes code", active: search.executesCode ?? false },
|
||||
]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
/>
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{items.length} plugin{items.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-center rounded-[var(--radius-pill)] border border-[color:var(--line)]"
|
||||
role="group"
|
||||
aria-label="Filter by type"
|
||||
>
|
||||
{[
|
||||
{ value: undefined, label: "All" },
|
||||
{ value: "code-plugin" as const, label: "Code" },
|
||||
{ value: "bundle-plugin" as const, label: "Bundles" },
|
||||
].map((opt) => (
|
||||
|
||||
{rateLimited ? (
|
||||
<div className="empty-state">
|
||||
<AlertTriangle size={20} aria-hidden="true" />
|
||||
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
|
||||
<p className="empty-state-body">
|
||||
Try again {formatRetryDelay(retryAfterSeconds)}.
|
||||
</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No plugins found</p>
|
||||
<p className="empty-state-body">Try a different search term or remove filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{items.map((item) => (
|
||||
<PluginListItem key={item.name} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 22 }}>
|
||||
{search.cursor ? (
|
||||
<button
|
||||
key={opt.label}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-semibold transition-colors first:rounded-l-[var(--radius-pill)] last:rounded-r-[var(--radius-pill)] sm:flex-none ${
|
||||
(search.family ?? undefined) === opt.value
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
className="btn"
|
||||
type="button"
|
||||
aria-pressed={(search.family ?? undefined) === opt.value}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
family: opt.value,
|
||||
}),
|
||||
search: (prev) => ({ ...prev, cursor: undefined }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
First page
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant={search.verified ? "primary" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
aria-pressed={search.verified ?? false}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
verified: prev.verified ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<VerifiedBadge /> Verified
|
||||
</Button>
|
||||
<Button
|
||||
variant={search.executesCode ? "primary" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
aria-pressed={search.executesCode ?? false}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
executesCode: prev.executesCode ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Executes code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{rateLimited ? (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Plugin catalog is temporarily unavailable"
|
||||
description={`The public plugin API is rate-limited right now. Try again ${formatRetryDelay(
|
||||
retryAfterSeconds,
|
||||
)}.`}
|
||||
action={{
|
||||
label: "Try again",
|
||||
onClick: () => window.location.reload(),
|
||||
}}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No plugins match that filter"
|
||||
description="Try a different search or filter."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(280px,1fr))] sm:gap-5">
|
||||
{items.map((item) => (
|
||||
<Link key={item.name} to="/plugins/$name" params={{ name: item.name }}>
|
||||
<Card className="h-full cursor-pointer hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="compact">{familyLabel(item.family)}</Badge>
|
||||
{item.isOfficial ? (
|
||||
<Badge variant="accent">
|
||||
<VerifiedBadge /> Verified
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
{item.displayName}
|
||||
</h3>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{item.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5 pt-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="min-w-0 break-words text-sm text-[color:var(--ink-soft)]">
|
||||
{item.ownerHandle ? `by ${item.ownerHandle}` : "community"}
|
||||
</span>
|
||||
{item.latestVersion ? (
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
v{item.latestVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div className="mt-6 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
|
||||
{search.cursor ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
First page
|
||||
</Button>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: nextCursor,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{nextCursor ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, cursor: nextCursor }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+163
-35
@@ -1,42 +1,170 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { detectSiteMode } from "../lib/site";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { PluginListItem } from "../components/PluginListItem";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { UserListItem } from "../components/UserListItem";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
type UnifiedUserResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
|
||||
type SearchState = {
|
||||
q?: string;
|
||||
type?: "all" | "skills" | "plugins" | "users";
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
validateSearch: (search) => ({
|
||||
validateSearch: (search): SearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
|
||||
highlighted: search.highlighted === "1" || search.highlighted === "true" ? true : undefined,
|
||||
nonSuspicious:
|
||||
search.nonSuspicious === "1" || search.nonSuspicious === "true" ? true : undefined,
|
||||
type:
|
||||
search.type === "skills" || search.type === "plugins" || search.type === "users"
|
||||
? search.type
|
||||
: undefined,
|
||||
}),
|
||||
beforeLoad: ({ search, location }) => {
|
||||
const hostname =
|
||||
(location as { url?: URL }).url?.hostname ??
|
||||
(typeof window !== "undefined" ? window.location.hostname : undefined);
|
||||
const mode = detectSiteMode(hostname);
|
||||
if (mode === "skills") {
|
||||
throw redirect({
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
nonSuspicious: search.nonSuspicious || undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
component: UnifiedSearchPage,
|
||||
});
|
||||
|
||||
throw redirect({
|
||||
to: "/",
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
highlighted: undefined,
|
||||
search: search.q ? undefined : true,
|
||||
},
|
||||
function UnifiedSearchPage() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const activeType = search.type ?? "all";
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
|
||||
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
|
||||
search.q ?? "",
|
||||
activeType,
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: query.trim() || undefined, type: search.type },
|
||||
});
|
||||
};
|
||||
|
||||
const setType = (type: "all" | "skills" | "plugins" | "users") => {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: search.q, type: type === "all" ? undefined : type },
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<h1 className="browse-title" style={{ marginBottom: 16 }}>
|
||||
{search.q ? (
|
||||
<>
|
||||
Search results for <span style={{ color: "var(--accent)" }}>"{search.q}"</span>
|
||||
</>
|
||||
) : (
|
||||
"Search"
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<form className="search-page-form" onSubmit={handleSearch}>
|
||||
<div className="browse-search-bar" style={{ maxWidth: 560, flex: 1 }}>
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
type="text"
|
||||
placeholder="Search skills, plugins, users..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="search-tabs">
|
||||
<button
|
||||
className={`search-tab${activeType === "all" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("all")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "skills" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("skills")}
|
||||
>
|
||||
Skills
|
||||
{skillCount > 0 ? (
|
||||
<span className="search-tab-count">{skillCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("plugins")}
|
||||
>
|
||||
Plugins
|
||||
{pluginCount > 0 ? (
|
||||
<span className="search-tab-count">{pluginCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("users")}
|
||||
>
|
||||
Users
|
||||
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Searching...</div>
|
||||
</div>
|
||||
) : !search.q ? (
|
||||
<div className="card" style={{ textAlign: "center", padding: 40 }}>
|
||||
<p style={{ color: "var(--ink-soft)" }}>
|
||||
Enter a search term to find skills, plugins, and users
|
||||
</p>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: "center", padding: 40 }}>
|
||||
<p style={{ color: "var(--ink-soft)" }}>No results found for "{search.q}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : item.type === "plugin" ? (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
) : (
|
||||
<UserResultRow key={`user-${item.user._id}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
|
||||
const skill = result.skill as unknown as PublicSkill;
|
||||
return (
|
||||
<SkillListItem
|
||||
skill={skill}
|
||||
ownerHandle={result.ownerHandle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
|
||||
return <PluginListItem item={result.plugin} />;
|
||||
}
|
||||
|
||||
function UserResultRow({ result }: { result: UnifiedUserResult }) {
|
||||
const user = result.user as PublicUser;
|
||||
return <UserListItem user={user} />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { SkillListItem } from "../../components/SkillListItem";
|
||||
import { getPlatformLabels } from "../../components/skillDetailUtils";
|
||||
import { SkillMetricsRow, SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
import { UserBadge } from "../../components/UserBadge";
|
||||
import { getSkillBadges } from "../../lib/badges";
|
||||
import { buildSkillHref, type SkillListEntry } from "./-types";
|
||||
@@ -29,7 +24,7 @@ export function SkillsResults({
|
||||
isLoadingSkills,
|
||||
sorted,
|
||||
view,
|
||||
listDoneLoading,
|
||||
listDoneLoading: _listDoneLoading,
|
||||
hasQuery,
|
||||
canLoadMore,
|
||||
isLoadingMore,
|
||||
@@ -40,14 +35,27 @@ export function SkillsResults({
|
||||
return (
|
||||
<>
|
||||
{isLoadingSkills ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
<div className="skeleton-list">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="skeleton-row">
|
||||
<div className="skeleton-icon" />
|
||||
<div className="skeleton-row-body">
|
||||
<div className="skeleton-bar skeleton-bar-lg" />
|
||||
<div className="skeleton-bar skeleton-bar-sm" />
|
||||
<div className="skeleton-bar skeleton-bar-xs" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
title={listDoneLoading || hasQuery ? "No skills match that filter" : "Loading skills..."}
|
||||
description={hasQuery ? "Try adjusting your search or filters." : undefined}
|
||||
/>
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No skills found</p>
|
||||
<p className="empty-state-body">
|
||||
{hasQuery ? "Try a different search term or remove filters." : "No skills have been published yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : view === "cards" ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
<div className="grid">
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill;
|
||||
const clawdis = entry.latestVersion?.parsed?.clawdis;
|
||||
@@ -65,105 +73,58 @@ export function SkillsResults({
|
||||
platformLabels={platforms.length ? platforms : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<>
|
||||
<div className="skill-card-footer-rows">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<div className="stat">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)]">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] gap-4 border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-5 py-3 text-xs font-bold uppercase tracking-wider text-[color:var(--ink-soft)]">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Author</span>
|
||||
<span className="text-right">Stats</span>
|
||||
</div>
|
||||
{sorted.map((entry, i) => {
|
||||
<div className="results-list">
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill;
|
||||
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
|
||||
const skillHref = buildSkillHref(skill, ownerHandle);
|
||||
return (
|
||||
<Link
|
||||
<SkillListItem
|
||||
key={skill._id}
|
||||
className={`grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] items-center gap-4 px-5 py-3.5 no-underline transition-colors hover:bg-[color:var(--surface-muted)] ${
|
||||
i % 2 === 0 ? "bg-[color:var(--surface)]" : "bg-[color:var(--bg-soft)]"
|
||||
}`}
|
||||
to={skillHref}
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[color:var(--ink)]">
|
||||
{skill.displayName}
|
||||
</span>
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
{entry.latestVersion?.version ? (
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
v{entry.latestVersion.version}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-sm text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? "No summary provided."}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
link={false}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-wrap justify-end gap-3 text-xs text-[color:var(--ink-soft)]">
|
||||
<SkillMetricsRow stats={skill.stats} />
|
||||
</span>
|
||||
</Link>
|
||||
skill={skill}
|
||||
ownerHandle={ownerHandle}
|
||||
owner={entry.owner}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{(canLoadMore || isLoadingMore) && (
|
||||
<div ref={canAutoLoad ? loadMoreRef : null} className="flex justify-center pt-4">
|
||||
{canLoadMore || isLoadingMore ? (
|
||||
<div
|
||||
ref={canAutoLoad ? loadMoreRef : null}
|
||||
className="card"
|
||||
style={{ marginTop: 16, display: "flex", justifyContent: "center" }}
|
||||
>
|
||||
{canAutoLoad ? (
|
||||
isLoadingMore ? (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[color:var(--ink-soft)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading more...
|
||||
</div>
|
||||
"Loading more..."
|
||||
) : (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">Scroll to load more</div>
|
||||
"Scroll to load more"
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={isLoadingMore}
|
||||
loading={isLoadingMore}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
<button className="btn" type="button" onClick={loadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+128
-64
@@ -1,15 +1,23 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useRef } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { SKILL_CAPABILITY_TAGS } from "../../../convex/lib/skillCapabilityTags";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { BrowseSidebar } from "../../components/BrowseSidebar";
|
||||
import { SKILL_CATEGORIES } from "../../lib/categories";
|
||||
import { formatCompactStat } from "../../lib/numberFormat";
|
||||
import { parseSort } from "./-params";
|
||||
import { SkillsResults } from "./-SkillsResults";
|
||||
import { SkillsToolbar } from "./-SkillsToolbar";
|
||||
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
|
||||
|
||||
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "downloads", label: "Most downloaded" },
|
||||
{ value: "stars", label: "Most starred" },
|
||||
{ value: "installs", label: "Most installed" },
|
||||
{ value: "updated", label: "Recently updated" },
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "name", label: "Name" },
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/skills/")({
|
||||
validateSearch: (search): SkillsSearchState => {
|
||||
@@ -27,10 +35,6 @@ export const Route = createFileRoute("/skills/")({
|
||||
search.nonSuspicious === true
|
||||
? true
|
||||
: undefined,
|
||||
tag:
|
||||
typeof search.tag === "string" && SKILL_CAPABILITY_TAG_SET.has(search.tag)
|
||||
? search.tag
|
||||
: undefined,
|
||||
view: search.view === "cards" || search.view === "list" ? search.view : undefined,
|
||||
focus: search.focus === "search" ? "search" : undefined,
|
||||
};
|
||||
@@ -46,7 +50,6 @@ export const Route = createFileRoute("/skills/")({
|
||||
dir: search.dir || undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
nonSuspicious: search.nonSuspicious || undefined,
|
||||
tag: search.tag || undefined,
|
||||
view: search.view || undefined,
|
||||
focus: search.focus || undefined,
|
||||
},
|
||||
@@ -62,7 +65,8 @@ export function SkillsIndex() {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills);
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null;
|
||||
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const model = useSkillsBrowseModel({
|
||||
navigate,
|
||||
@@ -70,60 +74,120 @@ export function SkillsIndex() {
|
||||
searchInputRef,
|
||||
});
|
||||
|
||||
const sortOptionsWithRelevance = model.hasQuery
|
||||
? [{ value: "relevance", label: "Relevance" }, ...SORT_OPTIONS]
|
||||
: SORT_OPTIONS;
|
||||
|
||||
const handleFilterToggle = useCallback(
|
||||
(key: string) => {
|
||||
if (key === "highlighted") model.onToggleHighlighted();
|
||||
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
},
|
||||
[model.onToggleHighlighted, model.onToggleNonSuspicious],
|
||||
);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(slug: string | undefined) => {
|
||||
if (slug) {
|
||||
const cat = SKILL_CATEGORIES.find((c) => c.slug === slug);
|
||||
if (cat?.keywords[0]) {
|
||||
model.onQueryChange(cat.keywords[0]);
|
||||
}
|
||||
} else {
|
||||
model.onQueryChange("");
|
||||
}
|
||||
},
|
||||
[model.onQueryChange],
|
||||
);
|
||||
|
||||
const activeCategory = useMemo(() => {
|
||||
if (!model.query) return undefined;
|
||||
return (
|
||||
SKILL_CATEGORIES.find((c) =>
|
||||
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
|
||||
)?.slug ?? undefined
|
||||
);
|
||||
}, [model.query]);
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="wide">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
Skills
|
||||
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
|
||||
({model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly
|
||||
? model.sorted.length.toLocaleString("en-US")
|
||||
: totalSkillsText ?? "…"})
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<h1 className="browse-title">
|
||||
Skills
|
||||
{totalSkillsText ? (
|
||||
<span className="browse-count">{totalSkillsText}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
<div className="browse-page-search">
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="browse-search-input"
|
||||
value={model.query}
|
||||
onChange={(event) => model.onQueryChange(event.target.value)}
|
||||
placeholder="Search skills..."
|
||||
/>
|
||||
</div>
|
||||
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<BrowseSidebar
|
||||
categories={SKILL_CATEGORIES}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
sortOptions={sortOptionsWithRelevance}
|
||||
activeSort={model.sort}
|
||||
onSortChange={model.onSortChange}
|
||||
filters={[
|
||||
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
|
||||
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
|
||||
]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
/>
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{model.isLoadingSkills
|
||||
? "Loading skills..."
|
||||
: `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(", ")})` : ""}.`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Toolbar */}
|
||||
<SkillsToolbar
|
||||
searchInputRef={searchInputRef}
|
||||
query={model.query}
|
||||
hasQuery={model.hasQuery}
|
||||
sort={model.sort}
|
||||
dir={model.dir}
|
||||
view={model.view}
|
||||
highlightedOnly={model.highlightedOnly}
|
||||
nonSuspiciousOnly={model.nonSuspiciousOnly}
|
||||
capabilityTag={model.capabilityTag}
|
||||
onQueryChange={model.onQueryChange}
|
||||
onToggleHighlighted={model.onToggleHighlighted}
|
||||
onToggleNonSuspicious={model.onToggleNonSuspicious}
|
||||
onCapabilityTagChange={model.onCapabilityTagChange}
|
||||
onSortChange={model.onSortChange}
|
||||
onToggleDir={model.onToggleDir}
|
||||
onToggleView={model.onToggleView}
|
||||
/>
|
||||
|
||||
{/* Results count */}
|
||||
{model.sorted.length > 0 && (
|
||||
<p className="text-xs font-medium text-[color:var(--ink-soft)]">
|
||||
{model.sorted.length}
|
||||
{!model.hasQuery && totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
|
||||
{model.hasQuery ? ` matching "${model.query}"` : ""}
|
||||
{model.highlightedOnly || model.nonSuspiciousOnly || model.capabilityTag
|
||||
? ` (filtered)`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
? "\u2014"
|
||||
: `${model.sorted.length} results`}
|
||||
{(model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly) ? (
|
||||
<button
|
||||
className="browse-clear-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
model.onQueryChange("");
|
||||
if (model.highlightedOnly) model.onToggleHighlighted();
|
||||
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="browse-view-toggle">
|
||||
<button
|
||||
className={`browse-view-btn${model.view === "list" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={model.view === "cards" ? model.onToggleView : undefined}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
className={`browse-view-btn${model.view === "cards" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={model.view === "list" ? model.onToggleView : undefined}
|
||||
>
|
||||
Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SkillsResults
|
||||
isLoadingSkills={model.isLoadingSkills}
|
||||
sorted={model.sorted}
|
||||
@@ -137,7 +201,7 @@ export function SkillsIndex() {
|
||||
loadMore={model.loadMore}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+46
-231
@@ -1,32 +1,13 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
|
||||
import { SoulCard } from "../../components/SoulCard";
|
||||
import { SoulMetricsRow, SoulStatsTripletLine } from "../../components/SoulStats";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import type { PublicSoul } from "../../lib/publicUser";
|
||||
|
||||
const sortKeys = ["newest", "downloads", "stars", "name", "updated"] as const;
|
||||
type SortKey = (typeof sortKeys)[number];
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function parseSort(value: unknown): SortKey {
|
||||
if (typeof value !== "string") return "newest";
|
||||
if ((sortKeys as readonly string[]).includes(value)) return value as SortKey;
|
||||
return "newest";
|
||||
}
|
||||
|
||||
function parseDir(value: unknown, sort: SortKey): SortDir {
|
||||
if (value === "asc" || value === "desc") return value;
|
||||
return sort === "name" ? "asc" : "desc";
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/souls/")({
|
||||
validateSearch: (search) => {
|
||||
return {
|
||||
@@ -37,223 +18,57 @@ export const Route = createFileRoute("/souls/")({
|
||||
focus: search.focus === "search" ? "search" : undefined,
|
||||
};
|
||||
},
|
||||
component: SoulsIndex,
|
||||
component: SoulsHoldingPage,
|
||||
});
|
||||
|
||||
function SoulsIndex() {
|
||||
const navigate = Route.useNavigate();
|
||||
const search = Route.useSearch();
|
||||
const sort = search.sort ?? "newest";
|
||||
const dir = parseDir(search.dir, sort);
|
||||
const view = search.view ?? "list";
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
|
||||
const souls = useQuery(api.souls.list, { limit: 500 }) as PublicSoul[] | undefined;
|
||||
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
|
||||
const seedEnsuredRef = useRef(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const isLoadingSouls = souls === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
// Auto-focus search input when focus=search param is present
|
||||
useEffect(() => {
|
||||
if (search.focus === "search" && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
// Clear the focus param from URL to avoid re-focusing on navigation
|
||||
void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true });
|
||||
}
|
||||
}, [search.focus, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (seedEnsuredRef.current) return;
|
||||
seedEnsuredRef.current = true;
|
||||
void ensureSoulSeeds({});
|
||||
}, [ensureSoulSeeds]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const value = query.trim().toLowerCase();
|
||||
const all = souls ?? [];
|
||||
if (!value) return all;
|
||||
return all.filter((soul) => {
|
||||
if (soul.slug.toLowerCase().includes(value)) return true;
|
||||
if (soul.displayName.toLowerCase().includes(value)) return true;
|
||||
return (soul.summary ?? "").toLowerCase().includes(value);
|
||||
});
|
||||
}, [query, souls]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const multiplier = dir === "asc" ? 1 : -1;
|
||||
const results = [...filtered];
|
||||
results.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case "downloads":
|
||||
return (a.stats.downloads - b.stats.downloads) * multiplier;
|
||||
case "stars":
|
||||
return (a.stats.stars - b.stats.stars) * multiplier;
|
||||
case "updated":
|
||||
return (a.updatedAt - b.updatedAt) * multiplier;
|
||||
case "name":
|
||||
return (
|
||||
(a.displayName.localeCompare(b.displayName) || a.slug.localeCompare(b.slug)) *
|
||||
multiplier
|
||||
);
|
||||
default:
|
||||
return (a.createdAt - b.createdAt) * multiplier;
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}, [dir, filtered, sort]);
|
||||
|
||||
const showing = sorted.length;
|
||||
const total = souls?.length;
|
||||
|
||||
function SoulsHoldingPage() {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Souls</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{isLoadingSouls
|
||||
? "Loading souls..."
|
||||
: `${showing}${typeof total === "number" ? ` of ${total}` : ""} souls.`}
|
||||
</p>
|
||||
<main className="section souls-coming-page">
|
||||
<section className="souls-coming-hero">
|
||||
<div>
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">Souls</span>
|
||||
<span className="tag tag-accent">Coming soon</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:items-end">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
const trimmed = next.trim();
|
||||
setQuery(next);
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, q: trimmed ? next : undefined }),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
placeholder="Filter by name, slug, or summary..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="min-h-[34px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3 py-1.5 text-sm text-[color:var(--ink)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
const nextSort = parseSort(event.target.value);
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: nextSort,
|
||||
dir: parseDir(prev.dir, nextSort),
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
aria-label="Sort souls"
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="downloads">Downloads</option>
|
||||
<option value="stars">Stars</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Sort direction ${dir}`}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
dir: parseDir(prev.dir, sort) === "asc" ? "desc" : "asc",
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{dir === "asc" ? "\u2191" : "\u2193"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === "cards" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
view: prev.view === "cards" ? undefined : "cards",
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{view === "cards" ? "List" : "Cards"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoadingSouls ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : showing === 0 ? (
|
||||
<EmptyState
|
||||
title="No souls match that filter"
|
||||
description="Try a different search term."
|
||||
/>
|
||||
) : view === "cards" ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{sorted.map((soul) => (
|
||||
<SoulCard
|
||||
key={soul._id}
|
||||
soul={soul}
|
||||
summaryFallback="A SOUL.md bundle."
|
||||
meta={
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<SoulStatsTripletLine stats={soul.stats} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sorted.map((soul) => (
|
||||
<Link
|
||||
key={soul._id}
|
||||
className="flex items-center justify-between gap-4 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 transition-all duration-200 hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
|
||||
to="/souls/$slug"
|
||||
params={{ slug: soul.slug }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-display font-bold text-[color:var(--ink)]">
|
||||
{soul.displayName}
|
||||
</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">/{soul.slug}</span>
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
{soul.summary ?? "SOUL.md bundle."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<SoulMetricsRow stats={soul.stats} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="about-title">SOUL.md discovery is on deck</h1>
|
||||
<p className="about-lead">
|
||||
This page is the holding area for public SOUL.md profiles you’ll be able to discover,
|
||||
compare, and share. We’re not shipping a half-baked directory just to tick a box.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="souls-coming-grid">
|
||||
<article className="about-rule-card">
|
||||
<h2>Discover</h2>
|
||||
<p>Browse public system personas, writing voices, and full character sheets.</p>
|
||||
</article>
|
||||
<article className="about-rule-card">
|
||||
<h2>Share</h2>
|
||||
<p>Publish versioned SOUL.md files with attribution and clean history.</p>
|
||||
</article>
|
||||
<article className="about-rule-card">
|
||||
<h2>Compare</h2>
|
||||
<p>Inspect changes, stats, and adoption without the usual metadata sludge.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-enforcement">
|
||||
<div>
|
||||
<span className="about-callout-label">In the meantime</span>
|
||||
<p className="about-lead" style={{ marginBottom: 0 }}>
|
||||
ClawHub already handles skills and plugins. Souls will get the same discovery treatment
|
||||
once the publishing flow is ready.
|
||||
</p>
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Link className="btn btn-primary" to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
|
||||
Browse Skills
|
||||
</Link>
|
||||
<Link className="btn" to="/users" search={{ q: undefined }}>
|
||||
Browse Users
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+145
-221
@@ -1,23 +1,9 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { Star, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Doc } from "../../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { SignInButton } from "../../components/SignInButton";
|
||||
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
|
||||
import { getSkillBadges } from "../../lib/badges";
|
||||
import { SkillListItem } from "../../components/SkillListItem";
|
||||
import type { PublicSkill, PublicUser } from "../../lib/publicUser";
|
||||
|
||||
export const Route = createFileRoute("/u/$handle")({
|
||||
@@ -51,28 +37,18 @@ function UserProfile() {
|
||||
|
||||
if (user === undefined) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Skeleton className="h-20 w-20 rounded-full" />
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading user…</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (user === null) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="User not found"
|
||||
description="This user doesn't exist or their account has been removed."
|
||||
/>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<div className="card">User not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -87,134 +63,93 @@ function UserProfile() {
|
||||
const published = publishedSkills ?? [];
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Profile header */}
|
||||
<Card className="flex-row items-center gap-4 p-6">
|
||||
<Avatar className="h-16 w-16">
|
||||
{avatar && <AvatarImage src={avatar} alt={displayName} />}
|
||||
<AvatarFallback className="text-xl">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="font-mono text-sm text-[color:var(--ink-soft)]">@{displayHandle}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
{isSelf ? (
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "stars" | "installed")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="stars">Stars</TabsTrigger>
|
||||
<TabsTrigger value="installed">Installed</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="stars" className="mt-6">
|
||||
<PublishedAndStarred
|
||||
published={published}
|
||||
isLoadingPublished={isLoadingPublished}
|
||||
skills={skills}
|
||||
isLoadingSkills={isLoadingSkills}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="installed" className="mt-6">
|
||||
<InstalledSection
|
||||
includeRemoved={includeRemoved}
|
||||
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
|
||||
data={installed}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<PublishedAndStarred
|
||||
published={published}
|
||||
isLoadingPublished={isLoadingPublished}
|
||||
skills={skills}
|
||||
isLoadingSkills={isLoadingSkills}
|
||||
/>
|
||||
)}
|
||||
<main className="browse-page">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar-lg" aria-hidden="true">
|
||||
{avatar ? <img src={avatar} alt="" /> : <span>{initial}</span>}
|
||||
</div>
|
||||
</Container>
|
||||
<div className="profile-info">
|
||||
<h1 className="profile-display-name">{displayName}</h1>
|
||||
<span className="profile-handle">@{displayHandle}</span>
|
||||
<div className="profile-stats-row">
|
||||
<span className="profile-stat">{published.length} published</span>
|
||||
<span className="profile-stat">{skills.length} stars</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelf ? (
|
||||
<div className="profile-tabs" role="tablist" aria-label="Profile tabs">
|
||||
<button
|
||||
className={tab === "stars" ? "profile-tab is-active" : "profile-tab"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "stars"}
|
||||
onClick={() => setTab("stars")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
className={tab === "installed" ? "profile-tab is-active" : "profile-tab"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "installed"}
|
||||
onClick={() => setTab("installed")}
|
||||
>
|
||||
Installed
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "installed" && isSelf ? (
|
||||
<InstalledSection
|
||||
includeRemoved={includeRemoved}
|
||||
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
|
||||
data={installed}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{published.length > 0 ? (
|
||||
<>
|
||||
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
|
||||
Published ({published.length})
|
||||
</h2>
|
||||
{isLoadingPublished ? (
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading published skills...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list" style={{ marginBottom: 24 }}>
|
||||
{published.map((skill) => (
|
||||
<SkillListItem key={skill._id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h2 className="home-section-title" style={{ marginBottom: 12 }}>
|
||||
Stars ({skills.length})
|
||||
</h2>
|
||||
{isLoadingSkills ? (
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading stars...</div>
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="card">No stars yet.</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{skills.map((skill) => (
|
||||
<SkillListItem key={skill._id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishedAndStarred({
|
||||
published,
|
||||
isLoadingPublished,
|
||||
skills,
|
||||
isLoadingSkills,
|
||||
}: {
|
||||
published: PublicSkill[];
|
||||
isLoadingPublished: boolean;
|
||||
skills: PublicSkill[];
|
||||
isLoadingSkills: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Published */}
|
||||
<section>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Published</h2>
|
||||
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Skills published by this user.
|
||||
</p>
|
||||
{isLoadingPublished ? (
|
||||
<SkillCardSkeletonGrid count={3} />
|
||||
) : published.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{published.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge={getSkillBadges(skill)}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">No published skills yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Stars */}
|
||||
<section>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Stars</h2>
|
||||
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Skills this user has starred.
|
||||
</p>
|
||||
{isLoadingSkills ? (
|
||||
<SkillCardSkeletonGrid count={3} />
|
||||
) : skills.length === 0 ? (
|
||||
<EmptyState icon={Star} title="No stars yet" />
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge={getSkillBadges(skill)}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledSection(props: {
|
||||
includeRemoved: boolean;
|
||||
onToggleRemoved: () => void;
|
||||
@@ -223,119 +158,108 @@ function InstalledSection(props: {
|
||||
const clearTelemetry = useMutation(api.telemetry.clearMyTelemetry);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const data = props.data;
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
|
||||
<SkillCardSkeletonGrid count={3} />
|
||||
</div>
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
Installed
|
||||
</h2>
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading telemetry…</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
|
||||
<EmptyState title="Sign in to view your installed skills">
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</EmptyState>
|
||||
</div>
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
Installed
|
||||
</h2>
|
||||
<div className="card">Sign in to view your installed skills.</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
|
||||
<p className="max-w-2xl text-sm text-[color:var(--ink-soft)]">
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
Installed
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ maxWidth: 760 }}>
|
||||
Private view. Only you can see your folders/roots. Everyone else only sees aggregated
|
||||
install counts per skill.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={props.onToggleRemoved}>
|
||||
<div className="profile-actions">
|
||||
<button className="btn" type="button" onClick={props.onToggleRemoved}>
|
||||
{props.includeRemoved ? "Hide removed" : "Show removed"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRaw((value) => !value)}>
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => setShowRaw((value) => !value)}>
|
||||
{showRaw ? "Hide JSON" : "Show JSON"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toast("Delete all telemetry data?", {
|
||||
action: {
|
||||
label: "Delete",
|
||||
onClick: () => void clearTelemetry(),
|
||||
},
|
||||
});
|
||||
if (!window.confirm("Delete all telemetry data?")) return;
|
||||
void clearTelemetry();
|
||||
}}
|
||||
>
|
||||
Delete telemetry
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRaw ? (
|
||||
<Card className="mb-4">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-all">
|
||||
<div className="card telemetry-json" style={{ marginBottom: 18 }}>
|
||||
<pre className="mono" style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.roots.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No telemetry yet"
|
||||
description='Run "clawhub sync" from the CLI to start tracking.'
|
||||
/>
|
||||
<div className="card">No telemetry yet. Run `clawhub sync` from the CLI.</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
{data.roots.map((root) => (
|
||||
<Card key={root.rootId}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div key={root.rootId} className="card telemetry-root">
|
||||
<div className="telemetry-root-header">
|
||||
<div>
|
||||
<h3 className="font-display text-sm font-bold text-[color:var(--ink)]">
|
||||
{root.label}
|
||||
</h3>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
<div className="telemetry-root-title">{root.label}</div>
|
||||
<div className="telemetry-root-meta">
|
||||
Last sync {new Date(root.lastSeenAt).toLocaleString()}
|
||||
{root.expiredAt ? " · stale" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default">{root.skills.length} skills</Badge>
|
||||
<div className="tag">{root.skills.length} skills</div>
|
||||
</div>
|
||||
{root.skills.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
No skills found in this root.
|
||||
</p>
|
||||
<div className="stat">No skills found in this root.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="telemetry-skill-list">
|
||||
{root.skills.map((entry) => (
|
||||
<a
|
||||
key={`${root.rootId}:${entry.skill.slug}`}
|
||||
className="flex items-center justify-between rounded-[var(--radius-sm)] px-3 py-2 text-sm no-underline transition-colors hover:bg-[color:var(--surface-muted)]"
|
||||
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[color:var(--ink)]">
|
||||
{entry.skill.displayName}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
/{entry.skill.slug}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
|
||||
<a
|
||||
className="telemetry-skill-link"
|
||||
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
|
||||
>
|
||||
<span>{entry.skill.displayName}</span>
|
||||
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
|
||||
</a>
|
||||
<div className="telemetry-skill-meta mono">
|
||||
{entry.lastVersion ? `v${entry.lastVersion}` : "v?"}{" "}
|
||||
{entry.removedAt ? "· removed" : ""}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { convexHttp } from "../../convex/client";
|
||||
import type { PublicUser } from "../../lib/publicUser";
|
||||
|
||||
type UserSearchState = {
|
||||
q?: string;
|
||||
};
|
||||
|
||||
type UsersLoaderResult = { items: PublicUser[]; total: number };
|
||||
|
||||
export const Route = createFileRoute("/users/")({
|
||||
validateSearch: (search): UserSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
}),
|
||||
component: UsersIndex,
|
||||
});
|
||||
|
||||
function UsersIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchUsers = useCallback(async (q?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await convexHttp.query(api.users.listPublic, {
|
||||
limit: 48,
|
||||
search: q,
|
||||
});
|
||||
setResult(data as UsersLoaderResult);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
void fetchUsers(search.q);
|
||||
}, [search.q, fetchUsers]);
|
||||
|
||||
const users = result?.items ?? [];
|
||||
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<h1 className="browse-title">
|
||||
Users
|
||||
{typeof result?.total === "number" ? (
|
||||
<span className="browse-count">{result.total}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
<form
|
||||
className="browse-page-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: {
|
||||
q: query.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
placeholder="Search users..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{loading ? "Loading users..." : `${users.length} users`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading users...</div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No users found</p>
|
||||
<p className="empty-state-body">Try a different handle or name.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{users.map((user) => (
|
||||
<UserListItem key={user._id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+5535
-237
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user