Compare commits

...
Author SHA1 Message Date
Patrick Erichsen b3c42b661b Merge pull request #1882 from openclaw/pe/plugin-management-tools
Add plugin curation to management
2026-04-28 22:31:44 -07:00
Vincent Koc f72179f37d chore(ci): update package publish artifact action
Update package publish artifact upload to the Node 24-ready artifact action and align the stale skills default-sort test with current filter behavior.
2026-04-28 22:31:33 -07:00
Patrick Erichsen 1d79f78426 feat: add plugin curation to management 2026-04-28 22:30:43 -07:00
Patrick Erichsen 6209fe3fff Merge pull request #1880 from openclaw/pe/featured-plugin-curation
feat: add featured plugin curation
2026-04-28 22:19:21 -07:00
Patrick Erichsen a7d1701f5a feat: add featured plugin curation 2026-04-28 22:18:35 -07:00
Patrick Erichsen 52da4954f6 Merge pull request #1871 from openclaw/pe/skills-plugins-typeahead
[codex] Add skills/plugins search typeahead
2026-04-28 21:24:26 -07:00
Patrick Erichsen 0ee5958f7a merge: sync with origin main 2026-04-28 21:18:34 -07:00
Patrick Erichsen 6c0163f9f2 feat: add skills plugins search typeahead 2026-04-28 18:33:20 -07:00
28 changed files with 1929 additions and 1047 deletions
+4 -1
View File
@@ -73,6 +73,9 @@ on:
description: Published release id when dry_run is false.
value: ${{ jobs.publish.outputs.release_id }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: ubuntu-latest
@@ -334,7 +337,7 @@ jobs:
PY
- name: Upload publish JSON artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: clawhub-package-publish-json
path: ${{ runner.temp }}/package-publish.json
+9 -5
View File
@@ -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:**
+393 -15
View File
@@ -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,
+24 -1
View File
@@ -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,
}),
+183
View File
@@ -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,41 @@ 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();
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
const owner = toPublicPublisher(
await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
}),
);
return {
package: pkg,
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
owner,
highlighted: highlighted
? {
byUserId: highlighted.byUserId,
at: highlighted.at,
}
: null,
};
},
});
export const getByNameForViewerInternal = internalQuery({
args: {
name: v.string(),
@@ -1158,6 +1281,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 +1300,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 +1317,7 @@ async function listPackagePageImpl(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -1206,6 +1332,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 +1435,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 +1455,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 +1473,7 @@ async function searchPackagesImpl(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -1349,6 +1487,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,
@@ -2797,6 +2950,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
View File
@@ -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,
+4
View File
@@ -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
View File
@@ -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
+145 -3
View File
@@ -1,9 +1,8 @@
/* @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 { beforeEach, describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
type HeaderAuthStatus = {
isAuthenticated: boolean;
@@ -13,6 +12,52 @@ type HeaderAuthStatus = {
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 }) => (
@@ -90,6 +135,10 @@ vi.mock("../lib/gravatar", () => ({
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>,
@@ -105,6 +154,8 @@ vi.mock("../components/ui/toggle-group", () => ({
),
}));
import Header from "../components/Header";
describe("Header", () => {
beforeEach(() => {
authStatusMock.mockReturnValue({
@@ -113,6 +164,7 @@ describe("Header", () => {
me: null,
});
siteModeMock.mockReturnValue("souls");
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
});
it("hides Packages navigation in soul mode on mobile and desktop", () => {
@@ -136,7 +188,7 @@ describe("Header", () => {
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
expect(setModeMock).toHaveBeenCalledWith("dark");
@@ -148,6 +200,96 @@ describe("Header", () => {
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
it("shows grouped skills and plugins typeahead without users", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
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();
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
});
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,
});
render(<Header />);
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");
+28 -6
View File
@@ -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 -3
View File
@@ -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,
});
});
+18 -14
View File
@@ -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();
});
});
@@ -40,7 +40,7 @@ function runBeforeLoad(search: Record<string, unknown>) {
describe("skills route default sort", () => {
it("redirects browse view to downloads when sort is missing", () => {
expect(runBeforeLoad({ nonSuspicious: true })).toEqual({
expect(runBeforeLoad({})).toEqual({
redirect: {
to: "/skills",
search: {
@@ -48,7 +48,8 @@ describe("skills route default sort", () => {
sort: "downloads",
dir: undefined,
highlighted: undefined,
nonSuspicious: true,
featured: undefined,
nonSuspicious: undefined,
tag: undefined,
view: undefined,
focus: undefined,
@@ -61,4 +62,10 @@ describe("skills route default sort", () => {
it("does not redirect when query is present", () => {
expect(runBeforeLoad({ q: "notion" })).toBeUndefined();
});
it("does not redirect when filters are present", () => {
expect(runBeforeLoad({ nonSuspicious: true })).toBeUndefined();
expect(runBeforeLoad({ featured: true })).toBeUndefined();
expect(runBeforeLoad({ highlighted: true })).toBeUndefined();
});
});
+56
View File
@@ -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();
});
});
+343 -19
View File
@@ -1,7 +1,7 @@
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";
@@ -10,6 +10,11 @@ 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,
@@ -33,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();
@@ -58,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);
@@ -85,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">
@@ -172,22 +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 ? (
@@ -305,7 +468,7 @@ 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
@@ -317,6 +480,167 @@ export default function 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}`;
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+63 -364
View File
@@ -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
+151 -35
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
@@ -12,6 +12,8 @@ import {
isSkillHighlighted,
isSkillOfficial,
} from "../lib/badges";
import { familyLabel } from "../lib/packageLabels";
import type { PublicPublisher } from "../lib/publicUser";
import { isAdmin, isModerator } from "../lib/roles";
import { useAuthStatus } from "../lib/useAuthStatus";
@@ -75,6 +77,13 @@ type SkillBySlugResult = {
} | null;
} | null;
type PluginByNameResult = {
package: Doc<"packages">;
latestRelease: Doc<"packageReleases"> | null;
owner: PublicPublisher | null;
highlighted: { byUserId: Id<"users">; at: number } | null;
} | null;
function resolveOwnerParam(
handle: string | null | undefined,
ownerId?: Id<"users"> | Id<"publishers">,
@@ -99,6 +108,7 @@ function promptUnbanReason(label: string) {
export const Route = createFileRoute("/management")({
validateSearch: (search) => ({
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
plugin: typeof search.plugin === "string" && search.plugin.trim() ? search.plugin : undefined,
}),
component: Management,
});
@@ -106,14 +116,20 @@ export const Route = createFileRoute("/management")({
function Management() {
const { me } = useAuthStatus();
const search = Route.useSearch();
const navigate = useNavigate();
const staff = isModerator(me);
const admin = isAdmin(me);
const selectedSlug = search.skill?.trim();
const selectedPluginName = search.plugin?.trim();
const selectedSkill = useQuery(
api.skills.getBySlugForStaff,
staff && selectedSlug ? { slug: selectedSlug, auditLogLimit: SKILL_AUDIT_LOG_LIMIT } : "skip",
) as SkillBySlugResult | undefined;
const selectedPlugin = useQuery(
api.packages.getByNameForStaff,
staff && selectedPluginName ? { name: selectedPluginName } : "skip",
) as PluginByNameResult | undefined;
const selectedSkillId = selectedSkill?.skill?._id ?? null;
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : "skip") as
| RecentVersionEntry[]
@@ -130,6 +146,7 @@ function Management() {
const banUser = useMutation(api.users.banUser);
const unbanUser = useMutation(api.users.unbanUser);
const setBatch = useMutation(api.skills.setBatch);
const setPackageBatch = useMutation(api.packages.setBatch);
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
const hardDelete = useMutation(api.skills.hardDelete);
const changeOwner = useMutation(api.skills.changeOwner);
@@ -145,6 +162,7 @@ function Management() {
const [reportSearchDebounced, setReportSearchDebounced] = useState("");
const [userSearch, setUserSearch] = useState("");
const [userSearchDebounced, setUserSearchDebounced] = useState("");
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
const [skillOverrideNote, setSkillOverrideNote] = useState("");
const userQuery = userSearchDebounced.trim();
@@ -166,6 +184,10 @@ function Management() {
setSkillOverrideNote("");
}, [selectedSkillId]);
useEffect(() => {
setPluginSearch(selectedPluginName ?? "");
}, [selectedPluginName]);
useEffect(() => {
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250);
return () => clearTimeout(handle);
@@ -257,15 +279,22 @@ function Management() {
.catch((error) => window.alert(formatMutationError(error)));
};
const managePlugin = () => {
const name = pluginSearch.trim();
if (!name) return;
void navigate({
to: "/management",
search: { skill: undefined, plugin: name },
});
};
return (
<main className="section">
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Reported skills
</h2>
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
@@ -318,9 +347,7 @@ function Management() {
))}
</div>
) : (
<div className="section-subtitle m-0">
No report reasons yet.
</div>
<div className="section-subtitle m-0">No report reasons yet.</div>
)}
</div>
<div className="management-actions">
@@ -365,9 +392,7 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Skill tools
</h2>
<h2 className="section-title text-[1.2rem] m-0">Skill tools</h2>
{selectedSlug ? (
<div className="section-subtitle mt-2">
Managing "{selectedSlug}" ·{" "}
@@ -417,16 +442,12 @@ function Management() {
{skill.moderationFlags?.length ? (
<div className="management-tags">
{skill.moderationFlags.map((flag: string) => (
<Badge key={flag}>
{flag}
</Badge>
<Badge key={flag}>{flag}</Badge>
))}
</div>
) : null}
<div className="management-sublist">
<div className="section-subtitle m-0">
Manual overrides
</div>
<div className="section-subtitle m-0">Manual overrides</div>
<section className="management-override-panel">
<div className="management-report-item">
<span className="management-report-meta">Current override</span>
@@ -478,18 +499,14 @@ function Management() {
</section>
</div>
<div className="management-sublist">
<div className="section-subtitle m-0">
Recent audit activity
</div>
<div className="section-subtitle m-0">Recent audit activity</div>
<section className="management-override-panel management-audit-panel">
<div className="management-report-item">
<span className="management-report-meta">Window</span>
<span>Last {SKILL_AUDIT_LOG_LIMIT} entries for this skill.</span>
</div>
{auditLogs.length === 0 ? (
<div className="section-subtitle m-0">
No audit activity yet.
</div>
<div className="section-subtitle m-0">No audit activity yet.</div>
) : (
<div className="management-audit-list">
{auditLogs.map((entry) => {
@@ -590,10 +607,7 @@ function Management() {
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link
to="/$owner/$slug"
params={{ owner: ownerParam, slug: skill.slug }}
>
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
View
</Link>
</Button>
@@ -693,9 +707,115 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Duplicate candidates
</h2>
<h2 className="section-title text-[1.2rem] m-0">Plugin tools</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Package</span>
<input
type="search"
placeholder="@scope/plugin-name or package-name"
value={pluginSearch}
onChange={(event) => setPluginSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
managePlugin();
}
}}
/>
</div>
<Button type="button" onClick={managePlugin} disabled={!pluginSearch.trim()}>
Manage
</Button>
</div>
{selectedPluginName ? (
<div className="section-subtitle mt-2">
Managing "{selectedPluginName}" ·{" "}
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Clear selection
</Link>
</div>
) : null}
<div className="management-list">
{!selectedPluginName ? (
<div className="stat">Enter a plugin package name to open tooling here.</div>
) : selectedPlugin === undefined ? (
<div className="stat">Loading plugin</div>
) : !selectedPlugin?.package ? (
<div className="stat">No plugin found for "{selectedPluginName}".</div>
) : (
(() => {
const plugin = selectedPlugin.package;
const owner = selectedPlugin.owner;
const latestRelease = selectedPlugin.latestRelease;
const isHighlighted = Boolean(selectedPlugin.highlighted);
return (
<div key={plugin._id} className="management-item management-item-detail">
<div className="management-item-main">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
{plugin.displayName}
</Link>
<div className="section-subtitle m-0">
{owner?.handle ? `@${owner.handle}` : "unknown owner"} ·{" "}
{familyLabel(plugin.family)} · v{latestRelease?.version ?? "—"} · updated{" "}
{formatTimestamp(plugin.updatedAt)}
{plugin.softDeletedAt ? " · hidden" : ""}
{isHighlighted ? " · highlighted" : ""}
</div>
<div className="management-tags">
<Badge>{plugin.channel}</Badge>
{plugin.isOfficial ? <Badge>official</Badge> : null}
{plugin.executesCode ? <Badge>executes code</Badge> : null}
{plugin.runtimeId ? <Badge>{plugin.runtimeId}</Badge> : null}
</div>
<div className="management-sublist">
<div className="management-report-item">
<span className="management-report-meta">Package name</span>
<span className="mono">{plugin.name}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Summary</span>
<span>{plugin.summary ?? "No summary provided."}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Featured state</span>
<span>
{isHighlighted
? `Highlighted ${formatTimestamp(selectedPlugin.highlighted?.at ?? 0)}`
: "Not highlighted"}
</span>
</div>
</div>
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
View
</Link>
</Button>
<Button
className="management-action-btn"
type="button"
onClick={() =>
void setPackageBatch({
packageId: plugin._id,
batch: isHighlighted ? undefined : "highlighted",
}).catch((error) => window.alert(formatMutationError(error)))
}
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</Button>
</div>
</div>
);
})()
)}
</div>
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">Duplicate candidates</h2>
<div className="management-list">
{duplicateCandidates.length === 0 ? (
<div className="stat">No duplicate candidates.</div>
@@ -784,9 +904,7 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Recent pushes
</h2>
<h2 className="section-title text-[1.2rem] m-0">Recent pushes</h2>
<div className="management-list">
{recentVersions.length === 0 ? (
<div className="stat">No recent versions.</div>
@@ -832,9 +950,7 @@ function Management() {
{admin ? (
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">
Users
</h2>
<h2 className="section-title text-[1.2rem] m-0">Users</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
+185 -186
View File
@@ -400,195 +400,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>
+26 -11
View File
@@ -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
View File
@@ -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} />;
}
+15 -12
View File
@@ -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
View File
@@ -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}
+13 -4
View File
@@ -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>
+5 -38
View File
@@ -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
View File
@@ -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;
}