mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d1701f5a | ||
|
|
52da4954f6 | ||
|
|
0ee5958f7a | ||
|
|
5d01b99adb | ||
|
|
5dc834c27e | ||
|
|
82b9a69dad | ||
|
|
064804e2d3 | ||
|
|
6c0163f9f2 | ||
|
|
04a862d2b2 | ||
|
|
a7fc4bbae2 | ||
|
|
4701c555f3 | ||
|
|
c1f167721b | ||
|
|
1a94744484 | ||
|
|
9a5cfeee85 | ||
|
|
e69b7d4501 | ||
|
|
ecf09b868a | ||
|
|
4d16472f5b |
@@ -12,6 +12,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
@@ -22,6 +24,24 @@ jobs:
|
||||
- name: Peer deps
|
||||
run: bun run check:peers
|
||||
|
||||
- name: Format
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
mapfile -d '' changed_files < <(
|
||||
git diff --name-only --diff-filter=ACMR -z \
|
||||
"${{ github.event.pull_request.base.sha }}" \
|
||||
"${{ github.event.pull_request.head.sha }}" \
|
||||
-- \
|
||||
'*.css' '*.js' '*.jsx' '*.json' '*.md' '*.mjs' '*.ts' '*.tsx' '*.yaml' '*.yml'
|
||||
)
|
||||
|
||||
if (( ${#changed_files[@]} == 0 )); then
|
||||
echo "No changed files supported by oxfmt."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bun run format:check -- "${changed_files[@]}"
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: ClawHub Rescan Guidance
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue:
|
||||
description: "Issue number to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: clawhub-rescan-guidance-${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rescan-guidance:
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'r: rescan-guidance' }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLAWHUB_RESCAN_GUIDANCE_APPLY: "1"
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Comment when rescan guidance label is present
|
||||
run: |
|
||||
node scripts/github/clawhub-rescan-auto-response.mjs \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--issue "$ISSUE_NUMBER" \
|
||||
--comment-for-labeled-issue \
|
||||
--apply
|
||||
@@ -15,6 +15,7 @@
|
||||
- `bun run preview` — preview built app.
|
||||
- `bunx convex dev` — Convex dev deployment + function watcher.
|
||||
- `bunx convex codegen` — regenerate `convex/_generated`.
|
||||
- `bun run format:check` — formatting check.
|
||||
- `bun run lint` — Biome + oxlint (type-aware).
|
||||
- `bun run test` — Vitest (unit tests).
|
||||
- `bun run coverage` — coverage run; keep global >= 80%.
|
||||
@@ -37,6 +38,7 @@
|
||||
|
||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
|
||||
- Keep changes scoped; avoid repo-wide search/replace.
|
||||
- Before commit/PR handoff, run `bun run format:check` and `bun run lint`; include commands run in the PR summary.
|
||||
- PRs: include summary + test commands run. Add screenshots for UI changes.
|
||||
- Before merging any PR, verify TypeScript cleanly with `bunx tsc -p packages/schema/tsconfig.json --noEmit` and `bunx tsc -p packages/clawhub/tsconfig.json --noEmit`; if Convex code changed, also run the repo typecheck path used by deploy so `bunx convex deploy` will not fail on `tsc`.
|
||||
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
|
||||
@@ -90,11 +92,13 @@
|
||||
- **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 -->
|
||||
|
||||
## Stat Field Migration Rules
|
||||
@@ -102,11 +106,11 @@ Convex agent skills for common tasks can be installed by running `npx convex ai-
|
||||
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
|
||||
|
||||
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|
||||
|---|---|
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
| ------------------------------ | -------------------------------------- |
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
},
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
|
||||
+393
-15
@@ -20,6 +20,19 @@ type SeedSkillSpec = {
|
||||
rawSkillMd: string;
|
||||
};
|
||||
|
||||
type SeedPluginSpec = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
summary: string;
|
||||
version: string;
|
||||
runtimeId: string;
|
||||
sourceRepo: string;
|
||||
isOfficial: boolean;
|
||||
capabilityTags: string[];
|
||||
stats: { downloads: number; installs: number; stars: number; versions: number };
|
||||
readme: string;
|
||||
};
|
||||
|
||||
type SeedActionArgs = {
|
||||
reset?: boolean;
|
||||
};
|
||||
@@ -56,6 +69,134 @@ This seeded plugin is public and intentionally has completed scan results so loc
|
||||
preview plugin scanner detail pages without owner-only visibility.
|
||||
`;
|
||||
|
||||
const FEATURED_PLUGIN_SEEDS: SeedPluginSpec[] = [
|
||||
{
|
||||
name: "@apify/apify-openclaw-plugin",
|
||||
displayName: "Apify",
|
||||
summary:
|
||||
"Scrape websites through Apify actors and make structured web data available to agents.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "apify",
|
||||
sourceRepo: "apify/apify-openclaw-plugin",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["web", "scraping", "automation"],
|
||||
stats: { downloads: 1200, installs: 320, stars: 45, versions: 1 },
|
||||
readme: "# Apify\n\nScrape websites through Apify actors from OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "openclaw-codex-app-server",
|
||||
displayName: "Codex App Server Bridge",
|
||||
summary: "Bind OpenClaw chats to Codex App Server conversations and control threads from chat.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "codex-app-server",
|
||||
sourceRepo: "pwrdrvr/openclaw-codex-app-server",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["codex", "chat", "bridge"],
|
||||
stats: { downloads: 980, installs: 280, stars: 37, versions: 1 },
|
||||
readme: "# Codex App Server Bridge\n\nBridge OpenClaw chat sessions to Codex App Server.",
|
||||
},
|
||||
{
|
||||
name: "@largezhou/ddingtalk",
|
||||
displayName: "DingTalk",
|
||||
summary: "Connect OpenClaw to DingTalk enterprise robots with text, image, and file messages.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "dingtalk",
|
||||
sourceRepo: "largezhou/openclaw-dingtalk",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "dingtalk", "enterprise"],
|
||||
stats: { downloads: 930, installs: 250, stars: 32, versions: 1 },
|
||||
readme: "# DingTalk\n\nDingTalk enterprise robot plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "kudosity-openclaw-sms",
|
||||
displayName: "Kudosity SMS",
|
||||
summary: "Send and receive SMS through Kudosity as an OpenClaw plugin.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "kudosity-sms",
|
||||
sourceRepo: "kudosity/openclaw-sms",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "sms", "kudosity"],
|
||||
stats: { downloads: 860, installs: 210, stars: 29, versions: 1 },
|
||||
readme: "# Kudosity SMS\n\nKudosity SMS channel plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
displayName: "Lossless Claw",
|
||||
summary:
|
||||
"Preserve conversation context with DAG-based summarization and incremental compaction.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "lossless-claw",
|
||||
sourceRepo: "Martian-Engineering/lossless-claw",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["memory", "context", "summarization"],
|
||||
stats: { downloads: 820, installs: 190, stars: 28, versions: 1 },
|
||||
readme: "# Lossless Claw\n\nLossless context management plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@opik/opik-openclaw",
|
||||
displayName: "Opik",
|
||||
summary: "Export OpenClaw traces to Opik for monitoring, costs, token usage, and debugging.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "opik",
|
||||
sourceRepo: "comet-ml/opik-openclaw",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["observability", "tracing", "monitoring"],
|
||||
stats: { downloads: 760, installs: 180, stars: 25, versions: 1 },
|
||||
readme: "# Opik\n\nTrace OpenClaw agents with Opik.",
|
||||
},
|
||||
{
|
||||
name: "@prometheusavatar/openclaw-plugin",
|
||||
displayName: "Prometheus Avatar",
|
||||
summary: "Give OpenClaw agents a Live2D avatar with lip-sync, expressions, and speech.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "prometheus-avatar",
|
||||
sourceRepo: "myths-labs/prometheus-avatar",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["avatar", "tts", "live2d"],
|
||||
stats: { downloads: 690, installs: 150, stars: 22, versions: 1 },
|
||||
readme: "# Prometheus Avatar\n\nLive2D avatar plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@tencent-connect/openclaw-qqbot",
|
||||
displayName: "QQbot",
|
||||
summary:
|
||||
"Connect OpenClaw to QQ private chats, group mentions, channel messages, and rich media.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "qqbot",
|
||||
sourceRepo: "tencent-connect/openclaw-qqbot",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["channel", "qq", "messaging"],
|
||||
stats: { downloads: 640, installs: 140, stars: 20, versions: 1 },
|
||||
readme: "# QQbot\n\nQQ Bot plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@wecom/wecom-openclaw-plugin",
|
||||
displayName: "wecom",
|
||||
summary:
|
||||
"Use WeCom Bot WebSocket connections for direct messages, group chats, and proactive messaging.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "wecom",
|
||||
sourceRepo: "WecomTeam/wecom-openclaw-plugin",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["channel", "wecom", "enterprise"],
|
||||
stats: { downloads: 610, installs: 130, stars: 18, versions: 1 },
|
||||
readme: "# wecom\n\nWeCom channel plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "openclaw-plugin-yuanbao",
|
||||
displayName: "Yuanbao",
|
||||
summary:
|
||||
"Connect OpenClaw to Yuanbao with direct messages, group chats, media, and slash commands.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "yuanbao",
|
||||
sourceRepo: "yb-claw/openclaw-plugin-yuanbao",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "yuanbao", "messaging"],
|
||||
stats: { downloads: 580, installs: 125, stars: 17, versions: 1 },
|
||||
readme: "# Yuanbao\n\nYuanbao channel plugin for OpenClaw.",
|
||||
},
|
||||
];
|
||||
|
||||
type RoleHelpFixtureUser = {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
@@ -382,12 +523,13 @@ async function seedNixSkillsHandler(
|
||||
results.push({ slug: spec.slug, ...result });
|
||||
}
|
||||
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
|
||||
await Promise.all([
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
|
||||
]);
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] = await Promise.all(
|
||||
[
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
|
||||
],
|
||||
);
|
||||
const fixtureResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedRescanUxFixturesMutation,
|
||||
{
|
||||
@@ -402,6 +544,32 @@ async function seedNixSkillsHandler(
|
||||
);
|
||||
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
|
||||
|
||||
const featuredPluginStorageIds = await Promise.all(
|
||||
FEATURED_PLUGIN_SEEDS.map(async (spec) =>
|
||||
ctx.storage.store(new Blob([spec.readme], { type: "text/markdown" })),
|
||||
),
|
||||
);
|
||||
const featuredResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedFeaturedPluginPackagesMutation,
|
||||
{
|
||||
reset: args.reset,
|
||||
packages: FEATURED_PLUGIN_SEEDS.map((spec, index) => ({
|
||||
name: spec.name,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
runtimeId: spec.runtimeId,
|
||||
sourceRepo: spec.sourceRepo,
|
||||
isOfficial: spec.isOfficial,
|
||||
capabilityTags: spec.capabilityTags,
|
||||
stats: spec.stats,
|
||||
storageId: featuredPluginStorageIds[index],
|
||||
readmeSize: spec.readme.length,
|
||||
})),
|
||||
},
|
||||
);
|
||||
results.push({ slug: "featured-plugins", ...featuredResult });
|
||||
|
||||
return { ok: true, results };
|
||||
}
|
||||
|
||||
@@ -562,6 +730,59 @@ async function findScannedPluginFixture(ctx: MutationCtx) {
|
||||
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
async function ensureHighlightedSkillBadge(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("skillBadges")
|
||||
.withIndex("by_skill_kind", (q) => q.eq("skillId", skillId).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
} else {
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId,
|
||||
kind: "highlighted",
|
||||
byUserId: userId,
|
||||
at,
|
||||
});
|
||||
}
|
||||
const skill = await ctx.db.get(skillId);
|
||||
if (skill) {
|
||||
await ctx.db.patch(skillId, {
|
||||
badges: {
|
||||
...(skill.badges as Record<string, unknown> | undefined),
|
||||
highlighted: { byUserId: userId, at },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHighlightedPackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
} else {
|
||||
await ctx.db.insert("packageBadges", {
|
||||
packageId,
|
||||
kind: "highlighted",
|
||||
byUserId: userId,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function staticMaliciousScan(now: number) {
|
||||
return {
|
||||
status: "malicious" as const,
|
||||
@@ -1083,6 +1304,166 @@ export const seedRescanUxFixturesMutation = internalMutation({
|
||||
handler: seedRescanUxFixturesHandler,
|
||||
});
|
||||
|
||||
export const seedFeaturedPluginPackagesMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
packages: v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.string(),
|
||||
version: v.string(),
|
||||
runtimeId: v.string(),
|
||||
sourceRepo: v.string(),
|
||||
isOfficial: v.boolean(),
|
||||
capabilityTags: v.array(v.string()),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
installs: v.number(),
|
||||
stars: v.number(),
|
||||
versions: v.number(),
|
||||
}),
|
||||
storageId: v.id("_storage"),
|
||||
readmeSize: v.number(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const seeded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const spec of args.packages) {
|
||||
const existing = await findSeedPluginFixtureByName(ctx, spec.name);
|
||||
if (existing && !args.reset) {
|
||||
await ensureHighlightedPackageBadge(ctx, existing._id, userId, now);
|
||||
skipped.push(spec.name);
|
||||
continue;
|
||||
}
|
||||
if (existing && args.reset) {
|
||||
await deleteSeedPluginFixtureByName(ctx, spec.name);
|
||||
}
|
||||
|
||||
const compatibility = { pluginApiRange: ">=0.1.0" };
|
||||
const capabilities = {
|
||||
executesCode: true,
|
||||
runtimeId: spec.runtimeId,
|
||||
pluginKind: "runtime" as const,
|
||||
capabilityTags: spec.capabilityTags,
|
||||
};
|
||||
const verification = {
|
||||
tier: "source-linked" as const,
|
||||
scope: "artifact-only" as const,
|
||||
summary: "Local dev featured plugin fixture linked to source metadata.",
|
||||
sourceRepo: spec.sourceRepo,
|
||||
scanStatus: "clean" as const,
|
||||
};
|
||||
const normalizedName = normalizePackageName(spec.name);
|
||||
|
||||
const packageId = await ctx.db.insert("packages", {
|
||||
name: spec.name,
|
||||
normalizedName,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: spec.isOfficial,
|
||||
runtimeId: spec.runtimeId,
|
||||
sourceRepo: spec.sourceRepo,
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: spec.capabilityTags,
|
||||
executesCode: true,
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
scanStatus: "clean",
|
||||
stats: { ...spec.stats, versions: 0 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const releaseId = await ctx.db.insert("packageReleases", {
|
||||
packageId,
|
||||
version: spec.version,
|
||||
changelog: "Seeded local featured plugin release.",
|
||||
summary: spec.summary,
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: spec.readmeSize,
|
||||
storageId: spec.storageId,
|
||||
sha256: `seeded-featured-plugin-${normalizedName}`,
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
integritySha256: `seeded-featured-plugin-integrity-${normalizedName}`,
|
||||
extractedPackageJson: {
|
||||
name: spec.name,
|
||||
version: spec.version,
|
||||
description: spec.summary,
|
||||
},
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
sha256hash: `seeded-featured-plugin-hash-${normalizedName}`,
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
analysis: "Local featured plugin fixture scanned clean.",
|
||||
source: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
confidence: "high",
|
||||
summary: "Local featured plugin fixture is safe sample content.",
|
||||
model: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "Local featured plugin fixture static scan clean.",
|
||||
engineVersion: "local-dev-fixture",
|
||||
checkedAt: now,
|
||||
},
|
||||
source: { kind: "github", repo: spec.sourceRepo, path: "." },
|
||||
createdBy: userId,
|
||||
publishActor: { kind: "user", userId },
|
||||
createdAt: now,
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
|
||||
await ctx.db.patch(packageId, {
|
||||
latestReleaseId: releaseId,
|
||||
latestVersionSummary: {
|
||||
version: spec.version,
|
||||
createdAt: now,
|
||||
changelog: "Seeded local featured plugin release.",
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
},
|
||||
tags: { latest: releaseId },
|
||||
stats: { ...spec.stats, versions: 1 },
|
||||
updatedAt: now,
|
||||
});
|
||||
await ensureHighlightedPackageBadge(ctx, packageId, userId, now);
|
||||
seeded.push(spec.name);
|
||||
}
|
||||
|
||||
return { ok: true, seeded, skipped };
|
||||
},
|
||||
});
|
||||
|
||||
export const seedCliRoleHelpFixtures = rawInternalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
@@ -1135,11 +1516,7 @@ async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixture
|
||||
return created;
|
||||
}
|
||||
|
||||
async function replaceRoleHelpFixtureToken(
|
||||
ctx: MutationCtx,
|
||||
userId: Id<"users">,
|
||||
now: number,
|
||||
) {
|
||||
async function replaceRoleHelpFixtureToken(ctx: MutationCtx, userId: Id<"users">, now: number) {
|
||||
const existingTokens = await ctx.db
|
||||
.query("apiTokens")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
@@ -1177,12 +1554,15 @@ export const seedSkillMutation = internalMutation({
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.unique();
|
||||
|
||||
if (existing && !args.reset) {
|
||||
await ensureHighlightedSkillBadge(ctx, existing._id, userId, now);
|
||||
return { ok: true, skipped: true, skillId: existing._id };
|
||||
}
|
||||
|
||||
@@ -1204,9 +1584,6 @@ export const seedSkillMutation = internalMutation({
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: args.slug,
|
||||
displayName: args.displayName,
|
||||
@@ -1216,7 +1593,7 @@ export const seedSkillMutation = internalMutation({
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { redactionApproved: undefined },
|
||||
badges: { highlighted: { byUserId: userId, at: now }, redactionApproved: undefined },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
@@ -1232,6 +1609,7 @@ export const seedSkillMutation = internalMutation({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ensureHighlightedSkillBadge(ctx, skillId, userId, now);
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version: args.version,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import {
|
||||
fetchGitHubRepositoryIdentity,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
@@ -104,6 +104,7 @@ type PackageListQueryArgs = {
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -458,6 +459,7 @@ async function searchPackageCatalogByListing(
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -482,6 +484,7 @@ async function searchPackageCatalogByListing(
|
||||
family: args.family,
|
||||
channel: args.channel,
|
||||
isOfficial: args.isOfficial,
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
executesCode: args.executesCode,
|
||||
capabilityTag: args.capabilityTag,
|
||||
viewerUserId: args.viewerUserId,
|
||||
@@ -650,6 +653,11 @@ async function listPackages(
|
||||
const channelRaw = url.searchParams.get("channel")?.trim();
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const effectiveFamily =
|
||||
family ??
|
||||
@@ -674,6 +682,7 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
@@ -701,6 +710,7 @@ async function listPackages(
|
||||
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -720,6 +730,7 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
@@ -783,6 +794,7 @@ async function listPackages(
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -850,6 +862,7 @@ async function listPackages(
|
||||
family: effectiveFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1279,6 +1292,11 @@ async function searchPackages(
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
@@ -1305,6 +1323,7 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
},
|
||||
@@ -1319,6 +1338,7 @@ async function searchPackages(
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1343,6 +1363,7 @@ async function searchPackages(
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1355,6 +1376,7 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1364,6 +1386,7 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
|
||||
@@ -123,6 +123,7 @@ const insertReleaseInternalHandler = (
|
||||
capabilities?: unknown;
|
||||
verification?: unknown;
|
||||
staticScan?: unknown;
|
||||
allowExistingRelease?: boolean;
|
||||
extractedPackageJson?: unknown;
|
||||
extractedPluginManifest?: unknown;
|
||||
normalizedBundleManifest?: unknown;
|
||||
@@ -577,16 +578,40 @@ function makeInsertReleaseCtx(
|
||||
}
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
if (indexName === "by_package") {
|
||||
return {
|
||||
collect: vi.fn().mockResolvedValue(priorReleases),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_package_version") {
|
||||
const filters = new Map<string, unknown>();
|
||||
const query = {
|
||||
eq(field: string, value: unknown) {
|
||||
filters.set(field, value);
|
||||
return query;
|
||||
},
|
||||
};
|
||||
buildQuery?.(query);
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(
|
||||
priorReleases.find(
|
||||
(release) =>
|
||||
release.packageId === filters.get("packageId") &&
|
||||
release.version === filters.get("version"),
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
@@ -2011,6 +2036,66 @@ describe("packages public queries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate package versions by default", async () => {
|
||||
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
|
||||
makeReleaseDoc({
|
||||
_id: "packageReleases:existing",
|
||||
version: "1.0.0",
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
insertReleaseInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
ownerUserId: "users:owner",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "retry",
|
||||
tags: ["latest"],
|
||||
summary: "demo",
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
).rejects.toThrow("Version 1.0.0 already exists");
|
||||
});
|
||||
|
||||
it("treats matching workflow duplicate package releases as idempotent", async () => {
|
||||
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
|
||||
makeReleaseDoc({
|
||||
_id: "packageReleases:existing",
|
||||
version: "1.0.0",
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
insertReleaseInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
ownerUserId: "users:owner",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "retry",
|
||||
tags: ["latest"],
|
||||
summary: "demo",
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
allowExistingRelease: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:existing",
|
||||
});
|
||||
|
||||
expect(ctx.insert).not.toHaveBeenCalled();
|
||||
expect(ctx.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a latest tag when an untagged promoted release becomes the package latest", async () => {
|
||||
const ctx = makeInsertReleaseCtx(
|
||||
makePackageDoc({
|
||||
|
||||
+192
-1
@@ -167,6 +167,8 @@ type PublicPackageListItem = {
|
||||
executesCode: boolean;
|
||||
verificationTier: PackageVerificationTier | null;
|
||||
};
|
||||
|
||||
type PackageBadgeKind = Doc<"packageBadges">["kind"];
|
||||
type PackageDigestLike = Pick<
|
||||
Doc<"packageSearchDigest">,
|
||||
| "packageId"
|
||||
@@ -409,6 +411,36 @@ function digestMatchesSearchFilters(
|
||||
return digestMatchesFilters(digest, args);
|
||||
}
|
||||
|
||||
async function upsertPackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
kind: PackageBadgeKind,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
return;
|
||||
}
|
||||
await ctx.db.insert("packageBadges", { packageId, kind, byUserId: userId, at });
|
||||
}
|
||||
|
||||
async function removePackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
kind: PackageBadgeKind,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
||||
.unique();
|
||||
if (existing) await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListItem {
|
||||
return {
|
||||
name: digest.name,
|
||||
@@ -935,6 +967,62 @@ function buildPackageCapabilityDigestQuery(
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchHighlightedPackageDigests(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
},
|
||||
) {
|
||||
const viewerUserId = args.viewerUserId;
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const badges = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_kind_at", (q) => q.eq("kind", "highlighted"))
|
||||
.order("desc")
|
||||
.take(MAX_PUBLIC_LIST_PAGE_SIZE);
|
||||
const digests: PackageDigestLike[] = [];
|
||||
for (const badge of badges) {
|
||||
const digest = await ctx.db
|
||||
.query("packageSearchDigest")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", badge.packageId))
|
||||
.unique();
|
||||
if (!digest || digest.softDeletedAt) continue;
|
||||
if (!(await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache))) continue;
|
||||
if (!digestMatchesSearchFilters(digest, args)) continue;
|
||||
digests.push(digest);
|
||||
}
|
||||
return digests;
|
||||
}
|
||||
|
||||
async function fetchHighlightedPackagePage(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
numItems: number;
|
||||
},
|
||||
) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.isOfficial) - Number(a.isOfficial) ||
|
||||
b.updatedAt - a.updatedAt ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.slice(0, args.numItems)
|
||||
.map(toPublicPackageListItem);
|
||||
}
|
||||
|
||||
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
|
||||
return (await ctx.db
|
||||
.query("packages")
|
||||
@@ -1011,6 +1099,32 @@ export const getByName = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getByNameForStaff = query({
|
||||
args: { name: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return null;
|
||||
|
||||
const highlighted = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
|
||||
return {
|
||||
package: pkg,
|
||||
highlighted: highlighted
|
||||
? {
|
||||
byUserId: highlighted.byUserId,
|
||||
at: highlighted.at,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getByNameForViewerInternal = internalQuery({
|
||||
args: {
|
||||
name: v.string(),
|
||||
@@ -1158,6 +1272,7 @@ export const listPublicPage = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -1176,6 +1291,7 @@ export const listPageForViewerInternal = internalQuery({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
@@ -1192,6 +1308,7 @@ async function listPackagePageImpl(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -1206,6 +1323,15 @@ async function listPackagePageImpl(
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
const targetCount = args.paginationOpts.numItems;
|
||||
|
||||
if (args.highlightedOnly) {
|
||||
const page = await fetchHighlightedPackagePage(ctx, {
|
||||
...args,
|
||||
numItems: targetCount,
|
||||
});
|
||||
return { page, isDone: true, continueCursor: "" };
|
||||
}
|
||||
|
||||
const collected: PublicPackageListItem[] = [];
|
||||
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
|
||||
if (decodedCursor.done && decodedCursor.offset === 0) {
|
||||
@@ -1300,6 +1426,7 @@ export const searchPublic = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
@@ -1319,6 +1446,7 @@ export const searchForViewerInternal = internalQuery({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
@@ -1336,6 +1464,7 @@ async function searchPackagesImpl(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -1349,6 +1478,21 @@ async function searchPackagesImpl(
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
if (args.highlightedOnly) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
.map((digest) => ({ score: packageSearchScore(digest, queryText), package: digest }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt,
|
||||
)
|
||||
.slice(0, targetCount)
|
||||
.map((entry) => ({ score: entry.score, package: toPublicPackageListItem(entry.package) }));
|
||||
}
|
||||
|
||||
const builder = args.capabilityTag
|
||||
? buildPackageCapabilityDigestQuery(ctx, {
|
||||
capabilityTag: args.capabilityTag,
|
||||
@@ -2005,6 +2149,9 @@ async function publishPackageImpl(
|
||||
staticScan,
|
||||
files,
|
||||
integritySha256,
|
||||
allowExistingRelease:
|
||||
auth.kind === "github-actions" ||
|
||||
(auth.kind === "user" && manualOverrideReason?.startsWith("GitHub Actions ")),
|
||||
extractedPackageJson: packageJson,
|
||||
extractedPluginManifest:
|
||||
family === "code-plugin" ? maybeParseJson(pluginManifestEntry?.text) : undefined,
|
||||
@@ -2164,6 +2311,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
capabilities: v.optional(v.any()),
|
||||
verification: v.optional(v.any()),
|
||||
staticScan: v.optional(v.any()),
|
||||
allowExistingRelease: v.optional(v.boolean()),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
@@ -2285,7 +2433,20 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
if (releaseExists) {
|
||||
if (
|
||||
args.allowExistingRelease &&
|
||||
!releaseExists.softDeletedAt &&
|
||||
releaseExists.integritySha256 === args.integritySha256
|
||||
) {
|
||||
return {
|
||||
ok: true as const,
|
||||
packageId: existing._id,
|
||||
releaseId: releaseExists._id,
|
||||
};
|
||||
}
|
||||
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
}
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
@@ -2780,6 +2941,36 @@ export const requestRescan = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const setBatch = mutation({
|
||||
args: { packageId: v.id("packages"), batch: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
const pkg = await ctx.db.get(args.packageId);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
throw new ConvexError("Plugin not found");
|
||||
}
|
||||
const nextBatch = args.batch?.trim() || undefined;
|
||||
const nextHighlighted = nextBatch === "highlighted";
|
||||
const now = Date.now();
|
||||
|
||||
if (nextHighlighted) {
|
||||
await upsertPackageBadge(ctx, pkg._id, "highlighted", user._id, now);
|
||||
} else {
|
||||
await removePackageBadge(ctx, pkg._id, "highlighted");
|
||||
}
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: user._id,
|
||||
action: "package.badge.highlighted",
|
||||
targetType: "package",
|
||||
targetId: pkg._id,
|
||||
metadata: { highlighted: nextHighlighted },
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescanForApiTokenInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
|
||||
+12
-6
@@ -547,6 +547,16 @@ const skillBadges = defineTable({
|
||||
.index("by_skill_kind", ["skillId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const packageBadges = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
kind: v.union(v.literal("highlighted")),
|
||||
byUserId: v.id("users"),
|
||||
at: v.number(),
|
||||
})
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_package_kind", ["packageId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const soulVersionFingerprints = defineTable({
|
||||
soulId: v.id("souls"),
|
||||
versionId: v.id("soulVersions"),
|
||||
@@ -1219,12 +1229,7 @@ const rescanRequests = defineTable({
|
||||
.index("by_skill_version", ["targetKind", "skillVersionId", "createdAt"])
|
||||
.index("by_skill_version_status", ["targetKind", "skillVersionId", "status", "createdAt"])
|
||||
.index("by_package_release", ["targetKind", "packageReleaseId", "createdAt"])
|
||||
.index("by_package_release_status", [
|
||||
"targetKind",
|
||||
"packageReleaseId",
|
||||
"status",
|
||||
"createdAt",
|
||||
])
|
||||
.index("by_package_release_status", ["targetKind", "packageReleaseId", "status", "createdAt"])
|
||||
.index("by_requester", ["requestedByUserId", "createdAt"]);
|
||||
|
||||
const apiTokens = defineTable({
|
||||
@@ -1362,6 +1367,7 @@ export default defineSchema({
|
||||
packageReleases,
|
||||
packageTrustedPublishers,
|
||||
packagePublishTokens,
|
||||
packageBadges,
|
||||
packageSearchDigest,
|
||||
packageCapabilitySearchDigest,
|
||||
souls,
|
||||
|
||||
@@ -3055,6 +3055,7 @@ function skillCatalogMatchesFilters(
|
||||
args: {
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
},
|
||||
@@ -3065,6 +3066,7 @@ function skillCatalogMatchesFilters(
|
||||
const isOfficial = isSkillCatalogOfficial(digest);
|
||||
const channel = getSkillCatalogChannel(digest);
|
||||
if (typeof args.isOfficial === "boolean" && isOfficial !== args.isOfficial) return false;
|
||||
if (args.highlightedOnly && !isSkillHighlighted(digest)) return false;
|
||||
if (args.channel && channel !== args.channel) return false;
|
||||
if (args.capabilityTag && !(digest.capabilityTags ?? []).includes(args.capabilityTag))
|
||||
return false;
|
||||
@@ -3120,6 +3122,7 @@ export const listPackageCatalogPage = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -3211,6 +3214,7 @@ export const searchPackageCatalogPublic = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
|
||||
+1
-2
@@ -394,12 +394,11 @@ export const list = query({
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
args: { limit: v.optional(v.number()) },
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dev": "bun --bun vite dev --port 3000",
|
||||
"docs:list": "bun scripts/docs-list.ts",
|
||||
"format": "oxfmt --write",
|
||||
"format:check": "oxfmt --check",
|
||||
"install:local-hooks": "bun scripts/install-git-hooks.mjs",
|
||||
"lint": "bun run lint:oxlint",
|
||||
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const RESCAN_GUIDANCE_LABEL = "r: rescan-guidance";
|
||||
export const RESCAN_GUIDANCE_COMMENT_MARKER = "<!-- clawhub-rescan-guidance -->";
|
||||
export const SUPPRESS_LABEL = "skip-rescan-guidance";
|
||||
|
||||
const DEFAULT_REPO = "openclaw/clawhub";
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const APPLY_CONFIRM_ENV = "CLAWHUB_RESCAN_GUIDANCE_APPLY";
|
||||
|
||||
const explicitIntentRules = [
|
||||
{
|
||||
id: "explicit-rescan",
|
||||
pattern:
|
||||
/\b(?:re[-\s]?scan|rerun(?:ning)?\s+(?:the\s+)?(?:security\s+)?scan|re-run\s+(?:the\s+)?(?:security\s+)?scan|run\s+(?:the\s+)?(?:security\s+)?scan\s+again|scan\s+again)\b/i,
|
||||
},
|
||||
{
|
||||
id: "re-evaluation",
|
||||
pattern: /\b(?:re[-\s]?evaluat(?:e|ion)|reassess|re-assess|re[-\s]?review)\b/i,
|
||||
},
|
||||
{
|
||||
id: "reclassification",
|
||||
pattern:
|
||||
/\b(?:re[-\s]?classif(?:y|ication)|remove\s+(?:the\s+)?suspicious\s+flag|clear\s+(?:the\s+)?suspicious\s+flag|mark\s+(?:it\s+)?(?:as\s+)?(?:clean|benign))\b/i,
|
||||
},
|
||||
{
|
||||
id: "review-after-fix",
|
||||
pattern:
|
||||
/\b(?:security\s+flag\s+review|scan\s+flag\s+review|(?:request(?:ing)?|please)\s+(?:a\s+)?(?:manual\s+)?(?:review|security\s+review)\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version)|review\s+request\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version))\b/i,
|
||||
},
|
||||
{
|
||||
id: "fixed-and-still-flagged",
|
||||
pattern:
|
||||
/\b(?:(?:after|despite)\s+(?:fixing|fixes|metadata\s+fixes|clarifying|removing)|fix(?:ed|es)?\s+.*\b(?:still|yet)\s+.*\b(?:flagged|suspicious))\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const moderationContextRules = [
|
||||
{
|
||||
id: "clawhub-asset",
|
||||
pattern: /\b(?:skill|plugin|package|publisher|published|version|clawhub)\b/i,
|
||||
},
|
||||
{
|
||||
id: "moderation-signal",
|
||||
pattern:
|
||||
/\b(?:suspicious|flagged\s+(?:as\s+)?suspicious|security\s+scan|scanner|virustotal|vt\b|openclaw\s+verdict|moderation|malicious|benign|clean)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const negativeContextRules = [
|
||||
{
|
||||
id: "auth-login",
|
||||
pattern: /\b(?:login|log\s+in|sign[-\s]?in|oauth|unauthorized|token|callback)\b/i,
|
||||
},
|
||||
{
|
||||
id: "install-rate-limit",
|
||||
pattern: /\b(?:install(?:ing)?|rate\s+limit|429|download|npx)\b/i,
|
||||
},
|
||||
{
|
||||
id: "search-indexing",
|
||||
pattern: /\b(?:search|indexed|indexing|explore|catalog|disappeared|hidden)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
export const rescanGuidanceComment = [
|
||||
RESCAN_GUIDANCE_COMMENT_MARKER,
|
||||
'Thanks for the report. Please use the "Rescan" button on the skill/plugin page while signed in as the owner.',
|
||||
"",
|
||||
"You can also request a fresh scan from the CLI:",
|
||||
"- Skill: `clawhub skill rescan <slug>`",
|
||||
"- Plugin/package: `clawhub package rescan <name>`",
|
||||
"",
|
||||
"If the content or metadata changed, publish the fixed version first, then request the rescan for the latest release. I'm closing this issue after posting this guidance. If you're still having trouble after rescanning, please reopen this issue with the ClawHub URL, version, and latest scan result.",
|
||||
].join("\n");
|
||||
|
||||
function normalizeLabel(label) {
|
||||
if (typeof label === "string") return label.trim().toLowerCase();
|
||||
if (label && typeof label.name === "string") return label.name.trim().toLowerCase();
|
||||
return "";
|
||||
}
|
||||
|
||||
function issueLabels(issue) {
|
||||
return Array.isArray(issue.labels) ? issue.labels.map(normalizeLabel).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function issueState(issue) {
|
||||
return String(issue.state ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function issueText(issue) {
|
||||
return `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
|
||||
}
|
||||
|
||||
function matchingRuleIds(rules, text) {
|
||||
return rules.filter((rule) => rule.pattern.test(text)).map((rule) => rule.id);
|
||||
}
|
||||
|
||||
function commentHash(body) {
|
||||
return createHash("sha256").update(body).digest("hex");
|
||||
}
|
||||
|
||||
export function classifyRescanRequest(issue) {
|
||||
const labels = issueLabels(issue);
|
||||
const state = issueState(issue);
|
||||
const text = issueText(issue);
|
||||
|
||||
if (state && state !== "OPEN") {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.pull_request || issue.isPullRequest) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "Skipped because this is a pull request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (labels.includes(SUPPRESS_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${SUPPRESS_LABEL} is present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (labels.includes(RESCAN_GUIDANCE_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is already present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(?:false\s+duplicate|duplicate\s+flag|not\s+a\s+duplicate|duplicate\s+of)\b/i.test(text)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason:
|
||||
"Skipped because this looks like a duplicate-classification appeal, not a rescan request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const explicitMatches = matchingRuleIds(explicitIntentRules, text);
|
||||
if (explicitMatches.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "No explicit rescan, re-evaluation, review, or reclassification request found.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const contextMatches = matchingRuleIds(moderationContextRules, text);
|
||||
if (contextMatches.length < moderationContextRules.length) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: explicitMatches,
|
||||
reason: "Explicit request found, but it lacks ClawHub asset and moderation/scan context.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const negativeMatches = matchingRuleIds(negativeContextRules, text);
|
||||
const hasStrongModerationLanguage =
|
||||
/\b(?:suspicious|flagged|virustotal|vt\b|malicious|benign|clean|security\s+scan|scanner|moderation)\b/i.test(
|
||||
text,
|
||||
);
|
||||
if (negativeMatches.length > 0 && !hasStrongModerationLanguage) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [...explicitMatches, ...contextMatches],
|
||||
reason: `Skipped because it looks like ${negativeMatches.join(", ")} support rather than a moderation rescan request.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const matchedRules = [...explicitMatches, ...contextMatches];
|
||||
return {
|
||||
matched: true,
|
||||
matchedRules,
|
||||
reason: `Explicit rescan guidance match: ${matchedRules.join(", ")}.`,
|
||||
actions: planRescanGuidanceActions(),
|
||||
};
|
||||
}
|
||||
|
||||
export function planRescanGuidanceActions() {
|
||||
return [
|
||||
{
|
||||
type: "add_label",
|
||||
label: RESCAN_GUIDANCE_LABEL,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function planCommentForLabeledIssue(issue) {
|
||||
const labels = issueLabels(issue);
|
||||
const state = issueState(issue);
|
||||
|
||||
if (state && state !== "OPEN") {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.pull_request || issue.isPullRequest) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "Skipped because this is a pull request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!labels.includes(RESCAN_GUIDANCE_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is not present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
matchedRules: ["rescan-guidance-label"],
|
||||
reason: `Matched because ${RESCAN_GUIDANCE_LABEL} is present.`,
|
||||
actions: [
|
||||
{
|
||||
type: "comment",
|
||||
body: rescanGuidanceComment,
|
||||
bodySha256: commentHash(rescanGuidanceComment),
|
||||
},
|
||||
{
|
||||
type: "close",
|
||||
stateReason: "not_planned",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
repo: DEFAULT_REPO,
|
||||
limit: DEFAULT_LIMIT,
|
||||
issues: [],
|
||||
dryRun: true,
|
||||
json: true,
|
||||
commentForLabeledIssue: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--repo") {
|
||||
args.repo = requireValue(argv, (index += 1), "--repo");
|
||||
} else if (arg === "--limit") {
|
||||
args.limit = Number.parseInt(requireValue(argv, (index += 1), "--limit"), 10);
|
||||
} else if (arg === "--issue" || arg === "--item") {
|
||||
args.issues.push(Number.parseInt(requireValue(argv, (index += 1), arg), 10));
|
||||
} else if (arg === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
} else if (arg === "--apply") {
|
||||
args.dryRun = false;
|
||||
} else if (arg === "--comment-for-labeled-issue") {
|
||||
args.commentForLabeledIssue = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isInteger(args.limit) || args.limit < 1) {
|
||||
throw new Error("--limit must be a positive integer.");
|
||||
}
|
||||
if (args.issues.some((issue) => !Number.isInteger(issue) || issue < 1)) {
|
||||
throw new Error("--issue values must be positive integers.");
|
||||
}
|
||||
if (args.commentForLabeledIssue && args.issues.length === 0) {
|
||||
throw new Error("--comment-for-labeled-issue requires --issue.");
|
||||
}
|
||||
if (!args.dryRun && process.env[APPLY_CONFIRM_ENV] !== "1") {
|
||||
throw new Error(`--apply requires ${APPLY_CONFIRM_ENV}=1.`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function requireValue(argv, index, flag) {
|
||||
const value = argv[index];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${flag} requires a value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ghJson(args) {
|
||||
const stdout = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function gh(args, input) {
|
||||
execFileSync("gh", args, {
|
||||
encoding: "utf8",
|
||||
input,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ghOk(args) {
|
||||
try {
|
||||
gh(args);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function labelApiName(label) {
|
||||
return encodeURIComponent(label);
|
||||
}
|
||||
|
||||
function ensureGuidanceLabel(repo) {
|
||||
if (ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) return;
|
||||
const created = ghOk([
|
||||
"label",
|
||||
"create",
|
||||
RESCAN_GUIDANCE_LABEL,
|
||||
"--repo",
|
||||
repo,
|
||||
"--color",
|
||||
"bfdadc",
|
||||
"--description",
|
||||
"Rescan guidance has been posted for this ClawHub item",
|
||||
]);
|
||||
if (!created && !ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) {
|
||||
throw new Error(`Could not create or find label: ${RESCAN_GUIDANCE_LABEL}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGhIssue(issue) {
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title ?? "",
|
||||
body: issue.body ?? "",
|
||||
state: issue.state ?? "",
|
||||
url: issue.url ?? issue.html_url ?? "",
|
||||
labels: issue.labels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchIssues(options) {
|
||||
if (options.issues.length > 0) {
|
||||
return options.issues.map((issueNumber) =>
|
||||
normalizeGhIssue(
|
||||
ghJson([
|
||||
"issue",
|
||||
"view",
|
||||
String(issueNumber),
|
||||
"--repo",
|
||||
options.repo,
|
||||
"--json",
|
||||
"number,title,body,state,url,labels",
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ghJson([
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
options.repo,
|
||||
"--state",
|
||||
"open",
|
||||
"--limit",
|
||||
String(options.limit),
|
||||
"--json",
|
||||
"number,title,body,state,url,labels",
|
||||
]).map(normalizeGhIssue);
|
||||
}
|
||||
|
||||
export function planIssue(issue) {
|
||||
const classification = classifyRescanRequest(issue);
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
matched: classification.matched,
|
||||
matchedRules: classification.matchedRules,
|
||||
reason: classification.reason,
|
||||
actions: classification.actions,
|
||||
};
|
||||
}
|
||||
|
||||
function writeCommentPayload(plan) {
|
||||
const commentAction = plan.actions.find((action) => action.type === "comment");
|
||||
if (!commentAction) return null;
|
||||
return JSON.stringify({ body: commentAction.body });
|
||||
}
|
||||
|
||||
function hasExistingGuidanceComment(repo, number) {
|
||||
const comments = ghJson([
|
||||
"api",
|
||||
`repos/${repo}/issues/${number}/comments?per_page=100`,
|
||||
"--jq",
|
||||
`[.[] | {body}]`,
|
||||
]);
|
||||
return comments.some((comment) =>
|
||||
String(comment.body ?? "").includes(RESCAN_GUIDANCE_COMMENT_MARKER),
|
||||
);
|
||||
}
|
||||
|
||||
function applyPlan(plan, options) {
|
||||
if (!plan.matched) return { number: plan.number, applied: false, reason: plan.reason };
|
||||
if (plan.actions.some((action) => action.type === "add_label")) {
|
||||
ensureGuidanceLabel(options.repo);
|
||||
}
|
||||
const existingGuidanceComment = hasExistingGuidanceComment(options.repo, plan.number);
|
||||
const appliedActions = [];
|
||||
for (const action of plan.actions) {
|
||||
if (action.type === "add_label") {
|
||||
gh([
|
||||
"api",
|
||||
`repos/${options.repo}/issues/${plan.number}/labels`,
|
||||
"--method",
|
||||
"POST",
|
||||
"--field",
|
||||
`labels[]=${action.label}`,
|
||||
]);
|
||||
appliedActions.push(action.type);
|
||||
} else if (action.type === "comment") {
|
||||
if (existingGuidanceComment) continue;
|
||||
gh(
|
||||
[
|
||||
"api",
|
||||
`repos/${options.repo}/issues/${plan.number}/comments`,
|
||||
"--method",
|
||||
"POST",
|
||||
"--input",
|
||||
"-",
|
||||
],
|
||||
writeCommentPayload(plan),
|
||||
);
|
||||
appliedActions.push(action.type);
|
||||
} else if (action.type === "close") {
|
||||
gh(
|
||||
["api", `repos/${options.repo}/issues/${plan.number}`, "--method", "PATCH", "--input", "-"],
|
||||
JSON.stringify({ state: "closed", state_reason: action.stateReason ?? "not_planned" }),
|
||||
);
|
||||
appliedActions.push(action.type);
|
||||
}
|
||||
}
|
||||
return {
|
||||
number: plan.number,
|
||||
applied: appliedActions.length > 0,
|
||||
actions: appliedActions,
|
||||
skippedComment: existingGuidanceComment,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSummary(plans, options) {
|
||||
const matches = plans.filter((plan) => plan.matched);
|
||||
const lines = [
|
||||
`ClawHub rescan auto-response ${options.dryRun ? "dry run" : "apply run"} for ${options.repo}`,
|
||||
`Scanned ${plans.length} issue(s); matched ${matches.length}.`,
|
||||
];
|
||||
for (const plan of matches) {
|
||||
lines.push(`- #${plan.number}: ${plan.title}`);
|
||||
lines.push(` ${plan.url}`);
|
||||
lines.push(` rules: ${plan.matchedRules.join(", ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function helpText() {
|
||||
return [
|
||||
"Usage: bun scripts/github/clawhub-rescan-auto-response.mjs [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --repo <owner/repo> Repository to inspect. Default: openclaw/clawhub",
|
||||
" --limit <n> Number of open issues to scan. Default: 100",
|
||||
" --issue <n> Inspect one issue number. Repeatable.",
|
||||
" --dry-run Preview only. Default.",
|
||||
" --comment-for-labeled-issue",
|
||||
` Post guidance only when ${RESCAN_GUIDANCE_LABEL} is already present.`,
|
||||
` --apply Add the label and guidance comment. Requires ${APPLY_CONFIRM_ENV}=1.`,
|
||||
" --help Show this help.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function runCli(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
if (options.help) {
|
||||
console.log(helpText());
|
||||
return;
|
||||
}
|
||||
const issues = await fetchIssues(options);
|
||||
const plans = options.commentForLabeledIssue
|
||||
? issues.map((issue) => {
|
||||
const classification = planCommentForLabeledIssue(issue);
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
matched: classification.matched,
|
||||
matchedRules: classification.matchedRules,
|
||||
reason: classification.reason,
|
||||
actions: classification.actions,
|
||||
};
|
||||
})
|
||||
: issues.map(planIssue);
|
||||
const applyResults = options.dryRun ? [] : plans.map((plan) => applyPlan(plan, options));
|
||||
console.error(renderSummary(plans, options));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
repo: options.repo,
|
||||
dryRun: options.dryRun,
|
||||
scanned: plans.length,
|
||||
matched: plans.filter((plan) => plan.matched).length,
|
||||
applied: applyResults.filter((result) => result.applied).length,
|
||||
applyResults,
|
||||
plans,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === currentFile) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RESCAN_GUIDANCE_COMMENT_MARKER,
|
||||
RESCAN_GUIDANCE_LABEL,
|
||||
SUPPRESS_LABEL,
|
||||
classifyRescanRequest,
|
||||
planCommentForLabeledIssue,
|
||||
planIssue,
|
||||
rescanGuidanceComment,
|
||||
} from "./clawhub-rescan-auto-response.mjs";
|
||||
|
||||
const issue = (overrides) => ({
|
||||
number: 1,
|
||||
title: "placeholder",
|
||||
body: "",
|
||||
state: "OPEN",
|
||||
url: "https://github.com/openclaw/clawhub/issues/1",
|
||||
labels: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("clawhub rescan auto-response classifier", () => {
|
||||
it.each([
|
||||
[
|
||||
1553,
|
||||
"Re-scan jarviyin/clawpk v5.0.0 - remove suspicious flag",
|
||||
"Please re-run the security scan on v5.0.0 and remove the suspicious flag. The package no longer contains any patterns that should trigger it.",
|
||||
],
|
||||
[
|
||||
1834,
|
||||
"feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
|
||||
"The skill fixed credentials and Unicode control characters. Please re-run the security scan on v2.4.3.",
|
||||
],
|
||||
[
|
||||
1808,
|
||||
"Re-evaluation request: topview-skill (official Topview AI client) - medium-suspicious verdict triggered by emoji ZWJ false positive",
|
||||
"Please re-scan at the current commit and reclassify as Benign. The suspicious scan findings have been fixed.",
|
||||
],
|
||||
[
|
||||
1671,
|
||||
'Request for Security Re-evaluation: "book-companion" skill marked as suspicious',
|
||||
"I have proactively audited the skill and implemented compliance measures. Please review the updated documentation and clear the suspicious flag.",
|
||||
],
|
||||
])("matches explicit rescan/re-evaluation request #%s", (number, title, body) => {
|
||||
const result = classifyRescanRequest(issue({ number, title, body }));
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.matchedRules.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.actions).toEqual([{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }]);
|
||||
expect(rescanGuidanceComment).toContain(RESCAN_GUIDANCE_COMMENT_MARKER);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
589,
|
||||
"Rate limit exceeded when installing clawhub",
|
||||
"npx clawhub@latest install sonoscli returns Rate limit exceeded. Is this not getting fixed?",
|
||||
],
|
||||
[
|
||||
100,
|
||||
"CLI: Auth fails due to redirect from clawhub.ai to www.clawhub.ai",
|
||||
"The clawhub CLI fails to authenticate because a redirect loses the Authorization header.",
|
||||
],
|
||||
[
|
||||
758,
|
||||
"False positive: create-project skill flagged as suspicious by VirusTotal",
|
||||
"The create-project skill has been flagged as suspicious. This appears to be the same class of false positive as other issues.",
|
||||
],
|
||||
[
|
||||
256,
|
||||
"False positive: clawarr-suite flagged as suspicious",
|
||||
"Please review and unflag. All patterns are standard for a media server management tool.",
|
||||
],
|
||||
[
|
||||
1514,
|
||||
"False duplicate flag: claude-to-free is not a duplicate of model-migration",
|
||||
"This skill is not a duplicate. Please remove the duplicate flag.",
|
||||
],
|
||||
])("does not match non-rescan issue #%s", (number, title, body) => {
|
||||
const result = classifyRescanRequest(issue({ number, title, body }));
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips closed issues", () => {
|
||||
const result = classifyRescanRequest(
|
||||
issue({
|
||||
state: "CLOSED",
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.reason).toContain("closed");
|
||||
});
|
||||
|
||||
it("skips pull requests", () => {
|
||||
const result = classifyRescanRequest(
|
||||
issue({
|
||||
isPullRequest: true,
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.reason).toContain("pull request");
|
||||
});
|
||||
|
||||
it("skips suppressed and already-handled issues", () => {
|
||||
expect(
|
||||
classifyRescanRequest(
|
||||
issue({
|
||||
labels: [{ name: SUPPRESS_LABEL }],
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
).reason,
|
||||
).toContain(SUPPRESS_LABEL);
|
||||
|
||||
expect(
|
||||
classifyRescanRequest(
|
||||
issue({
|
||||
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
).reason,
|
||||
).toContain(RESCAN_GUIDANCE_LABEL);
|
||||
});
|
||||
|
||||
it("plans dry-run rows for matched issues", () => {
|
||||
const plan = planIssue(
|
||||
issue({
|
||||
number: 1834,
|
||||
title: "feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
|
||||
body: "This skill has fixed flagged metadata issues. Please re-run the security scan on v2.4.3.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
number: 1834,
|
||||
matched: true,
|
||||
actions: [{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }],
|
||||
});
|
||||
});
|
||||
|
||||
it("plans comments only for issues already labeled for guidance", () => {
|
||||
const plan = planCommentForLabeledIssue(
|
||||
issue({
|
||||
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
|
||||
title: "False positive: example skill flagged as suspicious",
|
||||
body: "Please re-run the security scan.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
matched: true,
|
||||
matchedRules: ["rescan-guidance-label"],
|
||||
actions: [
|
||||
{
|
||||
type: "comment",
|
||||
body: rescanGuidanceComment,
|
||||
bodySha256: expect.any(String),
|
||||
},
|
||||
{
|
||||
type: "close",
|
||||
stateReason: "not_planned",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(rescanGuidanceComment).toContain("reopen this issue");
|
||||
});
|
||||
|
||||
it("does not plan comments without the guidance label", () => {
|
||||
const plan = planCommentForLabeledIssue(
|
||||
issue({
|
||||
title: "False positive: example skill flagged as suspicious",
|
||||
body: "Please re-run the security scan.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
matched: false,
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+287
-133
@@ -1,198 +1,352 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Header from "../components/Header";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HeaderAuthStatus = {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
me: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const siteModeMock = vi.fn(() => "souls");
|
||||
const navigateMock = vi.fn();
|
||||
const { useUnifiedSearchMock } = vi.hoisted(() => ({
|
||||
useUnifiedSearchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultUnifiedSearchResult = {
|
||||
results: [],
|
||||
skillResults: [
|
||||
{
|
||||
type: "skill",
|
||||
ownerHandle: "local",
|
||||
score: 10,
|
||||
skill: {
|
||||
_id: "skills:weather",
|
||||
slug: "weather",
|
||||
displayName: "Weather Skill",
|
||||
ownerUserId: "users:local",
|
||||
stats: { downloads: 1, stars: 2 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
pluginResults: [
|
||||
{
|
||||
type: "plugin",
|
||||
plugin: {
|
||||
name: "weather-plugin",
|
||||
displayName: "Weather Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Plugin weather tools.",
|
||||
ownerHandle: "local",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestVersion: "1.0.0",
|
||||
capabilityTags: [],
|
||||
executesCode: true,
|
||||
verificationTier: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
skillCount: 1,
|
||||
pluginCount: 1,
|
||||
isSearching: false,
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a
|
||||
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => (
|
||||
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const authStatusMock = vi.fn(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
const authStatusMock = vi.fn<() => HeaderAuthStatus>(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
}));
|
||||
|
||||
const setThemeMock = vi.fn();
|
||||
const setModeMock = vi.fn();
|
||||
|
||||
vi.mock("../lib/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/theme-transition", () => ({
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthError", () => ({
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/roles", () => ({
|
||||
isModerator: () => false,
|
||||
isModerator: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => useUnifiedSearchMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/toggle-group", () => ({
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Header from "../components/Header";
|
||||
|
||||
describe("Header", () => {
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
beforeEach(() => {
|
||||
authStatusMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
});
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
render(<Header />);
|
||||
|
||||
it("renders simplified desktop nav and theme toggle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("renders simplified desktop nav and theme toggle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(1);
|
||||
expect(screen.queryByText("Users")).toBeNull();
|
||||
expect(screen.queryByText("Dashboard")).toBeNull();
|
||||
expect(screen.queryByText("Manage")).toBeNull();
|
||||
expect(
|
||||
screen.getByPlaceholderText("Search skills, plugins, users"),
|
||||
).toBeTruthy();
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
|
||||
);
|
||||
expect(setModeMock).toHaveBeenCalledWith("dark");
|
||||
expect(screen.getByRole("button", { name: /Toggle theme\. Current: system/i })).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(1);
|
||||
expect(screen.queryByText("Users")).toBeNull();
|
||||
expect(screen.queryByText("Dashboard")).toBeNull();
|
||||
expect(screen.queryByText("Manage")).toBeNull();
|
||||
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("dark");
|
||||
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(2);
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("shows grouped skills and plugins typeahead without users", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
navigateMock.mockReset();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
render(<Header />);
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "weather" } });
|
||||
|
||||
const labels = Array.from(
|
||||
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
|
||||
)
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
const typeahead = screen.getByRole("listbox");
|
||||
expect(within(typeahead).getByText("Skills")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Skill")).toBeTruthy();
|
||||
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Plugin")).toBeTruthy();
|
||||
expect(within(typeahead).queryByText("Users")).toBeNull();
|
||||
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/search",
|
||||
search: { q: "weather", type: "skills" },
|
||||
});
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("falls back to typed skill search when a typeahead skill has no owner handle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
navigateMock.mockReset();
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
...defaultUnifiedSearchResult,
|
||||
skillResults: [
|
||||
{
|
||||
...defaultUnifiedSearchResult.skillResults[0],
|
||||
ownerHandle: null,
|
||||
skill: {
|
||||
...defaultUnifiedSearchResult.skillResults[0].skill,
|
||||
ownerUserId: "users:opaque-id",
|
||||
ownerPublisherId: "publishers:opaque-id",
|
||||
},
|
||||
},
|
||||
],
|
||||
pluginResults: [],
|
||||
pluginCount: 0,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
render(<Header />);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "weather" } });
|
||||
fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/search",
|
||||
search: { q: "weather", type: "skills" },
|
||||
});
|
||||
expect(navigateMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/publishers%3Aopaque-id/weather",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a single no-results state without section footers", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
isSearching: false,
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "zzzz" } });
|
||||
|
||||
const typeahead = screen.getByRole("listbox");
|
||||
expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy();
|
||||
expect(within(typeahead).queryByText("Skills")).toBeNull();
|
||||
expect(within(typeahead).queryByText("Plugins")).toBeNull();
|
||||
expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull();
|
||||
expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
|
||||
const labels = Array.from(document.querySelectorAll(".mobile-nav-section .mobile-nav-link"))
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
|
||||
it("keeps Stars out of signed-in header navigation", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
authStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: {
|
||||
displayName: "Patrick",
|
||||
email: "patrick@example.com",
|
||||
handle: "patrick",
|
||||
image: null,
|
||||
name: "Patrick",
|
||||
},
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByText("Stars")).toBeNull();
|
||||
expect(screen.getAllByText("Dashboard").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
const fetchFeaturedPluginsMock = vi.fn();
|
||||
const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
|
||||
@@ -57,6 +58,10 @@ vi.mock("../lib/packageApi", () => ({
|
||||
isRateLimitedPackageApiError: (error: unknown) => isRateLimitedPackageApiErrorMock(error),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/featuredCatalog", () => ({
|
||||
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/plugins/index")).Route as unknown as {
|
||||
__config: {
|
||||
@@ -70,6 +75,7 @@ async function loadRoute() {
|
||||
describe("plugins route", () => {
|
||||
beforeEach(() => {
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
fetchFeaturedPluginsMock.mockReset();
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
@@ -92,6 +98,7 @@ describe("plugins route", () => {
|
||||
family: undefined,
|
||||
q: "demo",
|
||||
cursor: undefined,
|
||||
featured: undefined,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
});
|
||||
@@ -211,12 +218,29 @@ describe("plugins route", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("selects featured from the sort group", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Featured" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.search({ family: "code-plugin", cursor: "cursor:current" })).toEqual({
|
||||
family: undefined,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a retryable empty state when the catalog is rate limited", async () => {
|
||||
fetchPluginCatalogMock.mockRejectedValue({ status: 429, retryAfterSeconds: 22 });
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
@@ -237,9 +261,7 @@ describe("plugins route", () => {
|
||||
it("flags API errors for filtered catalog requests", async () => {
|
||||
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
@@ -51,10 +50,10 @@ describe("search route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the users type filter", () => {
|
||||
it("ignores the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => ({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
userCount: 0,
|
||||
isSearching: false,
|
||||
}),
|
||||
}));
|
||||
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
|
||||
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
@@ -64,7 +59,7 @@ describe("search route", () => {
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const rendered = render(<Component />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
|
||||
expect(input.value).toBe("first");
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft" } });
|
||||
@@ -74,7 +69,16 @@ describe("search route", () => {
|
||||
rendered.rerender(<Component />);
|
||||
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
|
||||
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
|
||||
).toBe("second");
|
||||
});
|
||||
|
||||
it("does not render a public users search tab", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryMock = vi.fn();
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/users/index")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
validateSearch?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("users route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
queryMock.mockReset();
|
||||
queryMock.mockResolvedValue({ items: [], total: 0 });
|
||||
});
|
||||
|
||||
it("does not expose public user search", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
await waitFor(() => expect(queryMock).toHaveBeenCalled());
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
|
||||
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
|
||||
expect(route.__config.validateSearch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+347
-24
@@ -1,19 +1,20 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useState } from "react";
|
||||
import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
filterNavItems,
|
||||
type NavIconName,
|
||||
PRIMARY_NAV_ITEMS,
|
||||
} from "../lib/nav-items";
|
||||
import { filterNavItems, type NavIconName, PRIMARY_NAV_ITEMS } from "../lib/nav-items";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -37,6 +38,24 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
type TypeaheadItem =
|
||||
| {
|
||||
kind: "skill";
|
||||
key: string;
|
||||
result: UnifiedSkillResult;
|
||||
}
|
||||
| {
|
||||
kind: "plugin";
|
||||
key: string;
|
||||
result: UnifiedPluginResult;
|
||||
}
|
||||
| {
|
||||
kind: "footer";
|
||||
key: string;
|
||||
section: "skills" | "plugins";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
@@ -62,10 +81,67 @@ export default function Header() {
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const [navSearchQuery, setNavSearchQuery] = useState("");
|
||||
const [typeaheadOpen, setTypeaheadOpen] = useState(false);
|
||||
const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0);
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const searchWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const ThemeModeIcon = getThemeModeIcon(mode);
|
||||
const nextThemeMode = getNextThemeMode(mode);
|
||||
const trimmedNavSearchQuery = navSearchQuery.trim();
|
||||
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
|
||||
const {
|
||||
skillResults,
|
||||
skillCount,
|
||||
pluginResults,
|
||||
pluginCount,
|
||||
isSearching: typeaheadSearching,
|
||||
} = useUnifiedSearch(navSearchQuery, "all", {
|
||||
debounceMs: 180,
|
||||
enabled: showTypeahead,
|
||||
limits: { skills: 4, plugins: 4 },
|
||||
});
|
||||
const typeaheadItems = useMemo<TypeaheadItem[]>(() => {
|
||||
if (!showTypeahead) return [];
|
||||
const items: TypeaheadItem[] = [];
|
||||
for (const result of skillResults) {
|
||||
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
|
||||
}
|
||||
if (skillCount > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-skills",
|
||||
section: "skills",
|
||||
label: `See skill results for "${trimmedNavSearchQuery}"`,
|
||||
});
|
||||
}
|
||||
for (const result of pluginResults) {
|
||||
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
|
||||
}
|
||||
if (pluginCount > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-plugins",
|
||||
section: "plugins",
|
||||
label: `See plugin results for "${trimmedNavSearchQuery}"`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setTypeaheadActiveIndex(0);
|
||||
}, [trimmedNavSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeaheadOpen) return () => {};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (searchWrapRef.current?.contains(event.target as Node)) return;
|
||||
setTypeaheadOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [typeaheadOpen]);
|
||||
|
||||
const setThemeMode = (next: "system" | "light" | "dark") => {
|
||||
applyTheme(next, theme);
|
||||
@@ -89,9 +165,72 @@ export default function Header() {
|
||||
: { q, type: undefined },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
const navigateToTypeaheadItem = (item: TypeaheadItem) => {
|
||||
if (item.kind === "skill") {
|
||||
const resultOwnerHandle = item.result.ownerHandle?.trim();
|
||||
if (!resultOwnerHandle) {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: trimmedNavSearchQuery, type: "skills" },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
void navigate({
|
||||
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
|
||||
});
|
||||
} else if (item.kind === "plugin") {
|
||||
void navigate({
|
||||
to: "/plugins/$name",
|
||||
params: { name: item.result.plugin.name },
|
||||
});
|
||||
} else {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: trimmedNavSearchQuery, type: item.section },
|
||||
});
|
||||
}
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSoulMode) return;
|
||||
if (event.key === "Escape") {
|
||||
setTypeaheadOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return;
|
||||
if (!showTypeahead || typeaheadItems.length === 0) {
|
||||
if (event.key === "ArrowDown" && trimmedNavSearchQuery) {
|
||||
setTypeaheadOpen(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setTypeaheadActiveIndex(
|
||||
(index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length,
|
||||
);
|
||||
} else if (event.key === "Enter") {
|
||||
const activeItem = typeaheadItems[typeaheadActiveIndex];
|
||||
if (!activeItem) return;
|
||||
event.preventDefault();
|
||||
navigateToTypeaheadItem(activeItem);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
@@ -176,17 +315,42 @@ export default function Header() {
|
||||
<span className="brand-name brand-name-responsive">{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"
|
||||
/>
|
||||
</form>
|
||||
<div className="navbar-search-wrap" ref={searchWrapRef}>
|
||||
<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 and plugins"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => {
|
||||
setNavSearchQuery(e.target.value);
|
||||
setTypeaheadOpen(true);
|
||||
}}
|
||||
onFocus={() => setTypeaheadOpen(true)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
aria-label="Search"
|
||||
aria-expanded={showTypeahead}
|
||||
aria-controls="navbar-search-typeahead"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</form>
|
||||
{showTypeahead ? (
|
||||
<SearchTypeahead
|
||||
activeIndex={typeaheadActiveIndex}
|
||||
items={typeaheadItems}
|
||||
loading={typeaheadSearching}
|
||||
onHoverItem={setTypeaheadActiveIndex}
|
||||
onSelectItem={navigateToTypeaheadItem}
|
||||
query={trimmedNavSearchQuery}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="navbar-top-links" aria-label="Primary">
|
||||
{isSoulMode ? (
|
||||
@@ -248,9 +412,6 @@ export default function Header() {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stars">Stars</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -287,7 +448,9 @@ export default function Header() {
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
setAuthError(
|
||||
getUserFacingAuthError(error, "Sign in failed. Please try again."),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -305,19 +468,179 @@ export default function Header() {
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="text"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchTypeahead({
|
||||
activeIndex,
|
||||
items,
|
||||
loading,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
query,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
items: TypeaheadItem[];
|
||||
loading: boolean;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
query: string;
|
||||
}) {
|
||||
const skillItems = items.filter((item) => item.kind === "skill");
|
||||
const pluginItems = items.filter((item) => item.kind === "plugin");
|
||||
const footerItems = items.filter((item) => item.kind === "footer");
|
||||
const skillsFooter = footerItems.find(
|
||||
(item) => item.kind === "footer" && item.section === "skills",
|
||||
);
|
||||
const pluginsFooter = footerItems.find(
|
||||
(item) => item.kind === "footer" && item.section === "plugins",
|
||||
);
|
||||
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
|
||||
<TypeaheadSection
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
label="Skills"
|
||||
sectionItems={skillItems}
|
||||
footer={skillsFooter}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
<TypeaheadSection
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
label="Plugins"
|
||||
sectionItems={pluginItems}
|
||||
footer={pluginsFooter}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
{loading && !hasMatches ? (
|
||||
<div className="navbar-search-typeahead-status">Searching...</div>
|
||||
) : null}
|
||||
{!loading && !hasMatches ? (
|
||||
<div className="navbar-search-typeahead-status">
|
||||
No skills or plugins found for "{query}"
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeaheadSection({
|
||||
activeIndex,
|
||||
footer,
|
||||
items,
|
||||
label,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
sectionItems,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
footer: TypeaheadItem | undefined;
|
||||
items: TypeaheadItem[];
|
||||
label: string;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
sectionItems: TypeaheadItem[];
|
||||
}) {
|
||||
if (sectionItems.length === 0 && !footer) return null;
|
||||
return (
|
||||
<div className="navbar-search-typeahead-section">
|
||||
<div className="navbar-search-typeahead-heading">{label}</div>
|
||||
{sectionItems.map((item) => (
|
||||
<TypeaheadRow
|
||||
key={item.key}
|
||||
active={items[activeIndex]?.key === item.key}
|
||||
item={item}
|
||||
index={items.findIndex((candidate) => candidate.key === item.key)}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
))}
|
||||
{footer ? (
|
||||
<TypeaheadRow
|
||||
active={items[activeIndex]?.key === footer.key}
|
||||
item={footer}
|
||||
index={items.findIndex((candidate) => candidate.key === footer.key)}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeaheadRow({
|
||||
active,
|
||||
index,
|
||||
item,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
}: {
|
||||
active: boolean;
|
||||
index: number;
|
||||
item: TypeaheadItem;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
}) {
|
||||
const body = getTypeaheadRowBody(item);
|
||||
return (
|
||||
<button
|
||||
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseEnter={() => onHoverItem(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
{body.icon ? <span className="navbar-search-typeahead-icon">{body.icon}</span> : null}
|
||||
<span className="navbar-search-typeahead-copy">
|
||||
<span className="navbar-search-typeahead-title">{body.title}</span>
|
||||
{body.meta ? <span className="navbar-search-typeahead-meta">{body.meta}</span> : null}
|
||||
</span>
|
||||
{item.kind === "footer" ? <ArrowRight size={14} aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getTypeaheadRowBody(item: TypeaheadItem) {
|
||||
if (item.kind === "skill") {
|
||||
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
|
||||
return {
|
||||
icon: "S",
|
||||
title: item.result.skill.displayName,
|
||||
meta: `${owner} / ${item.result.skill.slug}`,
|
||||
};
|
||||
}
|
||||
if (item.kind === "plugin") {
|
||||
return {
|
||||
icon: "P",
|
||||
title: item.result.plugin.displayName,
|
||||
meta: item.result.plugin.ownerHandle
|
||||
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
|
||||
: item.result.plugin.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: null,
|
||||
title: item.label,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import { Calendar, Download, History, Package, Scale, Settings, Star } from "lucide-react";
|
||||
import { Calendar, Download, History, Package, Scale, Settings, Star, Upload } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
@@ -171,7 +171,7 @@ export function SkillHeader({
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
{isStaff || settingsHref ? (
|
||||
{canManage || isStaff || settingsHref ? (
|
||||
<div className="skill-title-actions">
|
||||
{isStaff ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
@@ -180,6 +180,14 @@ export function SkillHeader({
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{settingsHref ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<a href={settingsHref}>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { fetchPluginCatalog } from "./packageApi";
|
||||
|
||||
export async function fetchFeaturedPlugins(limit: number = 50) {
|
||||
const result = await fetchPluginCatalog({ featured: true, limit });
|
||||
return result.items;
|
||||
}
|
||||
+18
-3
@@ -255,6 +255,7 @@ export async function fetchPackages(params: {
|
||||
cursor?: string;
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
limit?: number;
|
||||
@@ -267,6 +268,7 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -287,6 +289,7 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -299,6 +302,7 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor?: string;
|
||||
family?: PluginFamily;
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
@@ -308,6 +312,7 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor: params.cursor,
|
||||
family: params.family,
|
||||
isOfficial: params.isOfficial,
|
||||
featured: params.featured,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
@@ -332,6 +337,7 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -339,7 +345,9 @@ export async function fetchPluginCatalog(params: {
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
return {
|
||||
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
|
||||
items: (response?.results ?? [])
|
||||
.map((entry) => entry?.package)
|
||||
.filter(Boolean) as PackageListItem[],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
@@ -350,6 +358,7 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -370,7 +379,10 @@ export async function fetchPackageDetail(name: string): Promise<PackageDetailRes
|
||||
return (await response.json()) as PackageDetailResponse;
|
||||
}
|
||||
|
||||
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
|
||||
export async function fetchPackageVersion(
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<PackageVersionDetail | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
|
||||
@@ -382,7 +394,10 @@ export async function fetchPackageVersion(name: string, version: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
|
||||
export async function fetchPackageReadme(
|
||||
name: string,
|
||||
version?: string | null,
|
||||
): Promise<string | null> {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
|
||||
+46
-47
@@ -1,9 +1,9 @@
|
||||
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 UnifiedSearchType = "all" | "skills" | "plugins";
|
||||
|
||||
export type UnifiedSkillResult = {
|
||||
type: "skill";
|
||||
@@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
|
||||
plugin: PackageListItem;
|
||||
};
|
||||
|
||||
export type UnifiedUserResult = {
|
||||
type: "user";
|
||||
user: PublicUser;
|
||||
};
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
|
||||
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
|
||||
type UnifiedSearchOptions = {
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
limits?: {
|
||||
skills?: number;
|
||||
plugins?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function useUnifiedSearch(
|
||||
query: string,
|
||||
activeType: "all" | "skills" | "plugins" | "users",
|
||||
activeType: UnifiedSearchType,
|
||||
options: UnifiedSearchOptions = {},
|
||||
) {
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
const [results, setResults] = useState<UnifiedResult[]>([]);
|
||||
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
|
||||
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
|
||||
const [skillCount, setSkillCount] = useState(0);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
const debounceMs = options.debounceMs ?? 300;
|
||||
const enabled = options.enabled ?? true;
|
||||
const skillLimit = options.limits?.skills ?? 25;
|
||||
const pluginLimit = options.limits?.plugins ?? 25;
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
if (!enabled || !trimmed) {
|
||||
requestRef.current += 1;
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
}
|
||||
@@ -65,40 +76,34 @@ export function useUnifiedSearch(
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const promises: [
|
||||
Promise<unknown> | null,
|
||||
Promise<{ items: PackageListItem[] }> | null,
|
||||
Promise<{ items: PublicUser[] }> | null,
|
||||
] = [null, null, null];
|
||||
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
|
||||
[null, null];
|
||||
|
||||
if (activeType === "all" || activeType === "skills") {
|
||||
promises[0] = searchSkills({
|
||||
query: trimmed,
|
||||
limit: 25,
|
||||
limit: skillLimit,
|
||||
nonSuspiciousOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
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 }>) ?? []
|
||||
const nextSkillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
score: number;
|
||||
}>) ?? []
|
||||
).map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
@@ -106,32 +111,25 @@ export function useUnifiedSearch(
|
||||
score: entry.score,
|
||||
}));
|
||||
|
||||
const pluginResults: UnifiedPluginResult[] = (
|
||||
const nextPluginResults: 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);
|
||||
setSkillCount(nextSkillResults.length);
|
||||
setPluginCount(nextPluginResults.length);
|
||||
setSkillResults(nextSkillResults);
|
||||
setPluginResults(nextPluginResults);
|
||||
|
||||
const merged: UnifiedResult[] = [];
|
||||
if (activeType === "all") {
|
||||
merged.push(...skillResults, ...pluginResults, ...userResults);
|
||||
merged.push(...nextSkillResults, ...nextPluginResults);
|
||||
} else if (activeType === "skills") {
|
||||
merged.push(...skillResults);
|
||||
} else if (activeType === "plugins") {
|
||||
merged.push(...pluginResults);
|
||||
merged.push(...nextSkillResults);
|
||||
} else {
|
||||
merged.push(...userResults);
|
||||
merged.push(...nextPluginResults);
|
||||
}
|
||||
|
||||
setResults(merged);
|
||||
@@ -139,9 +137,10 @@ export function useUnifiedSearch(
|
||||
console.error("Unified search failed:", error);
|
||||
if (requestId === requestRef.current) {
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestRef.current) {
|
||||
@@ -149,10 +148,10 @@ export function useUnifiedSearch(
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}, debounceMs);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, activeType, searchSkills]);
|
||||
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
|
||||
|
||||
return { results, skillCount, pluginCount, userCount, isSearching };
|
||||
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { Settings } from "./settings";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
@@ -16,6 +18,15 @@ vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => useAuthActionsMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Settings", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
@@ -35,4 +46,30 @@ describe("Settings", () => {
|
||||
expect(screen.getByText(/sign in to access settings\./i)).toBeTruthy();
|
||||
expect(useQueryMock.mock.calls.some(([, args]) => args === "skip")).toBe(true);
|
||||
});
|
||||
|
||||
it("links to starred skills from signed-in settings", () => {
|
||||
useQueryMock.mockImplementation((query, args) => {
|
||||
if (query === api.users.me) {
|
||||
return {
|
||||
_id: "user_123",
|
||||
displayName: "Patrick",
|
||||
name: "Patrick",
|
||||
handle: "patrick",
|
||||
email: "patrick@example.com",
|
||||
image: null,
|
||||
bio: null,
|
||||
};
|
||||
}
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "publisherHandle" in args) {
|
||||
return undefined;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
render(<Settings />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Stars" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "View stars" }).getAttribute("href")).toBe("/stars");
|
||||
});
|
||||
});
|
||||
|
||||
+63
-364
@@ -1,6 +1,5 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -12,10 +11,13 @@ import {
|
||||
Star,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchFeaturedPlugins } from "../lib/featuredCatalog";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
import type { PublicSkill, PublicSoul, PublicUser } from "../lib/publicUser";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
|
||||
@@ -28,13 +30,6 @@ function Home() {
|
||||
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
|
||||
}
|
||||
|
||||
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
|
||||
const SLOT_WORDS = [
|
||||
"Equip", "Install", "Unleash", "Ship", "Build",
|
||||
"Create", "Deploy", "Launch", "Hack", "Scale",
|
||||
"Forge", "Craft", "Wield",
|
||||
];
|
||||
|
||||
function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
@@ -44,7 +39,7 @@ function SkillsHome() {
|
||||
};
|
||||
|
||||
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
|
||||
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
|
||||
const [featuredPlugins, setFeaturedPlugins] = useState<PackageListItem[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -56,15 +51,9 @@ function SkillsHome() {
|
||||
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);
|
||||
fetchFeaturedPlugins(6)
|
||||
.then((items) => {
|
||||
if (!cancelled) setFeaturedPlugins(items);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -104,262 +93,8 @@ function SkillsHome() {
|
||||
// Build carousel cards from highlighted data
|
||||
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
|
||||
|
||||
// ═══ SLOT MACHINE EASTER EGG ═══
|
||||
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
|
||||
const clickTimesRef = useRef<number[]>([]);
|
||||
const [slotState, setSlotState] = useState<
|
||||
| null
|
||||
| { phase: "spinning" }
|
||||
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
|
||||
>(null);
|
||||
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
|
||||
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
|
||||
const confettiRef = useRef<HTMLCanvasElement>(null);
|
||||
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cooldownUntilRef = useRef<number>(0);
|
||||
|
||||
// Clean up timers/intervals if the component unmounts mid-spin
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerSlots = useCallback(() => {
|
||||
// Clean up any previous timers
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
slotTimersRef.current = [];
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
|
||||
setSlotState({ phase: "spinning" });
|
||||
setStoppedReels(new Set());
|
||||
|
||||
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
|
||||
let r0: number, r1: number, r2: number;
|
||||
const isJackpot = Math.random() < 1 / 25;
|
||||
if (isJackpot) {
|
||||
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
|
||||
const isHack = Math.random() < 0.25;
|
||||
if (isHack) {
|
||||
r0 = HACK_INDEX;
|
||||
} else {
|
||||
// Pick any word except Hack
|
||||
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
|
||||
if (idx >= HACK_INDEX) idx++;
|
||||
r0 = idx;
|
||||
}
|
||||
r1 = r0;
|
||||
r2 = r0;
|
||||
} else {
|
||||
// Normal spin — re-roll if accidental triple match
|
||||
do {
|
||||
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
} while (r0 === r1 && r1 === r2);
|
||||
}
|
||||
const results: [number, number, number] = [r0, r1, r2];
|
||||
const landed = new Set<number>();
|
||||
|
||||
// Animate fast offset cycling — only cycle reels that haven't landed
|
||||
let frame = 0;
|
||||
const spinInterval = setInterval(() => {
|
||||
frame++;
|
||||
setSlotReelOffsets((prev) => [
|
||||
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
|
||||
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
|
||||
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
|
||||
]);
|
||||
}, 60);
|
||||
spinIntervalRef.current = spinInterval;
|
||||
|
||||
// Stop reels sequentially with a satisfying stagger
|
||||
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
|
||||
const t = setTimeout(() => {
|
||||
landed.add(reelIdx);
|
||||
setStoppedReels((prev) => new Set(prev).add(reelIdx));
|
||||
setSlotReelOffsets((prev) => {
|
||||
const next = [...prev] as [number, number, number];
|
||||
next[reelIdx] = results[reelIdx];
|
||||
return next;
|
||||
});
|
||||
}, delay);
|
||||
slotTimersRef.current.push(t);
|
||||
};
|
||||
|
||||
stopReel(0, 1200);
|
||||
stopReel(1, 1800);
|
||||
|
||||
const tFinal = setTimeout(() => {
|
||||
clearInterval(spinInterval);
|
||||
spinIntervalRef.current = null;
|
||||
landed.add(2);
|
||||
setStoppedReels(new Set([0, 1, 2]));
|
||||
setSlotReelOffsets(results);
|
||||
const won = r0 === r1 && r1 === r2;
|
||||
const isHackJackpot = won && r0 === HACK_INDEX;
|
||||
setSlotState({ phase: "stopped", results, won, isHackJackpot });
|
||||
if (won) {
|
||||
fireConfetti(isHackJackpot);
|
||||
}
|
||||
// Cooldown: 18s after win, 3s after loss
|
||||
const displayTime = won ? 10000 : 2400;
|
||||
const cooldownTime = won ? 18000 : 3000;
|
||||
cooldownUntilRef.current = Date.now() + cooldownTime;
|
||||
const tReset = setTimeout(() => {
|
||||
setSlotState(null);
|
||||
setStoppedReels(new Set());
|
||||
}, displayTime);
|
||||
slotTimersRef.current.push(tReset);
|
||||
}, 2400);
|
||||
slotTimersRef.current.push(tFinal);
|
||||
}, []);
|
||||
|
||||
const handleLabelClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
// Respect cooldown period
|
||||
if (now < cooldownUntilRef.current) return;
|
||||
clickTimesRef.current.push(now);
|
||||
// Keep only last 3 clicks
|
||||
if (clickTimesRef.current.length > 3) {
|
||||
clickTimesRef.current = clickTimesRef.current.slice(-3);
|
||||
}
|
||||
if (clickTimesRef.current.length === 3) {
|
||||
const first = clickTimesRef.current[0];
|
||||
const last = clickTimesRef.current[2];
|
||||
if (last - first < 800 && !slotState) {
|
||||
clickTimesRef.current = [];
|
||||
triggerSlots();
|
||||
}
|
||||
}
|
||||
}, [slotState, triggerSlots]);
|
||||
|
||||
const fireConfetti = (isHackJackpot: boolean) => {
|
||||
const canvas = confettiRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
canvas.style.display = "block";
|
||||
|
||||
const STANDARD_COLORS = [
|
||||
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
|
||||
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
|
||||
];
|
||||
const OCEAN_COLORS = [
|
||||
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
|
||||
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
|
||||
"#d4453a", "#ff6b6b",
|
||||
];
|
||||
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
|
||||
|
||||
type Particle = {
|
||||
x: number; y: number; vx: number; vy: number;
|
||||
w: number; h: number; color: string; rot: number; vr: number;
|
||||
life: number; shape: "rect" | "bubble" | "claw";
|
||||
};
|
||||
const particles: Particle[] = [];
|
||||
|
||||
const count = isHackJackpot ? 200 : 150;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isBubble = isHackJackpot && Math.random() < 0.35;
|
||||
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
|
||||
particles.push({
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
|
||||
y: canvas.height * 0.35,
|
||||
vx: (Math.random() - 0.5) * 18,
|
||||
vy: isHackJackpot
|
||||
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
|
||||
: -Math.random() * 16 - 4,
|
||||
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
|
||||
h: isBubble ? 0 : Math.random() * 6 + 3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
life: isHackJackpot ? 1.3 : 1,
|
||||
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
|
||||
});
|
||||
}
|
||||
|
||||
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
|
||||
// Simple lobster claw shape
|
||||
context.beginPath();
|
||||
context.moveTo(0, size * 0.5);
|
||||
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
|
||||
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
|
||||
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
|
||||
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let alive = false;
|
||||
for (const p of particles) {
|
||||
if (p.life <= 0) continue;
|
||||
alive = true;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
|
||||
p.vx *= 0.99;
|
||||
p.rot += p.vr;
|
||||
p.life -= isHackJackpot ? 0.005 : 0.008;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === "bubble") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = p.color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha *= 0.7;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha *= 0.15;
|
||||
ctx.fill();
|
||||
} else if (p.shape === "claw") {
|
||||
drawClaw(ctx, p.w);
|
||||
} else {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
if (alive) {
|
||||
requestAnimationFrame(draw);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.style.display = "none";
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
|
||||
const offset = slotReelOffsets[reelIdx];
|
||||
const word = SLOT_WORDS[offset];
|
||||
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
|
||||
return (
|
||||
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
|
||||
<span className="home-v2-slot-word">{word}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="home-v2-main">
|
||||
{/* Confetti canvas for slot machine wins */}
|
||||
<canvas
|
||||
ref={confettiRef}
|
||||
className="home-v2-confetti"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{/* ═══ HERO ═══ */}
|
||||
<section className="home-v2-hero">
|
||||
<div className="home-v2-hero-bg">
|
||||
@@ -370,81 +105,41 @@ function SkillsHome() {
|
||||
<div className="home-v2-ring home-v2-ring-3" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
|
||||
onClick={handleLabelClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
|
||||
>
|
||||
BUILT BY THE COMMUNITY.
|
||||
</div>
|
||||
|
||||
{slotState ? (
|
||||
<h1 className={`home-v2-headline home-v2-headline-slots${
|
||||
slotState.phase === "stopped" && slotState.won
|
||||
? slotState.isHackJackpot
|
||||
? " home-v2-headline-jackpot home-v2-headline-hack"
|
||||
: " home-v2-headline-jackpot"
|
||||
: ""
|
||||
}`}>
|
||||
{slotState.phase === "stopped" && slotState.isHackJackpot && (
|
||||
<img
|
||||
src="/clawd-mark.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="home-v2-hack-lobster"
|
||||
/>
|
||||
)}
|
||||
<span className="home-v2-headline-inner">
|
||||
{renderSlotReel(0)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(1)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(2)}
|
||||
</span>
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
</span>
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="home-v2-search-container">
|
||||
<form className="home-v2-search-bar" onSubmit={handleSearch}>
|
||||
<Search className="home-v2-search-icon" size={20} />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="What are you looking for?"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<kbd>/</kbd>
|
||||
<button type="submit" className="home-v2-search-go">
|
||||
<span className="home-v2-search-go-label">Search</span>{" "}
|
||||
<ArrowRight size={16} />
|
||||
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="home-v2-suggestions">
|
||||
<span className="home-v2-suggestions-label">Try</span>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-suggestion"
|
||||
@@ -480,8 +175,24 @@ function SkillsHome() {
|
||||
{carouselCards.length > 0 && (
|
||||
<section className="home-v2-carousel-section">
|
||||
<div className="home-v2-carousel-header">
|
||||
<h2>Featured</h2>
|
||||
<h2>Featured skills</h2>
|
||||
<div className="home-v2-carousel-controls">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
featured: true,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
<button type="button" className="home-v2-carousel-btn" aria-label="Previous">
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
@@ -516,12 +227,10 @@ function SkillsHome() {
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -554,12 +263,10 @@ function SkillsHome() {
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -650,21 +357,20 @@ function SkillsHome() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ TRENDING ═══ */}
|
||||
{popular.length > 0 && (
|
||||
{/* ═══ FEATURED PLUGINS ═══ */}
|
||||
{featuredPlugins.length > 0 && (
|
||||
<section className="home-v2-trending-section">
|
||||
<div className="home-v2-section-header">
|
||||
<h2>Trending Now</h2>
|
||||
<h2>Featured plugins</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
to="/plugins"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
featured: true,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
@@ -672,33 +378,26 @@ function SkillsHome() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-v2-trending-grid">
|
||||
{popular.slice(0, 6).map((entry) => (
|
||||
{featuredPlugins.slice(0, 6).map((plugin) => (
|
||||
<Link
|
||||
key={entry.skill._id}
|
||||
to={skillLink(entry)}
|
||||
key={plugin.name}
|
||||
to="/plugins/$name"
|
||||
params={{ name: plugin.name }}
|
||||
className="home-v2-trend-card"
|
||||
>
|
||||
<div className="home-v2-trend-head">
|
||||
<div className="home-v2-trend-title">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
</div>
|
||||
<div className="home-v2-trend-title">{plugin.displayName || plugin.name}</div>
|
||||
<div className="home-v2-trend-creator">
|
||||
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
|
||||
{plugin.ownerHandle ? `by @${plugin.ownerHandle}` : "community plugin"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-v2-trend-desc">
|
||||
{entry.skill.summary || "Agent-ready skill pack."}
|
||||
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
|
||||
</div>
|
||||
<div className="home-v2-trend-bottom">
|
||||
<div className="home-v2-trend-signals">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
{plugin.isOfficial ? <span>Verified</span> : null}
|
||||
{plugin.latestVersion ? <span>v{plugin.latestVersion}</span> : null}
|
||||
</div>
|
||||
<span className="home-v2-trend-install">
|
||||
<Download size={13} /> Install
|
||||
|
||||
+215
-187
@@ -26,6 +26,7 @@ import {
|
||||
type PackageVersionDetail,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
import { isModerator } from "../../lib/roles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
type PluginDetailRateLimitState = {
|
||||
@@ -184,8 +185,14 @@ function PluginDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const setPackageBatch = useMutation(api.packages.setBatch);
|
||||
const requestPluginRescan = useMutation(api.packages.requestRescan);
|
||||
const staffPackage = useQuery(
|
||||
api.packages.getByNameForStaff,
|
||||
staff && detail.package ? { name: detail.package.name } : "skip",
|
||||
) as { package: { _id: Id<"packages"> }; highlighted: { at: number } | null } | null | undefined;
|
||||
const rescanState = useQuery(
|
||||
api.packages.getOwnerRescanStateByName,
|
||||
isAuthenticated && detail.package ? { name: detail.package.name } : "skip",
|
||||
@@ -242,6 +249,21 @@ function PluginDetailRoute() {
|
||||
: pkg.family === "bundle-plugin"
|
||||
? `openclaw bundles install clawhub:${pkg.name}`
|
||||
: `openclaw skills install ${pkg.name}`;
|
||||
const isHighlighted = Boolean(staffPackage?.highlighted);
|
||||
|
||||
const toggleHighlighted = async () => {
|
||||
const packageId = staffPackage?.package._id;
|
||||
if (!packageId) return;
|
||||
try {
|
||||
await setPackageBatch({
|
||||
packageId,
|
||||
batch: isHighlighted ? undefined : "highlighted",
|
||||
});
|
||||
toast.success(isHighlighted ? "Plugin unhighlighted." : "Plugin highlighted.");
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "Could not update plugin highlight."));
|
||||
}
|
||||
};
|
||||
|
||||
const capabilities = latestRelease?.capabilities ?? pkg.capabilities;
|
||||
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
|
||||
@@ -304,6 +326,13 @@ function PluginDetailRoute() {
|
||||
<Badge variant="destructive">Download blocked</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
{staffPackage ? (
|
||||
<div className="skill-title-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => void toggleHighlighted()}>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{pkg.summary ?? "No summary provided."}</p>
|
||||
|
||||
@@ -400,195 +429,194 @@ function PluginDetailRoute() {
|
||||
) : null}
|
||||
|
||||
{/* Capabilities */}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(capabilities, null, 2)}
|
||||
ariaLabel="Copy capabilities JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(capabilities, null, 2)}
|
||||
ariaLabel="Copy capabilities JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(compatibility, null, 2)}
|
||||
ariaLabel="Copy compatibility JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(compatibility, null, 2)}
|
||||
ariaLabel="Copy compatibility JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</DetailHero>
|
||||
</DetailPageShell>
|
||||
</main>
|
||||
|
||||
@@ -14,6 +14,7 @@ type PluginSearchState = {
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
featured?: boolean;
|
||||
verified?: boolean;
|
||||
executesCode?: boolean;
|
||||
};
|
||||
@@ -43,14 +44,16 @@ export const Route = createFileRoute("/plugins/")({
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
featured:
|
||||
search.featured === true || search.featured === "true" || search.featured === "1"
|
||||
? true
|
||||
: undefined,
|
||||
verified:
|
||||
search.verified === true || search.verified === "true" || search.verified === "1"
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
@@ -61,6 +64,7 @@ export const Route = createFileRoute("/plugins/")({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
featured: deps.featured,
|
||||
isOfficial: deps.verified,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
@@ -100,14 +104,14 @@ function PluginsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
|
||||
|
||||
|
||||
// Defensive handling for when loader data is unavailable (SSR errors, etc.)
|
||||
const items = loaderData?.items ?? [];
|
||||
const nextCursor = loaderData?.nextCursor ?? null;
|
||||
const rateLimited = loaderData?.rateLimited ?? false;
|
||||
const retryAfterSeconds = loaderData?.retryAfterSeconds ?? null;
|
||||
const apiError = loaderData?.apiError ?? !loaderData;
|
||||
|
||||
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
@@ -136,12 +140,24 @@ function PluginsIndex() {
|
||||
};
|
||||
|
||||
const handleFamilySort = (value: string) => {
|
||||
const family =
|
||||
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
if (value === "featured") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
family: undefined,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const family = value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
featured: undefined,
|
||||
family: family as "code-plugin" | "bundle-plugin" | undefined,
|
||||
}),
|
||||
});
|
||||
@@ -200,11 +216,12 @@ function PluginsIndex() {
|
||||
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<BrowseSidebar
|
||||
sortOptions={[
|
||||
{ value: "featured", label: "Featured" },
|
||||
{ value: "all", label: "All types" },
|
||||
{ value: "code-plugin", label: "Code plugins" },
|
||||
{ value: "bundle-plugin", label: "Bundle plugins" },
|
||||
]}
|
||||
activeSort={search.family ?? "all"}
|
||||
activeSort={search.featured ? "featured" : (search.family ?? "all")}
|
||||
onSortChange={handleFamilySort}
|
||||
filters={[
|
||||
{ key: "verified", label: "Verified only", active: search.verified ?? false },
|
||||
@@ -231,9 +248,7 @@ function PluginsIndex() {
|
||||
<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>
|
||||
<p className="empty-state-body">Try again {formatRetryDelay(retryAfterSeconds)}.</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
|
||||
+13
-43
@@ -3,28 +3,24 @@ import { Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PluginListItem } from "../components/PluginListItem";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { UserListItem } from "../components/UserListItem";
|
||||
import { Card } from "../components/ui/card";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedSearchType,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
type UnifiedUserResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
|
||||
type SearchState = {
|
||||
q?: string;
|
||||
type?: "all" | "skills" | "plugins" | "users";
|
||||
type?: UnifiedSearchType;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
validateSearch: (search): SearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
|
||||
type:
|
||||
search.type === "skills" || search.type === "plugins" || search.type === "users"
|
||||
? search.type
|
||||
: undefined,
|
||||
type: search.type === "skills" || search.type === "plugins" ? search.type : undefined,
|
||||
}),
|
||||
component: UnifiedSearchPage,
|
||||
});
|
||||
@@ -39,7 +35,7 @@ function UnifiedSearchPage() {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
|
||||
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
|
||||
search.q ?? "",
|
||||
activeType,
|
||||
);
|
||||
@@ -52,7 +48,7 @@ function UnifiedSearchPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const setType = (type: "all" | "skills" | "plugins" | "users") => {
|
||||
const setType = (type: UnifiedSearchType) => {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: search.q, type: type === "all" ? undefined : type },
|
||||
@@ -74,11 +70,11 @@ function UnifiedSearchPage() {
|
||||
|
||||
<form className="search-page-form" onSubmit={handleSearch}>
|
||||
<div className="browse-search-bar max-w-[560px] flex-1">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
type="text"
|
||||
placeholder="Search skills, plugins, users..."
|
||||
placeholder="Search skills and plugins..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
@@ -100,9 +96,7 @@ function UnifiedSearchPage() {
|
||||
onClick={() => setType("skills")}
|
||||
>
|
||||
Skills
|
||||
{skillCount > 0 ? (
|
||||
<span className="search-tab-count">{skillCount}</span>
|
||||
) : null}
|
||||
{skillCount > 0 ? <span className="search-tab-count">{skillCount}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
|
||||
@@ -110,17 +104,7 @@ function UnifiedSearchPage() {
|
||||
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}
|
||||
{pluginCount > 0 ? <span className="search-tab-count">{pluginCount}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +114,7 @@ function UnifiedSearchPage() {
|
||||
</Card>
|
||||
) : !search.q ? (
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">
|
||||
Enter a search term to find skills, plugins, and users
|
||||
</p>
|
||||
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
|
||||
</Card>
|
||||
) : results.length === 0 ? (
|
||||
<Card className="text-center p-10">
|
||||
@@ -143,10 +125,8 @@ function UnifiedSearchPage() {
|
||||
{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} />
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -157,19 +137,9 @@ function UnifiedSearchPage() {
|
||||
|
||||
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
|
||||
const skill = result.skill as unknown as PublicSkill;
|
||||
return (
|
||||
<SkillListItem
|
||||
skill={skill}
|
||||
ownerHandle={result.ownerHandle}
|
||||
/>
|
||||
);
|
||||
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} />;
|
||||
}
|
||||
|
||||
+85
-31
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import {
|
||||
Eye,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Moon,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Star,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -257,6 +258,21 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star size={18} />
|
||||
Stars
|
||||
</CardTitle>
|
||||
<CardDescription>Review skills you've starred for quick access.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/stars">View stars</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit profile form */}
|
||||
<Card>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSave}>
|
||||
@@ -297,9 +313,7 @@ export function Settings() {
|
||||
<Settings2 size={18} />
|
||||
Customization
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personalize your ClawHub experience
|
||||
</CardDescription>
|
||||
<CardDescription>Personalize your ClawHub experience</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
|
||||
@@ -316,7 +330,9 @@ export function Settings() {
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-3">
|
||||
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
|
||||
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Theme
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={themeMode === "light" ? "primary" : "ghost"}
|
||||
@@ -392,7 +408,7 @@ export function Settings() {
|
||||
{/* Layout Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
|
||||
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
|
||||
@@ -400,7 +416,9 @@ export function Settings() {
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.layoutDensity}
|
||||
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("layoutDensity", value as LayoutDensity)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="layout-density">
|
||||
<SelectValue />
|
||||
@@ -428,7 +446,9 @@ export function Settings() {
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.listViewMode}
|
||||
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("listViewMode", value as ListViewMode)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="list-view">
|
||||
<SelectValue />
|
||||
@@ -453,7 +473,9 @@ export function Settings() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
|
||||
<Label htmlFor="show-descriptions" className="text-sm">
|
||||
Show descriptions
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-descriptions"
|
||||
checked={preferences.showDescriptions}
|
||||
@@ -461,7 +483,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
|
||||
<Label htmlFor="show-stats" className="text-sm">
|
||||
Show statistics
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-stats"
|
||||
checked={preferences.showStats}
|
||||
@@ -469,7 +493,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
|
||||
<Label htmlFor="show-tags" className="text-sm">
|
||||
Show tags
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-tags"
|
||||
checked={preferences.showTags}
|
||||
@@ -477,7 +503,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
|
||||
<Label htmlFor="sticky-header" className="text-sm">
|
||||
Sticky header
|
||||
</Label>
|
||||
<Switch
|
||||
id="sticky-header"
|
||||
checked={preferences.stickyHeader}
|
||||
@@ -496,15 +524,20 @@ export function Settings() {
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Code & Content
|
||||
</Label>
|
||||
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
|
||||
<Label
|
||||
htmlFor="code-font-size"
|
||||
className="text-xs text-[color:var(--ink-soft)]"
|
||||
>
|
||||
Code font size
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.codeFontSize}
|
||||
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("codeFontSize", value as CodeFontSize)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="code-font-size">
|
||||
<SelectValue />
|
||||
@@ -518,20 +551,23 @@ export function Settings() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
|
||||
<Label
|
||||
htmlFor="animation-level"
|
||||
className="text-xs text-[color:var(--ink-soft)]"
|
||||
>
|
||||
Animation level
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.animationLevel}
|
||||
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("animationLevel", value as AnimationLevel)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="animation-level">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">
|
||||
Full
|
||||
</SelectItem>
|
||||
<SelectItem value="full">Full</SelectItem>
|
||||
<SelectItem value="reduced">Reduced</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -541,7 +577,9 @@ export function Settings() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
|
||||
<Label htmlFor="line-numbers" className="text-sm">
|
||||
Line numbers in code
|
||||
</Label>
|
||||
<Switch
|
||||
id="line-numbers"
|
||||
checked={preferences.lineNumbers}
|
||||
@@ -549,7 +587,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
|
||||
<Label htmlFor="word-wrap" className="text-sm">
|
||||
Word wrap in code
|
||||
</Label>
|
||||
<Switch
|
||||
id="word-wrap"
|
||||
checked={preferences.wordWrap}
|
||||
@@ -567,11 +607,13 @@ export function Settings() {
|
||||
<Eye size={14} className="text-[color:var(--accent)]" />
|
||||
Accessibility
|
||||
</Label>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
|
||||
<Label htmlFor="reduced-motion" className="text-sm">
|
||||
Reduced motion
|
||||
</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -582,8 +624,12 @@ export function Settings() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
|
||||
<Label htmlFor="high-contrast" className="text-sm">
|
||||
High contrast
|
||||
</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Increase color contrast
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="high-contrast"
|
||||
@@ -601,16 +647,22 @@ export function Settings() {
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Experimental
|
||||
</Label>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they're released</p>
|
||||
<Label htmlFor="experimental-features" className="text-sm">
|
||||
Enable experimental features
|
||||
</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Try new features before they're released
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="experimental-features"
|
||||
checked={preferences.experimentalFeatures}
|
||||
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
updatePreference("experimentalFeatures", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,7 +675,9 @@ export function Settings() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Restore all settings to defaults
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SkillsSearchState = {
|
||||
sort?: SortKey;
|
||||
dir?: SortDir;
|
||||
highlighted?: boolean;
|
||||
featured?: boolean;
|
||||
nonSuspicious?: boolean;
|
||||
tag?: string;
|
||||
view?: SkillsView;
|
||||
@@ -57,7 +58,7 @@ export function useSkillsBrowseModel({
|
||||
const navigateTimer = useRef<number>(0);
|
||||
|
||||
const view: SkillsView = search.view ?? "list";
|
||||
const highlightedOnly = search.highlighted ?? false;
|
||||
const featuredOnly = search.featured ?? search.highlighted ?? false;
|
||||
const nonSuspiciousOnly = search.nonSuspicious ?? false;
|
||||
const capabilityTag = search.tag;
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
@@ -72,7 +73,7 @@ export function useSkillsBrowseModel({
|
||||
const listSort = toListSort(sort);
|
||||
const dir = parseDir(search.dir, sort);
|
||||
const searchKey = trimmedQuery
|
||||
? `${trimmedQuery}::${highlightedOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
|
||||
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
|
||||
: "";
|
||||
|
||||
// One-shot paginated fetches (no reactive subscription)
|
||||
@@ -89,7 +90,7 @@ export function useSkillsBrowseModel({
|
||||
numItems: pageSize,
|
||||
sort: listSort,
|
||||
dir,
|
||||
highlightedOnly,
|
||||
highlightedOnly: featuredOnly,
|
||||
nonSuspiciousOnly,
|
||||
capabilityTag,
|
||||
});
|
||||
@@ -105,7 +106,7 @@ export function useSkillsBrowseModel({
|
||||
setListStatus(cursor ? "idle" : "done");
|
||||
}
|
||||
},
|
||||
[capabilityTag, dir, highlightedOnly, listSort, nonSuspiciousOnly],
|
||||
[capabilityTag, dir, featuredOnly, listSort, nonSuspiciousOnly],
|
||||
);
|
||||
|
||||
// Reset and fetch first page when sort/dir/filters change
|
||||
@@ -155,7 +156,7 @@ export function useSkillsBrowseModel({
|
||||
try {
|
||||
const data = (await searchSkills({
|
||||
query: trimmedQuery,
|
||||
highlightedOnly,
|
||||
highlightedOnly: featuredOnly,
|
||||
nonSuspiciousOnly,
|
||||
capabilityTag,
|
||||
limit: searchLimit,
|
||||
@@ -174,7 +175,7 @@ export function useSkillsBrowseModel({
|
||||
}, [
|
||||
capabilityTag,
|
||||
hasQuery,
|
||||
highlightedOnly,
|
||||
featuredOnly,
|
||||
nonSuspiciousOnly,
|
||||
searchLimit,
|
||||
searchSkills,
|
||||
@@ -197,7 +198,8 @@ export function useSkillsBrowseModel({
|
||||
const sorted = useMemo(() => {
|
||||
if (isOtherCategory) {
|
||||
return baseItems.filter((entry) => {
|
||||
const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
|
||||
const text =
|
||||
`${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
|
||||
return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw));
|
||||
});
|
||||
}
|
||||
@@ -319,11 +321,12 @@ export function useSkillsBrowseModel({
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const onToggleHighlighted = useCallback(() => {
|
||||
const onToggleFeatured = useCallback(() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
highlighted: prev.highlighted ? undefined : true,
|
||||
featured: prev.featured || prev.highlighted ? undefined : true,
|
||||
highlighted: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
@@ -375,7 +378,7 @@ export function useSkillsBrowseModel({
|
||||
}, [navigate]);
|
||||
|
||||
const activeFilters: string[] = [];
|
||||
if (highlightedOnly) activeFilters.push("highlighted");
|
||||
if (featuredOnly) activeFilters.push("featured");
|
||||
if (nonSuspiciousOnly) activeFilters.push("non-suspicious");
|
||||
if (capabilityTag) activeFilters.push(SKILL_CAPABILITY_LABELS[capabilityTag] ?? capabilityTag);
|
||||
|
||||
@@ -399,7 +402,7 @@ export function useSkillsBrowseModel({
|
||||
canLoadMore,
|
||||
dir,
|
||||
hasQuery,
|
||||
highlightedOnly,
|
||||
featuredOnly,
|
||||
isLoadingMore,
|
||||
isLoadingSkills,
|
||||
loadMore,
|
||||
@@ -409,7 +412,7 @@ export function useSkillsBrowseModel({
|
||||
onQueryChange,
|
||||
onSortChange,
|
||||
onToggleDir,
|
||||
onToggleHighlighted,
|
||||
onToggleFeatured,
|
||||
onToggleNonSuspicious,
|
||||
onToggleView,
|
||||
query,
|
||||
|
||||
+60
-30
@@ -6,7 +6,7 @@ import { api } from "../../../convex/_generated/api";
|
||||
import { BrowseSidebar } from "../../components/BrowseSidebar";
|
||||
import { SKILL_CATEGORIES } from "../../lib/categories";
|
||||
import { formatCompactStat } from "../../lib/numberFormat";
|
||||
import { parseSort } from "./-params";
|
||||
import { parseDir, parseSort } from "./-params";
|
||||
import { SkillsResults } from "./-SkillsResults";
|
||||
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
|
||||
|
||||
@@ -29,6 +29,10 @@ export const Route = createFileRoute("/skills/")({
|
||||
search.highlighted === "1" || search.highlighted === "true" || search.highlighted === true
|
||||
? true
|
||||
: undefined,
|
||||
featured:
|
||||
search.featured === "1" || search.featured === "true" || search.featured === true
|
||||
? true
|
||||
: undefined,
|
||||
nonSuspicious:
|
||||
search.nonSuspicious === "1" ||
|
||||
search.nonSuspicious === "true" ||
|
||||
@@ -41,7 +45,9 @@ export const Route = createFileRoute("/skills/")({
|
||||
},
|
||||
beforeLoad: ({ search }) => {
|
||||
const hasQuery = Boolean(search.q?.trim());
|
||||
if (hasQuery || search.sort) return;
|
||||
if (hasQuery || search.sort || search.featured || search.highlighted || search.nonSuspicious) {
|
||||
return;
|
||||
}
|
||||
throw redirect({
|
||||
to: "/skills",
|
||||
search: {
|
||||
@@ -49,6 +55,7 @@ export const Route = createFileRoute("/skills/")({
|
||||
sort: "downloads",
|
||||
dir: search.dir || undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
featured: search.featured || undefined,
|
||||
nonSuspicious: search.nonSuspicious || undefined,
|
||||
view: search.view || undefined,
|
||||
focus: search.focus || undefined,
|
||||
@@ -64,8 +71,7 @@ export function SkillsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills);
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const totalSkillsText = typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const model = useSkillsBrowseModel({
|
||||
@@ -80,12 +86,50 @@ export function SkillsIndex() {
|
||||
|
||||
const handleFilterToggle = useCallback(
|
||||
(key: string) => {
|
||||
if (key === "highlighted") model.onToggleHighlighted();
|
||||
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
},
|
||||
[model.onToggleHighlighted, model.onToggleNonSuspicious],
|
||||
[model.onToggleNonSuspicious],
|
||||
);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "featured") {
|
||||
if (!model.featuredOnly) model.onToggleFeatured();
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.featuredOnly) {
|
||||
const nextSort = parseSort(value);
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: nextSort,
|
||||
dir: parseDir(prev.dir, nextSort),
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
model.onSortChange(value);
|
||||
},
|
||||
[model.featuredOnly, model.onSortChange, model.onToggleFeatured, navigate],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
model.onQueryChange("");
|
||||
if (model.featuredOnly) model.onToggleFeatured();
|
||||
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
|
||||
}, [
|
||||
model.featuredOnly,
|
||||
model.onQueryChange,
|
||||
model.onToggleFeatured,
|
||||
model.onToggleNonSuspicious,
|
||||
model.nonSuspiciousOnly,
|
||||
]);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(slug: string | undefined) => {
|
||||
if (slug) {
|
||||
@@ -103,9 +147,8 @@ export function SkillsIndex() {
|
||||
const activeCategory = useMemo(() => {
|
||||
if (!model.query) return undefined;
|
||||
return (
|
||||
SKILL_CATEGORIES.find((c) =>
|
||||
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
|
||||
)?.slug ?? undefined
|
||||
SKILL_CATEGORIES.find((c) => c.keywords.some((k) => k === model.query.trim().toLowerCase()))
|
||||
?.slug ?? undefined
|
||||
);
|
||||
}, [model.query]);
|
||||
|
||||
@@ -122,9 +165,7 @@ export function SkillsIndex() {
|
||||
</button>
|
||||
<h1 className="browse-title">
|
||||
Skills
|
||||
{totalSkillsText ? (
|
||||
<span className="browse-count">{totalSkillsText}</span>
|
||||
) : null}
|
||||
{totalSkillsText ? <span className="browse-count">{totalSkillsText}</span> : null}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="browse-page-search">
|
||||
@@ -142,11 +183,10 @@ export function SkillsIndex() {
|
||||
categories={SKILL_CATEGORIES}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
sortOptions={sortOptionsWithRelevance}
|
||||
activeSort={model.sort}
|
||||
onSortChange={model.onSortChange}
|
||||
sortOptions={[{ value: "featured", label: "Featured" }, ...sortOptionsWithRelevance]}
|
||||
activeSort={model.featuredOnly ? "featured" : model.sort}
|
||||
onSortChange={handleSortChange}
|
||||
filters={[
|
||||
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
|
||||
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
|
||||
]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
@@ -154,19 +194,9 @@ export function SkillsIndex() {
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{model.isLoadingSkills
|
||||
? "\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();
|
||||
}}
|
||||
>
|
||||
{model.isLoadingSkills ? "\u2014" : `${model.sorted.length} results`}
|
||||
{model.hasQuery || model.featuredOnly || model.nonSuspiciousOnly ? (
|
||||
<button className="browse-clear-btn" type="button" onClick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -64,14 +64,23 @@ function SoulsHoldingPage() {
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/users" search={{ q: undefined }}>
|
||||
Browse Users
|
||||
</Link>
|
||||
<Link to="/users">Browse Users</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { 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) => {
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await convexHttp.query(api.users.listPublic, {
|
||||
limit: 48,
|
||||
search: q,
|
||||
});
|
||||
setResult(data as UsersLoaderResult);
|
||||
} finally {
|
||||
@@ -41,9 +29,8 @@ function UsersIndex() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
void fetchUsers(search.q);
|
||||
}, [search.q, fetchUsers]);
|
||||
void fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const users = result?.items ?? [];
|
||||
|
||||
@@ -57,25 +44,6 @@ function UsersIndex() {
|
||||
) : 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">
|
||||
@@ -90,8 +58,7 @@ function UsersIndex() {
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No users found</p>
|
||||
<p className="empty-state-body">Try a different handle or name.</p>
|
||||
<p className="empty-state-title">No users yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
|
||||
+97
-197
@@ -775,6 +775,11 @@ code {
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar-search-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navbar-search:focus-within {
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 2px var(--input-focus-ring);
|
||||
@@ -807,6 +812,98 @@ code {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-section + .navbar-search-typeahead-section {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-heading {
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row:hover,
|
||||
.navbar-search-typeahead-row.is-active {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row.is-footer {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-title,
|
||||
.navbar-search-typeahead-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-meta {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-status {
|
||||
padding: 12px;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.navbar-search-home {
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
@@ -8510,21 +8607,6 @@ code {
|
||||
}
|
||||
|
||||
/* Headline */
|
||||
.home-v2-hero-label {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--hv2-text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition:
|
||||
color 0.2s,
|
||||
text-shadow 0.3s;
|
||||
}
|
||||
.home-v2-headline {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700;
|
||||
@@ -8613,159 +8695,6 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ SLOT MACHINE EASTER EGG ═══ */
|
||||
.home-v2-hero-label:hover {
|
||||
color: var(--hv2-accent);
|
||||
}
|
||||
.home-v2-hero-label-active {
|
||||
color: var(--hv2-accent) !important;
|
||||
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
|
||||
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes home-v2-labelPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-headline-slots {
|
||||
min-height: 1.15em;
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-weight: 800;
|
||||
color: var(--hv2-accent);
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel.spinning .home-v2-slot-word {
|
||||
animation: home-v2-slotBlur 0.12s steps(1) infinite;
|
||||
}
|
||||
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
|
||||
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
@keyframes home-v2-slotBlur {
|
||||
0% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
filter: blur(1px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes home-v2-slotLand {
|
||||
0% {
|
||||
transform: translateY(-8px) scale(1.1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
60% {
|
||||
transform: translateY(2px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-headline-jackpot {
|
||||
animation: home-v2-jackpot 0.5s ease-out;
|
||||
}
|
||||
@keyframes home-v2-jackpot {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.home-v2-headline-jackpot .home-v2-slot-word {
|
||||
color: #ffd93d !important;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 217, 61, 0.6),
|
||||
0 0 40px rgba(255, 217, 61, 0.3);
|
||||
}
|
||||
|
||||
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
|
||||
.home-v2-headline-hack .home-v2-slot-word {
|
||||
color: #22d3ee !important;
|
||||
text-shadow:
|
||||
0 0 24px rgba(34, 211, 238, 0.6),
|
||||
0 0 48px rgba(6, 182, 212, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
.home-v2-headline-hack .home-v2-sep {
|
||||
border-color: #22d3ee;
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
|
||||
}
|
||||
.home-v2-hack-lobster {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5))
|
||||
drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
|
||||
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes home-v2-lobsterReveal {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.18;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0deg);
|
||||
opacity: 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-confetti {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-v2-sub {
|
||||
color: var(--hv2-text-tertiary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 36px;
|
||||
max-width: 580px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.home-v2-sub-clear {
|
||||
color: var(--hv2-text-secondary);
|
||||
max-width: 820px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.home-v2-motto {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8830,16 +8759,6 @@ code {
|
||||
.home-v2-search-bar input::placeholder {
|
||||
color: var(--hv2-text-tertiary);
|
||||
}
|
||||
.home-v2-search-bar kbd {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--hv2-border);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
color: var(--hv2-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-v2-search-go {
|
||||
background: var(--hv2-accent-fill);
|
||||
color: var(--hv2-accent);
|
||||
@@ -8877,12 +8796,6 @@ code {
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.home-v2-suggestions-label {
|
||||
color: var(--hv2-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.home-v2-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8918,11 +8831,6 @@ code {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.home-v2-suggestions-label {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.home-v2-suggestion {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
@@ -9714,11 +9622,6 @@ code {
|
||||
[data-theme-resolved="light"] .home-v2-search-bar input::placeholder {
|
||||
color: #9c8b7a;
|
||||
}
|
||||
[data-theme-resolved="light"] .home-v2-search-bar kbd {
|
||||
border-color: rgba(170, 125, 80, 0.2);
|
||||
color: #9c8b7a;
|
||||
}
|
||||
|
||||
/* Light — search button */
|
||||
[data-theme-resolved="light"] .home-v2-search-go {
|
||||
background: rgba(196, 58, 47, 0.06);
|
||||
@@ -9901,9 +9804,6 @@ code {
|
||||
.home-v2-search-go {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.home-v2-search-bar kbd {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.home-v2-c-icon {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user