mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c42b661b | ||
|
|
f72179f37d | ||
|
|
1d79f78426 | ||
|
|
6209fe3fff | ||
|
|
a7d1701f5a | ||
|
|
52da4954f6 | ||
|
|
0ee5958f7a | ||
|
|
6c0163f9f2 |
@@ -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
|
||||
|
||||
@@ -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
@@ -20,6 +20,19 @@ type SeedSkillSpec = {
|
||||
rawSkillMd: string;
|
||||
};
|
||||
|
||||
type SeedPluginSpec = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
summary: string;
|
||||
version: string;
|
||||
runtimeId: string;
|
||||
sourceRepo: string;
|
||||
isOfficial: boolean;
|
||||
capabilityTags: string[];
|
||||
stats: { downloads: number; installs: number; stars: number; versions: number };
|
||||
readme: string;
|
||||
};
|
||||
|
||||
type SeedActionArgs = {
|
||||
reset?: boolean;
|
||||
};
|
||||
@@ -56,6 +69,134 @@ This seeded plugin is public and intentionally has completed scan results so loc
|
||||
preview plugin scanner detail pages without owner-only visibility.
|
||||
`;
|
||||
|
||||
const FEATURED_PLUGIN_SEEDS: SeedPluginSpec[] = [
|
||||
{
|
||||
name: "@apify/apify-openclaw-plugin",
|
||||
displayName: "Apify",
|
||||
summary:
|
||||
"Scrape websites through Apify actors and make structured web data available to agents.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "apify",
|
||||
sourceRepo: "apify/apify-openclaw-plugin",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["web", "scraping", "automation"],
|
||||
stats: { downloads: 1200, installs: 320, stars: 45, versions: 1 },
|
||||
readme: "# Apify\n\nScrape websites through Apify actors from OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "openclaw-codex-app-server",
|
||||
displayName: "Codex App Server Bridge",
|
||||
summary: "Bind OpenClaw chats to Codex App Server conversations and control threads from chat.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "codex-app-server",
|
||||
sourceRepo: "pwrdrvr/openclaw-codex-app-server",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["codex", "chat", "bridge"],
|
||||
stats: { downloads: 980, installs: 280, stars: 37, versions: 1 },
|
||||
readme: "# Codex App Server Bridge\n\nBridge OpenClaw chat sessions to Codex App Server.",
|
||||
},
|
||||
{
|
||||
name: "@largezhou/ddingtalk",
|
||||
displayName: "DingTalk",
|
||||
summary: "Connect OpenClaw to DingTalk enterprise robots with text, image, and file messages.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "dingtalk",
|
||||
sourceRepo: "largezhou/openclaw-dingtalk",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "dingtalk", "enterprise"],
|
||||
stats: { downloads: 930, installs: 250, stars: 32, versions: 1 },
|
||||
readme: "# DingTalk\n\nDingTalk enterprise robot plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "kudosity-openclaw-sms",
|
||||
displayName: "Kudosity SMS",
|
||||
summary: "Send and receive SMS through Kudosity as an OpenClaw plugin.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "kudosity-sms",
|
||||
sourceRepo: "kudosity/openclaw-sms",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "sms", "kudosity"],
|
||||
stats: { downloads: 860, installs: 210, stars: 29, versions: 1 },
|
||||
readme: "# Kudosity SMS\n\nKudosity SMS channel plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
displayName: "Lossless Claw",
|
||||
summary:
|
||||
"Preserve conversation context with DAG-based summarization and incremental compaction.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "lossless-claw",
|
||||
sourceRepo: "Martian-Engineering/lossless-claw",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["memory", "context", "summarization"],
|
||||
stats: { downloads: 820, installs: 190, stars: 28, versions: 1 },
|
||||
readme: "# Lossless Claw\n\nLossless context management plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@opik/opik-openclaw",
|
||||
displayName: "Opik",
|
||||
summary: "Export OpenClaw traces to Opik for monitoring, costs, token usage, and debugging.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "opik",
|
||||
sourceRepo: "comet-ml/opik-openclaw",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["observability", "tracing", "monitoring"],
|
||||
stats: { downloads: 760, installs: 180, stars: 25, versions: 1 },
|
||||
readme: "# Opik\n\nTrace OpenClaw agents with Opik.",
|
||||
},
|
||||
{
|
||||
name: "@prometheusavatar/openclaw-plugin",
|
||||
displayName: "Prometheus Avatar",
|
||||
summary: "Give OpenClaw agents a Live2D avatar with lip-sync, expressions, and speech.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "prometheus-avatar",
|
||||
sourceRepo: "myths-labs/prometheus-avatar",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["avatar", "tts", "live2d"],
|
||||
stats: { downloads: 690, installs: 150, stars: 22, versions: 1 },
|
||||
readme: "# Prometheus Avatar\n\nLive2D avatar plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@tencent-connect/openclaw-qqbot",
|
||||
displayName: "QQbot",
|
||||
summary:
|
||||
"Connect OpenClaw to QQ private chats, group mentions, channel messages, and rich media.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "qqbot",
|
||||
sourceRepo: "tencent-connect/openclaw-qqbot",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["channel", "qq", "messaging"],
|
||||
stats: { downloads: 640, installs: 140, stars: 20, versions: 1 },
|
||||
readme: "# QQbot\n\nQQ Bot plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@wecom/wecom-openclaw-plugin",
|
||||
displayName: "wecom",
|
||||
summary:
|
||||
"Use WeCom Bot WebSocket connections for direct messages, group chats, and proactive messaging.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "wecom",
|
||||
sourceRepo: "WecomTeam/wecom-openclaw-plugin",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["channel", "wecom", "enterprise"],
|
||||
stats: { downloads: 610, installs: 130, stars: 18, versions: 1 },
|
||||
readme: "# wecom\n\nWeCom channel plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "openclaw-plugin-yuanbao",
|
||||
displayName: "Yuanbao",
|
||||
summary:
|
||||
"Connect OpenClaw to Yuanbao with direct messages, group chats, media, and slash commands.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "yuanbao",
|
||||
sourceRepo: "yb-claw/openclaw-plugin-yuanbao",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "yuanbao", "messaging"],
|
||||
stats: { downloads: 580, installs: 125, stars: 17, versions: 1 },
|
||||
readme: "# Yuanbao\n\nYuanbao channel plugin for OpenClaw.",
|
||||
},
|
||||
];
|
||||
|
||||
type RoleHelpFixtureUser = {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
@@ -382,12 +523,13 @@ async function seedNixSkillsHandler(
|
||||
results.push({ slug: spec.slug, ...result });
|
||||
}
|
||||
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
|
||||
await Promise.all([
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
|
||||
]);
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] = await Promise.all(
|
||||
[
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
|
||||
],
|
||||
);
|
||||
const fixtureResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedRescanUxFixturesMutation,
|
||||
{
|
||||
@@ -402,6 +544,32 @@ async function seedNixSkillsHandler(
|
||||
);
|
||||
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
|
||||
|
||||
const featuredPluginStorageIds = await Promise.all(
|
||||
FEATURED_PLUGIN_SEEDS.map(async (spec) =>
|
||||
ctx.storage.store(new Blob([spec.readme], { type: "text/markdown" })),
|
||||
),
|
||||
);
|
||||
const featuredResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedFeaturedPluginPackagesMutation,
|
||||
{
|
||||
reset: args.reset,
|
||||
packages: FEATURED_PLUGIN_SEEDS.map((spec, index) => ({
|
||||
name: spec.name,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
runtimeId: spec.runtimeId,
|
||||
sourceRepo: spec.sourceRepo,
|
||||
isOfficial: spec.isOfficial,
|
||||
capabilityTags: spec.capabilityTags,
|
||||
stats: spec.stats,
|
||||
storageId: featuredPluginStorageIds[index],
|
||||
readmeSize: spec.readme.length,
|
||||
})),
|
||||
},
|
||||
);
|
||||
results.push({ slug: "featured-plugins", ...featuredResult });
|
||||
|
||||
return { ok: true, results };
|
||||
}
|
||||
|
||||
@@ -562,6 +730,59 @@ async function findScannedPluginFixture(ctx: MutationCtx) {
|
||||
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
async function ensureHighlightedSkillBadge(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("skillBadges")
|
||||
.withIndex("by_skill_kind", (q) => q.eq("skillId", skillId).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
} else {
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId,
|
||||
kind: "highlighted",
|
||||
byUserId: userId,
|
||||
at,
|
||||
});
|
||||
}
|
||||
const skill = await ctx.db.get(skillId);
|
||||
if (skill) {
|
||||
await ctx.db.patch(skillId, {
|
||||
badges: {
|
||||
...(skill.badges as Record<string, unknown> | undefined),
|
||||
highlighted: { byUserId: userId, at },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHighlightedPackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
} else {
|
||||
await ctx.db.insert("packageBadges", {
|
||||
packageId,
|
||||
kind: "highlighted",
|
||||
byUserId: userId,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function staticMaliciousScan(now: number) {
|
||||
return {
|
||||
status: "malicious" as const,
|
||||
@@ -1083,6 +1304,166 @@ export const seedRescanUxFixturesMutation = internalMutation({
|
||||
handler: seedRescanUxFixturesHandler,
|
||||
});
|
||||
|
||||
export const seedFeaturedPluginPackagesMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
packages: v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.string(),
|
||||
version: v.string(),
|
||||
runtimeId: v.string(),
|
||||
sourceRepo: v.string(),
|
||||
isOfficial: v.boolean(),
|
||||
capabilityTags: v.array(v.string()),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
installs: v.number(),
|
||||
stars: v.number(),
|
||||
versions: v.number(),
|
||||
}),
|
||||
storageId: v.id("_storage"),
|
||||
readmeSize: v.number(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const seeded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const spec of args.packages) {
|
||||
const existing = await findSeedPluginFixtureByName(ctx, spec.name);
|
||||
if (existing && !args.reset) {
|
||||
await ensureHighlightedPackageBadge(ctx, existing._id, userId, now);
|
||||
skipped.push(spec.name);
|
||||
continue;
|
||||
}
|
||||
if (existing && args.reset) {
|
||||
await deleteSeedPluginFixtureByName(ctx, spec.name);
|
||||
}
|
||||
|
||||
const compatibility = { pluginApiRange: ">=0.1.0" };
|
||||
const capabilities = {
|
||||
executesCode: true,
|
||||
runtimeId: spec.runtimeId,
|
||||
pluginKind: "runtime" as const,
|
||||
capabilityTags: spec.capabilityTags,
|
||||
};
|
||||
const verification = {
|
||||
tier: "source-linked" as const,
|
||||
scope: "artifact-only" as const,
|
||||
summary: "Local dev featured plugin fixture linked to source metadata.",
|
||||
sourceRepo: spec.sourceRepo,
|
||||
scanStatus: "clean" as const,
|
||||
};
|
||||
const normalizedName = normalizePackageName(spec.name);
|
||||
|
||||
const packageId = await ctx.db.insert("packages", {
|
||||
name: spec.name,
|
||||
normalizedName,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: spec.isOfficial,
|
||||
runtimeId: spec.runtimeId,
|
||||
sourceRepo: spec.sourceRepo,
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: spec.capabilityTags,
|
||||
executesCode: true,
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
scanStatus: "clean",
|
||||
stats: { ...spec.stats, versions: 0 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const releaseId = await ctx.db.insert("packageReleases", {
|
||||
packageId,
|
||||
version: spec.version,
|
||||
changelog: "Seeded local featured plugin release.",
|
||||
summary: spec.summary,
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: spec.readmeSize,
|
||||
storageId: spec.storageId,
|
||||
sha256: `seeded-featured-plugin-${normalizedName}`,
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
integritySha256: `seeded-featured-plugin-integrity-${normalizedName}`,
|
||||
extractedPackageJson: {
|
||||
name: spec.name,
|
||||
version: spec.version,
|
||||
description: spec.summary,
|
||||
},
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
sha256hash: `seeded-featured-plugin-hash-${normalizedName}`,
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
analysis: "Local featured plugin fixture scanned clean.",
|
||||
source: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
confidence: "high",
|
||||
summary: "Local featured plugin fixture is safe sample content.",
|
||||
model: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "Local featured plugin fixture static scan clean.",
|
||||
engineVersion: "local-dev-fixture",
|
||||
checkedAt: now,
|
||||
},
|
||||
source: { kind: "github", repo: spec.sourceRepo, path: "." },
|
||||
createdBy: userId,
|
||||
publishActor: { kind: "user", userId },
|
||||
createdAt: now,
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
|
||||
await ctx.db.patch(packageId, {
|
||||
latestReleaseId: releaseId,
|
||||
latestVersionSummary: {
|
||||
version: spec.version,
|
||||
createdAt: now,
|
||||
changelog: "Seeded local featured plugin release.",
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
},
|
||||
tags: { latest: releaseId },
|
||||
stats: { ...spec.stats, versions: 1 },
|
||||
updatedAt: now,
|
||||
});
|
||||
await ensureHighlightedPackageBadge(ctx, packageId, userId, now);
|
||||
seeded.push(spec.name);
|
||||
}
|
||||
|
||||
return { ok: true, seeded, skipped };
|
||||
},
|
||||
});
|
||||
|
||||
export const seedCliRoleHelpFixtures = rawInternalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
@@ -1135,11 +1516,7 @@ async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixture
|
||||
return created;
|
||||
}
|
||||
|
||||
async function replaceRoleHelpFixtureToken(
|
||||
ctx: MutationCtx,
|
||||
userId: Id<"users">,
|
||||
now: number,
|
||||
) {
|
||||
async function replaceRoleHelpFixtureToken(ctx: MutationCtx, userId: Id<"users">, now: number) {
|
||||
const existingTokens = await ctx.db
|
||||
.query("apiTokens")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
@@ -1177,12 +1554,15 @@ export const seedSkillMutation = internalMutation({
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.unique();
|
||||
|
||||
if (existing && !args.reset) {
|
||||
await ensureHighlightedSkillBadge(ctx, existing._id, userId, now);
|
||||
return { ok: true, skipped: true, skillId: existing._id };
|
||||
}
|
||||
|
||||
@@ -1204,9 +1584,6 @@ export const seedSkillMutation = internalMutation({
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: args.slug,
|
||||
displayName: args.displayName,
|
||||
@@ -1216,7 +1593,7 @@ export const seedSkillMutation = internalMutation({
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { redactionApproved: undefined },
|
||||
badges: { highlighted: { byUserId: userId, at: now }, redactionApproved: undefined },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
@@ -1232,6 +1609,7 @@ export const seedSkillMutation = internalMutation({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ensureHighlightedSkillBadge(ctx, skillId, userId, now);
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version: args.version,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import {
|
||||
fetchGitHubRepositoryIdentity,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
@@ -104,6 +104,7 @@ type PackageListQueryArgs = {
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -458,6 +459,7 @@ async function searchPackageCatalogByListing(
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -482,6 +484,7 @@ async function searchPackageCatalogByListing(
|
||||
family: args.family,
|
||||
channel: args.channel,
|
||||
isOfficial: args.isOfficial,
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
executesCode: args.executesCode,
|
||||
capabilityTag: args.capabilityTag,
|
||||
viewerUserId: args.viewerUserId,
|
||||
@@ -650,6 +653,11 @@ async function listPackages(
|
||||
const channelRaw = url.searchParams.get("channel")?.trim();
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const effectiveFamily =
|
||||
family ??
|
||||
@@ -674,6 +682,7 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
@@ -701,6 +710,7 @@ async function listPackages(
|
||||
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -720,6 +730,7 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
@@ -783,6 +794,7 @@ async function listPackages(
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -850,6 +862,7 @@ async function listPackages(
|
||||
family: effectiveFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1279,6 +1292,11 @@ async function searchPackages(
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
@@ -1305,6 +1323,7 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
},
|
||||
@@ -1319,6 +1338,7 @@ async function searchPackages(
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1343,6 +1363,7 @@ async function searchPackages(
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1355,6 +1376,7 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1364,6 +1386,7 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
|
||||
@@ -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
@@ -547,6 +547,16 @@ const skillBadges = defineTable({
|
||||
.index("by_skill_kind", ["skillId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const packageBadges = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
kind: v.union(v.literal("highlighted")),
|
||||
byUserId: v.id("users"),
|
||||
at: v.number(),
|
||||
})
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_package_kind", ["packageId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const soulVersionFingerprints = defineTable({
|
||||
soulId: v.id("souls"),
|
||||
versionId: v.id("soulVersions"),
|
||||
@@ -1219,12 +1229,7 @@ const rescanRequests = defineTable({
|
||||
.index("by_skill_version", ["targetKind", "skillVersionId", "createdAt"])
|
||||
.index("by_skill_version_status", ["targetKind", "skillVersionId", "status", "createdAt"])
|
||||
.index("by_package_release", ["targetKind", "packageReleaseId", "createdAt"])
|
||||
.index("by_package_release_status", [
|
||||
"targetKind",
|
||||
"packageReleaseId",
|
||||
"status",
|
||||
"createdAt",
|
||||
])
|
||||
.index("by_package_release_status", ["targetKind", "packageReleaseId", "status", "createdAt"])
|
||||
.index("by_requester", ["requestedByUserId", "createdAt"]);
|
||||
|
||||
const apiTokens = defineTable({
|
||||
@@ -1362,6 +1367,7 @@ export default defineSchema({
|
||||
packageReleases,
|
||||
packageTrustedPublishers,
|
||||
packagePublishTokens,
|
||||
packageBadges,
|
||||
packageSearchDigest,
|
||||
packageCapabilitySearchDigest,
|
||||
souls,
|
||||
|
||||
@@ -3055,6 +3055,7 @@ function skillCatalogMatchesFilters(
|
||||
args: {
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
},
|
||||
@@ -3065,6 +3066,7 @@ function skillCatalogMatchesFilters(
|
||||
const isOfficial = isSkillCatalogOfficial(digest);
|
||||
const channel = getSkillCatalogChannel(digest);
|
||||
if (typeof args.isOfficial === "boolean" && isOfficial !== args.isOfficial) return false;
|
||||
if (args.highlightedOnly && !isSkillHighlighted(digest)) return false;
|
||||
if (args.channel && channel !== args.channel) return false;
|
||||
if (args.capabilityTag && !(digest.capabilityTags ?? []).includes(args.capabilityTag))
|
||||
return false;
|
||||
@@ -3120,6 +3122,7 @@ export const listPackageCatalogPage = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -3211,6 +3214,7 @@ export const searchPackageCatalogPublic = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
|
||||
+1
-2
@@ -394,12 +394,11 @@ export const list = query({
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 40, 1, 100);
|
||||
const result = await queryUsersForPublicList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
const fetchFeaturedPluginsMock = vi.fn();
|
||||
const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
|
||||
@@ -57,6 +58,10 @@ vi.mock("../lib/packageApi", () => ({
|
||||
isRateLimitedPackageApiError: (error: unknown) => isRateLimitedPackageApiErrorMock(error),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/featuredCatalog", () => ({
|
||||
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/plugins/index")).Route as unknown as {
|
||||
__config: {
|
||||
@@ -70,6 +75,7 @@ async function loadRoute() {
|
||||
describe("plugins route", () => {
|
||||
beforeEach(() => {
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
fetchFeaturedPluginsMock.mockReset();
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
@@ -92,6 +98,7 @@ describe("plugins route", () => {
|
||||
family: undefined,
|
||||
q: "demo",
|
||||
cursor: undefined,
|
||||
featured: undefined,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
});
|
||||
@@ -211,12 +218,29 @@ describe("plugins route", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("selects featured from the sort group", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Featured" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.search({ family: "code-plugin", cursor: "cursor:current" })).toEqual({
|
||||
family: undefined,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a retryable empty state when the catalog is rate limited", async () => {
|
||||
fetchPluginCatalogMock.mockRejectedValue({ status: 429, retryAfterSeconds: 22 });
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
@@ -237,9 +261,7 @@ describe("plugins route", () => {
|
||||
it("flags API errors for filtered catalog requests", async () => {
|
||||
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
@@ -51,10 +50,10 @@ describe("search route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the users type filter", () => {
|
||||
it("ignores the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => ({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
userCount: 0,
|
||||
isSearching: false,
|
||||
}),
|
||||
}));
|
||||
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
|
||||
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
@@ -64,7 +59,7 @@ describe("search route", () => {
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const rendered = render(<Component />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
|
||||
expect(input.value).toBe("first");
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft" } });
|
||||
@@ -74,7 +69,16 @@ describe("search route", () => {
|
||||
rendered.rerender(<Component />);
|
||||
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
|
||||
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
|
||||
).toBe("second");
|
||||
});
|
||||
|
||||
it("does not render a public users search tab", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { fetchPluginCatalog } from "./packageApi";
|
||||
|
||||
export async function fetchFeaturedPlugins(limit: number = 50) {
|
||||
const result = await fetchPluginCatalog({ featured: true, limit });
|
||||
return result.items;
|
||||
}
|
||||
+18
-3
@@ -255,6 +255,7 @@ export async function fetchPackages(params: {
|
||||
cursor?: string;
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
limit?: number;
|
||||
@@ -267,6 +268,7 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -287,6 +289,7 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -299,6 +302,7 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor?: string;
|
||||
family?: PluginFamily;
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
@@ -308,6 +312,7 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor: params.cursor,
|
||||
family: params.family,
|
||||
isOfficial: params.isOfficial,
|
||||
featured: params.featured,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
@@ -332,6 +337,7 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -339,7 +345,9 @@ export async function fetchPluginCatalog(params: {
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
return {
|
||||
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
|
||||
items: (response?.results ?? [])
|
||||
.map((entry) => entry?.package)
|
||||
.filter(Boolean) as PackageListItem[],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
@@ -350,6 +358,7 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -370,7 +379,10 @@ export async function fetchPackageDetail(name: string): Promise<PackageDetailRes
|
||||
return (await response.json()) as PackageDetailResponse;
|
||||
}
|
||||
|
||||
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
|
||||
export async function fetchPackageVersion(
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<PackageVersionDetail | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
|
||||
@@ -382,7 +394,10 @@ export async function fetchPackageVersion(name: string, version: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
|
||||
export async function fetchPackageReadme(
|
||||
name: string,
|
||||
version?: string | null,
|
||||
): Promise<string | null> {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
|
||||
+46
-47
@@ -1,9 +1,9 @@
|
||||
import { useAction } from "convex/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
|
||||
import type { PublicUser } from "./publicUser";
|
||||
|
||||
export type UnifiedSearchType = "all" | "skills" | "plugins";
|
||||
|
||||
export type UnifiedSkillResult = {
|
||||
type: "skill";
|
||||
@@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
|
||||
plugin: PackageListItem;
|
||||
};
|
||||
|
||||
export type UnifiedUserResult = {
|
||||
type: "user";
|
||||
user: PublicUser;
|
||||
};
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
|
||||
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
|
||||
type UnifiedSearchOptions = {
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
limits?: {
|
||||
skills?: number;
|
||||
plugins?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function useUnifiedSearch(
|
||||
query: string,
|
||||
activeType: "all" | "skills" | "plugins" | "users",
|
||||
activeType: UnifiedSearchType,
|
||||
options: UnifiedSearchOptions = {},
|
||||
) {
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
const [results, setResults] = useState<UnifiedResult[]>([]);
|
||||
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
|
||||
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
|
||||
const [skillCount, setSkillCount] = useState(0);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
const debounceMs = options.debounceMs ?? 300;
|
||||
const enabled = options.enabled ?? true;
|
||||
const skillLimit = options.limits?.skills ?? 25;
|
||||
const pluginLimit = options.limits?.plugins ?? 25;
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
if (!enabled || !trimmed) {
|
||||
requestRef.current += 1;
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
}
|
||||
@@ -65,40 +76,34 @@ export function useUnifiedSearch(
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const promises: [
|
||||
Promise<unknown> | null,
|
||||
Promise<{ items: PackageListItem[] }> | null,
|
||||
Promise<{ items: PublicUser[] }> | null,
|
||||
] = [null, null, null];
|
||||
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
|
||||
[null, null];
|
||||
|
||||
if (activeType === "all" || activeType === "skills") {
|
||||
promises[0] = searchSkills({
|
||||
query: trimmed,
|
||||
limit: 25,
|
||||
limit: skillLimit,
|
||||
nonSuspiciousOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "users") {
|
||||
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
promises.map((p) => p ?? Promise.resolve(null)),
|
||||
);
|
||||
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
|
||||
|
||||
if (requestId !== requestRef.current) return;
|
||||
|
||||
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
|
||||
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
|
||||
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
|
||||
|
||||
const skillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
|
||||
const nextSkillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
score: number;
|
||||
}>) ?? []
|
||||
).map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
@@ -106,32 +111,25 @@ export function useUnifiedSearch(
|
||||
score: entry.score,
|
||||
}));
|
||||
|
||||
const pluginResults: UnifiedPluginResult[] = (
|
||||
const nextPluginResults: UnifiedPluginResult[] = (
|
||||
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
|
||||
).map((item) => ({
|
||||
type: "plugin" as const,
|
||||
plugin: item,
|
||||
}));
|
||||
|
||||
setSkillCount(skillResults.length);
|
||||
setPluginCount(pluginResults.length);
|
||||
const userResults: UnifiedUserResult[] = (
|
||||
(usersRaw as { items: PublicUser[] })?.items ?? []
|
||||
).map((user) => ({
|
||||
type: "user" as const,
|
||||
user,
|
||||
}));
|
||||
setUserCount(userResults.length);
|
||||
setSkillCount(nextSkillResults.length);
|
||||
setPluginCount(nextPluginResults.length);
|
||||
setSkillResults(nextSkillResults);
|
||||
setPluginResults(nextPluginResults);
|
||||
|
||||
const merged: UnifiedResult[] = [];
|
||||
if (activeType === "all") {
|
||||
merged.push(...skillResults, ...pluginResults, ...userResults);
|
||||
merged.push(...nextSkillResults, ...nextPluginResults);
|
||||
} else if (activeType === "skills") {
|
||||
merged.push(...skillResults);
|
||||
} else if (activeType === "plugins") {
|
||||
merged.push(...pluginResults);
|
||||
merged.push(...nextSkillResults);
|
||||
} else {
|
||||
merged.push(...userResults);
|
||||
merged.push(...nextPluginResults);
|
||||
}
|
||||
|
||||
setResults(merged);
|
||||
@@ -139,9 +137,10 @@ export function useUnifiedSearch(
|
||||
console.error("Unified search failed:", error);
|
||||
if (requestId === requestRef.current) {
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestRef.current) {
|
||||
@@ -149,10 +148,10 @@ export function useUnifiedSearch(
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}, debounceMs);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, activeType, searchSkills]);
|
||||
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
|
||||
|
||||
return { results, skillCount, pluginCount, userCount, isSearching };
|
||||
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
|
||||
}
|
||||
|
||||
+63
-364
@@ -1,6 +1,5 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -12,10 +11,13 @@ import {
|
||||
Star,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchFeaturedPlugins } from "../lib/featuredCatalog";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
import type { PublicSkill, PublicSoul, PublicUser } from "../lib/publicUser";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
|
||||
@@ -28,13 +30,6 @@ function Home() {
|
||||
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
|
||||
}
|
||||
|
||||
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
|
||||
const SLOT_WORDS = [
|
||||
"Equip", "Install", "Unleash", "Ship", "Build",
|
||||
"Create", "Deploy", "Launch", "Hack", "Scale",
|
||||
"Forge", "Craft", "Wield",
|
||||
];
|
||||
|
||||
function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
@@ -44,7 +39,7 @@ function SkillsHome() {
|
||||
};
|
||||
|
||||
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
|
||||
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
|
||||
const [featuredPlugins, setFeaturedPlugins] = useState<PackageListItem[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -56,15 +51,9 @@ function SkillsHome() {
|
||||
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
|
||||
})
|
||||
.catch(() => {});
|
||||
convexHttp
|
||||
.query(api.skills.listPublicPageV4, {
|
||||
numItems: 12,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
|
||||
fetchFeaturedPlugins(6)
|
||||
.then((items) => {
|
||||
if (!cancelled) setFeaturedPlugins(items);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -104,262 +93,8 @@ function SkillsHome() {
|
||||
// Build carousel cards from highlighted data
|
||||
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
|
||||
|
||||
// ═══ SLOT MACHINE EASTER EGG ═══
|
||||
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
|
||||
const clickTimesRef = useRef<number[]>([]);
|
||||
const [slotState, setSlotState] = useState<
|
||||
| null
|
||||
| { phase: "spinning" }
|
||||
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
|
||||
>(null);
|
||||
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
|
||||
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
|
||||
const confettiRef = useRef<HTMLCanvasElement>(null);
|
||||
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cooldownUntilRef = useRef<number>(0);
|
||||
|
||||
// Clean up timers/intervals if the component unmounts mid-spin
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerSlots = useCallback(() => {
|
||||
// Clean up any previous timers
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
slotTimersRef.current = [];
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
|
||||
setSlotState({ phase: "spinning" });
|
||||
setStoppedReels(new Set());
|
||||
|
||||
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
|
||||
let r0: number, r1: number, r2: number;
|
||||
const isJackpot = Math.random() < 1 / 25;
|
||||
if (isJackpot) {
|
||||
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
|
||||
const isHack = Math.random() < 0.25;
|
||||
if (isHack) {
|
||||
r0 = HACK_INDEX;
|
||||
} else {
|
||||
// Pick any word except Hack
|
||||
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
|
||||
if (idx >= HACK_INDEX) idx++;
|
||||
r0 = idx;
|
||||
}
|
||||
r1 = r0;
|
||||
r2 = r0;
|
||||
} else {
|
||||
// Normal spin — re-roll if accidental triple match
|
||||
do {
|
||||
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
} while (r0 === r1 && r1 === r2);
|
||||
}
|
||||
const results: [number, number, number] = [r0, r1, r2];
|
||||
const landed = new Set<number>();
|
||||
|
||||
// Animate fast offset cycling — only cycle reels that haven't landed
|
||||
let frame = 0;
|
||||
const spinInterval = setInterval(() => {
|
||||
frame++;
|
||||
setSlotReelOffsets((prev) => [
|
||||
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
|
||||
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
|
||||
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
|
||||
]);
|
||||
}, 60);
|
||||
spinIntervalRef.current = spinInterval;
|
||||
|
||||
// Stop reels sequentially with a satisfying stagger
|
||||
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
|
||||
const t = setTimeout(() => {
|
||||
landed.add(reelIdx);
|
||||
setStoppedReels((prev) => new Set(prev).add(reelIdx));
|
||||
setSlotReelOffsets((prev) => {
|
||||
const next = [...prev] as [number, number, number];
|
||||
next[reelIdx] = results[reelIdx];
|
||||
return next;
|
||||
});
|
||||
}, delay);
|
||||
slotTimersRef.current.push(t);
|
||||
};
|
||||
|
||||
stopReel(0, 1200);
|
||||
stopReel(1, 1800);
|
||||
|
||||
const tFinal = setTimeout(() => {
|
||||
clearInterval(spinInterval);
|
||||
spinIntervalRef.current = null;
|
||||
landed.add(2);
|
||||
setStoppedReels(new Set([0, 1, 2]));
|
||||
setSlotReelOffsets(results);
|
||||
const won = r0 === r1 && r1 === r2;
|
||||
const isHackJackpot = won && r0 === HACK_INDEX;
|
||||
setSlotState({ phase: "stopped", results, won, isHackJackpot });
|
||||
if (won) {
|
||||
fireConfetti(isHackJackpot);
|
||||
}
|
||||
// Cooldown: 18s after win, 3s after loss
|
||||
const displayTime = won ? 10000 : 2400;
|
||||
const cooldownTime = won ? 18000 : 3000;
|
||||
cooldownUntilRef.current = Date.now() + cooldownTime;
|
||||
const tReset = setTimeout(() => {
|
||||
setSlotState(null);
|
||||
setStoppedReels(new Set());
|
||||
}, displayTime);
|
||||
slotTimersRef.current.push(tReset);
|
||||
}, 2400);
|
||||
slotTimersRef.current.push(tFinal);
|
||||
}, []);
|
||||
|
||||
const handleLabelClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
// Respect cooldown period
|
||||
if (now < cooldownUntilRef.current) return;
|
||||
clickTimesRef.current.push(now);
|
||||
// Keep only last 3 clicks
|
||||
if (clickTimesRef.current.length > 3) {
|
||||
clickTimesRef.current = clickTimesRef.current.slice(-3);
|
||||
}
|
||||
if (clickTimesRef.current.length === 3) {
|
||||
const first = clickTimesRef.current[0];
|
||||
const last = clickTimesRef.current[2];
|
||||
if (last - first < 800 && !slotState) {
|
||||
clickTimesRef.current = [];
|
||||
triggerSlots();
|
||||
}
|
||||
}
|
||||
}, [slotState, triggerSlots]);
|
||||
|
||||
const fireConfetti = (isHackJackpot: boolean) => {
|
||||
const canvas = confettiRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
canvas.style.display = "block";
|
||||
|
||||
const STANDARD_COLORS = [
|
||||
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
|
||||
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
|
||||
];
|
||||
const OCEAN_COLORS = [
|
||||
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
|
||||
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
|
||||
"#d4453a", "#ff6b6b",
|
||||
];
|
||||
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
|
||||
|
||||
type Particle = {
|
||||
x: number; y: number; vx: number; vy: number;
|
||||
w: number; h: number; color: string; rot: number; vr: number;
|
||||
life: number; shape: "rect" | "bubble" | "claw";
|
||||
};
|
||||
const particles: Particle[] = [];
|
||||
|
||||
const count = isHackJackpot ? 200 : 150;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isBubble = isHackJackpot && Math.random() < 0.35;
|
||||
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
|
||||
particles.push({
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
|
||||
y: canvas.height * 0.35,
|
||||
vx: (Math.random() - 0.5) * 18,
|
||||
vy: isHackJackpot
|
||||
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
|
||||
: -Math.random() * 16 - 4,
|
||||
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
|
||||
h: isBubble ? 0 : Math.random() * 6 + 3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
life: isHackJackpot ? 1.3 : 1,
|
||||
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
|
||||
});
|
||||
}
|
||||
|
||||
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
|
||||
// Simple lobster claw shape
|
||||
context.beginPath();
|
||||
context.moveTo(0, size * 0.5);
|
||||
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
|
||||
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
|
||||
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
|
||||
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let alive = false;
|
||||
for (const p of particles) {
|
||||
if (p.life <= 0) continue;
|
||||
alive = true;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
|
||||
p.vx *= 0.99;
|
||||
p.rot += p.vr;
|
||||
p.life -= isHackJackpot ? 0.005 : 0.008;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === "bubble") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = p.color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha *= 0.7;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha *= 0.15;
|
||||
ctx.fill();
|
||||
} else if (p.shape === "claw") {
|
||||
drawClaw(ctx, p.w);
|
||||
} else {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
if (alive) {
|
||||
requestAnimationFrame(draw);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.style.display = "none";
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
|
||||
const offset = slotReelOffsets[reelIdx];
|
||||
const word = SLOT_WORDS[offset];
|
||||
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
|
||||
return (
|
||||
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
|
||||
<span className="home-v2-slot-word">{word}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="home-v2-main">
|
||||
{/* Confetti canvas for slot machine wins */}
|
||||
<canvas
|
||||
ref={confettiRef}
|
||||
className="home-v2-confetti"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{/* ═══ HERO ═══ */}
|
||||
<section className="home-v2-hero">
|
||||
<div className="home-v2-hero-bg">
|
||||
@@ -370,81 +105,41 @@ function SkillsHome() {
|
||||
<div className="home-v2-ring home-v2-ring-3" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
|
||||
onClick={handleLabelClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
|
||||
>
|
||||
BUILT BY THE COMMUNITY.
|
||||
</div>
|
||||
|
||||
{slotState ? (
|
||||
<h1 className={`home-v2-headline home-v2-headline-slots${
|
||||
slotState.phase === "stopped" && slotState.won
|
||||
? slotState.isHackJackpot
|
||||
? " home-v2-headline-jackpot home-v2-headline-hack"
|
||||
: " home-v2-headline-jackpot"
|
||||
: ""
|
||||
}`}>
|
||||
{slotState.phase === "stopped" && slotState.isHackJackpot && (
|
||||
<img
|
||||
src="/clawd-mark.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="home-v2-hack-lobster"
|
||||
/>
|
||||
)}
|
||||
<span className="home-v2-headline-inner">
|
||||
{renderSlotReel(0)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(1)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(2)}
|
||||
</span>
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
</span>
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="home-v2-search-container">
|
||||
<form className="home-v2-search-bar" onSubmit={handleSearch}>
|
||||
<Search className="home-v2-search-icon" size={20} />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="What are you looking for?"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<kbd>/</kbd>
|
||||
<button type="submit" className="home-v2-search-go">
|
||||
<span className="home-v2-search-go-label">Search</span>{" "}
|
||||
<ArrowRight size={16} />
|
||||
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="home-v2-suggestions">
|
||||
<span className="home-v2-suggestions-label">Try</span>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-suggestion"
|
||||
@@ -480,8 +175,24 @@ function SkillsHome() {
|
||||
{carouselCards.length > 0 && (
|
||||
<section className="home-v2-carousel-section">
|
||||
<div className="home-v2-carousel-header">
|
||||
<h2>Featured</h2>
|
||||
<h2>Featured skills</h2>
|
||||
<div className="home-v2-carousel-controls">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
featured: true,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
<button type="button" className="home-v2-carousel-btn" aria-label="Previous">
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
@@ -516,12 +227,10 @@ function SkillsHome() {
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -554,12 +263,10 @@ function SkillsHome() {
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -650,21 +357,20 @@ function SkillsHome() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ TRENDING ═══ */}
|
||||
{popular.length > 0 && (
|
||||
{/* ═══ FEATURED PLUGINS ═══ */}
|
||||
{featuredPlugins.length > 0 && (
|
||||
<section className="home-v2-trending-section">
|
||||
<div className="home-v2-section-header">
|
||||
<h2>Trending Now</h2>
|
||||
<h2>Featured plugins</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
to="/plugins"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
featured: true,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
@@ -672,33 +378,26 @@ function SkillsHome() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-v2-trending-grid">
|
||||
{popular.slice(0, 6).map((entry) => (
|
||||
{featuredPlugins.slice(0, 6).map((plugin) => (
|
||||
<Link
|
||||
key={entry.skill._id}
|
||||
to={skillLink(entry)}
|
||||
key={plugin.name}
|
||||
to="/plugins/$name"
|
||||
params={{ name: plugin.name }}
|
||||
className="home-v2-trend-card"
|
||||
>
|
||||
<div className="home-v2-trend-head">
|
||||
<div className="home-v2-trend-title">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
</div>
|
||||
<div className="home-v2-trend-title">{plugin.displayName || plugin.name}</div>
|
||||
<div className="home-v2-trend-creator">
|
||||
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
|
||||
{plugin.ownerHandle ? `by @${plugin.ownerHandle}` : "community plugin"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-v2-trend-desc">
|
||||
{entry.skill.summary || "Agent-ready skill pack."}
|
||||
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
|
||||
</div>
|
||||
<div className="home-v2-trend-bottom">
|
||||
<div className="home-v2-trend-signals">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
{plugin.isOfficial ? <span>Verified</span> : null}
|
||||
{plugin.latestVersion ? <span>v{plugin.latestVersion}</span> : null}
|
||||
</div>
|
||||
<span className="home-v2-trend-install">
|
||||
<Download size={13} /> Install
|
||||
|
||||
+151
-35
@@ -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
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ type PluginSearchState = {
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
featured?: boolean;
|
||||
verified?: boolean;
|
||||
executesCode?: boolean;
|
||||
};
|
||||
@@ -43,14 +44,16 @@ export const Route = createFileRoute("/plugins/")({
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
featured:
|
||||
search.featured === true || search.featured === "true" || search.featured === "1"
|
||||
? true
|
||||
: undefined,
|
||||
verified:
|
||||
search.verified === true || search.verified === "true" || search.verified === "1"
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
@@ -61,6 +64,7 @@ export const Route = createFileRoute("/plugins/")({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
featured: deps.featured,
|
||||
isOfficial: deps.verified,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
@@ -100,14 +104,14 @@ function PluginsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
|
||||
|
||||
|
||||
// Defensive handling for when loader data is unavailable (SSR errors, etc.)
|
||||
const items = loaderData?.items ?? [];
|
||||
const nextCursor = loaderData?.nextCursor ?? null;
|
||||
const rateLimited = loaderData?.rateLimited ?? false;
|
||||
const retryAfterSeconds = loaderData?.retryAfterSeconds ?? null;
|
||||
const apiError = loaderData?.apiError ?? !loaderData;
|
||||
|
||||
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
@@ -136,12 +140,24 @@ function PluginsIndex() {
|
||||
};
|
||||
|
||||
const handleFamilySort = (value: string) => {
|
||||
const family =
|
||||
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
if (value === "featured") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
family: undefined,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const family = value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
featured: undefined,
|
||||
family: family as "code-plugin" | "bundle-plugin" | undefined,
|
||||
}),
|
||||
});
|
||||
@@ -200,11 +216,12 @@ function PluginsIndex() {
|
||||
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<BrowseSidebar
|
||||
sortOptions={[
|
||||
{ value: "featured", label: "Featured" },
|
||||
{ value: "all", label: "All types" },
|
||||
{ value: "code-plugin", label: "Code plugins" },
|
||||
{ value: "bundle-plugin", label: "Bundle plugins" },
|
||||
]}
|
||||
activeSort={search.family ?? "all"}
|
||||
activeSort={search.featured ? "featured" : (search.family ?? "all")}
|
||||
onSortChange={handleFamilySort}
|
||||
filters={[
|
||||
{ key: "verified", label: "Verified only", active: search.verified ?? false },
|
||||
@@ -231,9 +248,7 @@ function PluginsIndex() {
|
||||
<div className="empty-state">
|
||||
<AlertTriangle size={20} aria-hidden="true" />
|
||||
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
|
||||
<p className="empty-state-body">
|
||||
Try again {formatRetryDelay(retryAfterSeconds)}.
|
||||
</p>
|
||||
<p className="empty-state-body">Try again {formatRetryDelay(retryAfterSeconds)}.</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
|
||||
+13
-43
@@ -3,28 +3,24 @@ import { Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PluginListItem } from "../components/PluginListItem";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { UserListItem } from "../components/UserListItem";
|
||||
import { Card } from "../components/ui/card";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedSearchType,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
type UnifiedUserResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
|
||||
type SearchState = {
|
||||
q?: string;
|
||||
type?: "all" | "skills" | "plugins" | "users";
|
||||
type?: UnifiedSearchType;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
validateSearch: (search): SearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
|
||||
type:
|
||||
search.type === "skills" || search.type === "plugins" || search.type === "users"
|
||||
? search.type
|
||||
: undefined,
|
||||
type: search.type === "skills" || search.type === "plugins" ? search.type : undefined,
|
||||
}),
|
||||
component: UnifiedSearchPage,
|
||||
});
|
||||
@@ -39,7 +35,7 @@ function UnifiedSearchPage() {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
|
||||
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
|
||||
search.q ?? "",
|
||||
activeType,
|
||||
);
|
||||
@@ -52,7 +48,7 @@ function UnifiedSearchPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const setType = (type: "all" | "skills" | "plugins" | "users") => {
|
||||
const setType = (type: UnifiedSearchType) => {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: search.q, type: type === "all" ? undefined : type },
|
||||
@@ -74,11 +70,11 @@ function UnifiedSearchPage() {
|
||||
|
||||
<form className="search-page-form" onSubmit={handleSearch}>
|
||||
<div className="browse-search-bar max-w-[560px] flex-1">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
type="text"
|
||||
placeholder="Search skills, plugins, users..."
|
||||
placeholder="Search skills and plugins..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
@@ -100,9 +96,7 @@ function UnifiedSearchPage() {
|
||||
onClick={() => setType("skills")}
|
||||
>
|
||||
Skills
|
||||
{skillCount > 0 ? (
|
||||
<span className="search-tab-count">{skillCount}</span>
|
||||
) : null}
|
||||
{skillCount > 0 ? <span className="search-tab-count">{skillCount}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
|
||||
@@ -110,17 +104,7 @@ function UnifiedSearchPage() {
|
||||
onClick={() => setType("plugins")}
|
||||
>
|
||||
Plugins
|
||||
{pluginCount > 0 ? (
|
||||
<span className="search-tab-count">{pluginCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("users")}
|
||||
>
|
||||
Users
|
||||
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
|
||||
{pluginCount > 0 ? <span className="search-tab-count">{pluginCount}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +114,7 @@ function UnifiedSearchPage() {
|
||||
</Card>
|
||||
) : !search.q ? (
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">
|
||||
Enter a search term to find skills, plugins, and users
|
||||
</p>
|
||||
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
|
||||
</Card>
|
||||
) : results.length === 0 ? (
|
||||
<Card className="text-center p-10">
|
||||
@@ -143,10 +125,8 @@ function UnifiedSearchPage() {
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : item.type === "plugin" ? (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
) : (
|
||||
<UserResultRow key={`user-${item.user._id}`} result={item} />
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -157,19 +137,9 @@ function UnifiedSearchPage() {
|
||||
|
||||
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
|
||||
const skill = result.skill as unknown as PublicSkill;
|
||||
return (
|
||||
<SkillListItem
|
||||
skill={skill}
|
||||
ownerHandle={result.ownerHandle}
|
||||
/>
|
||||
);
|
||||
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
|
||||
}
|
||||
|
||||
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
|
||||
return <PluginListItem item={result.plugin} />;
|
||||
}
|
||||
|
||||
function UserResultRow({ result }: { result: UnifiedUserResult }) {
|
||||
const user = result.user as PublicUser;
|
||||
return <UserListItem user={user} />;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SkillsSearchState = {
|
||||
sort?: SortKey;
|
||||
dir?: SortDir;
|
||||
highlighted?: boolean;
|
||||
featured?: boolean;
|
||||
nonSuspicious?: boolean;
|
||||
tag?: string;
|
||||
view?: SkillsView;
|
||||
@@ -57,7 +58,7 @@ export function useSkillsBrowseModel({
|
||||
const navigateTimer = useRef<number>(0);
|
||||
|
||||
const view: SkillsView = search.view ?? "list";
|
||||
const highlightedOnly = search.highlighted ?? false;
|
||||
const featuredOnly = search.featured ?? search.highlighted ?? false;
|
||||
const nonSuspiciousOnly = search.nonSuspicious ?? false;
|
||||
const capabilityTag = search.tag;
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
@@ -72,7 +73,7 @@ export function useSkillsBrowseModel({
|
||||
const listSort = toListSort(sort);
|
||||
const dir = parseDir(search.dir, sort);
|
||||
const searchKey = trimmedQuery
|
||||
? `${trimmedQuery}::${highlightedOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
|
||||
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
|
||||
: "";
|
||||
|
||||
// One-shot paginated fetches (no reactive subscription)
|
||||
@@ -89,7 +90,7 @@ export function useSkillsBrowseModel({
|
||||
numItems: pageSize,
|
||||
sort: listSort,
|
||||
dir,
|
||||
highlightedOnly,
|
||||
highlightedOnly: featuredOnly,
|
||||
nonSuspiciousOnly,
|
||||
capabilityTag,
|
||||
});
|
||||
@@ -105,7 +106,7 @@ export function useSkillsBrowseModel({
|
||||
setListStatus(cursor ? "idle" : "done");
|
||||
}
|
||||
},
|
||||
[capabilityTag, dir, highlightedOnly, listSort, nonSuspiciousOnly],
|
||||
[capabilityTag, dir, featuredOnly, listSort, nonSuspiciousOnly],
|
||||
);
|
||||
|
||||
// Reset and fetch first page when sort/dir/filters change
|
||||
@@ -155,7 +156,7 @@ export function useSkillsBrowseModel({
|
||||
try {
|
||||
const data = (await searchSkills({
|
||||
query: trimmedQuery,
|
||||
highlightedOnly,
|
||||
highlightedOnly: featuredOnly,
|
||||
nonSuspiciousOnly,
|
||||
capabilityTag,
|
||||
limit: searchLimit,
|
||||
@@ -174,7 +175,7 @@ export function useSkillsBrowseModel({
|
||||
}, [
|
||||
capabilityTag,
|
||||
hasQuery,
|
||||
highlightedOnly,
|
||||
featuredOnly,
|
||||
nonSuspiciousOnly,
|
||||
searchLimit,
|
||||
searchSkills,
|
||||
@@ -197,7 +198,8 @@ export function useSkillsBrowseModel({
|
||||
const sorted = useMemo(() => {
|
||||
if (isOtherCategory) {
|
||||
return baseItems.filter((entry) => {
|
||||
const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
|
||||
const text =
|
||||
`${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
|
||||
return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw));
|
||||
});
|
||||
}
|
||||
@@ -319,11 +321,12 @@ export function useSkillsBrowseModel({
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const onToggleHighlighted = useCallback(() => {
|
||||
const onToggleFeatured = useCallback(() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
highlighted: prev.highlighted ? undefined : true,
|
||||
featured: prev.featured || prev.highlighted ? undefined : true,
|
||||
highlighted: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
@@ -375,7 +378,7 @@ export function useSkillsBrowseModel({
|
||||
}, [navigate]);
|
||||
|
||||
const activeFilters: string[] = [];
|
||||
if (highlightedOnly) activeFilters.push("highlighted");
|
||||
if (featuredOnly) activeFilters.push("featured");
|
||||
if (nonSuspiciousOnly) activeFilters.push("non-suspicious");
|
||||
if (capabilityTag) activeFilters.push(SKILL_CAPABILITY_LABELS[capabilityTag] ?? capabilityTag);
|
||||
|
||||
@@ -399,7 +402,7 @@ export function useSkillsBrowseModel({
|
||||
canLoadMore,
|
||||
dir,
|
||||
hasQuery,
|
||||
highlightedOnly,
|
||||
featuredOnly,
|
||||
isLoadingMore,
|
||||
isLoadingSkills,
|
||||
loadMore,
|
||||
@@ -409,7 +412,7 @@ export function useSkillsBrowseModel({
|
||||
onQueryChange,
|
||||
onSortChange,
|
||||
onToggleDir,
|
||||
onToggleHighlighted,
|
||||
onToggleFeatured,
|
||||
onToggleNonSuspicious,
|
||||
onToggleView,
|
||||
query,
|
||||
|
||||
+60
-30
@@ -6,7 +6,7 @@ import { api } from "../../../convex/_generated/api";
|
||||
import { BrowseSidebar } from "../../components/BrowseSidebar";
|
||||
import { SKILL_CATEGORIES } from "../../lib/categories";
|
||||
import { formatCompactStat } from "../../lib/numberFormat";
|
||||
import { parseSort } from "./-params";
|
||||
import { parseDir, parseSort } from "./-params";
|
||||
import { SkillsResults } from "./-SkillsResults";
|
||||
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
|
||||
|
||||
@@ -29,6 +29,10 @@ export const Route = createFileRoute("/skills/")({
|
||||
search.highlighted === "1" || search.highlighted === "true" || search.highlighted === true
|
||||
? true
|
||||
: undefined,
|
||||
featured:
|
||||
search.featured === "1" || search.featured === "true" || search.featured === true
|
||||
? true
|
||||
: undefined,
|
||||
nonSuspicious:
|
||||
search.nonSuspicious === "1" ||
|
||||
search.nonSuspicious === "true" ||
|
||||
@@ -41,7 +45,9 @@ export const Route = createFileRoute("/skills/")({
|
||||
},
|
||||
beforeLoad: ({ search }) => {
|
||||
const hasQuery = Boolean(search.q?.trim());
|
||||
if (hasQuery || search.sort) return;
|
||||
if (hasQuery || search.sort || search.featured || search.highlighted || search.nonSuspicious) {
|
||||
return;
|
||||
}
|
||||
throw redirect({
|
||||
to: "/skills",
|
||||
search: {
|
||||
@@ -49,6 +55,7 @@ export const Route = createFileRoute("/skills/")({
|
||||
sort: "downloads",
|
||||
dir: search.dir || undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
featured: search.featured || undefined,
|
||||
nonSuspicious: search.nonSuspicious || undefined,
|
||||
view: search.view || undefined,
|
||||
focus: search.focus || undefined,
|
||||
@@ -64,8 +71,7 @@ export function SkillsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills);
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const totalSkillsText = typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const model = useSkillsBrowseModel({
|
||||
@@ -80,12 +86,50 @@ export function SkillsIndex() {
|
||||
|
||||
const handleFilterToggle = useCallback(
|
||||
(key: string) => {
|
||||
if (key === "highlighted") model.onToggleHighlighted();
|
||||
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
},
|
||||
[model.onToggleHighlighted, model.onToggleNonSuspicious],
|
||||
[model.onToggleNonSuspicious],
|
||||
);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "featured") {
|
||||
if (!model.featuredOnly) model.onToggleFeatured();
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.featuredOnly) {
|
||||
const nextSort = parseSort(value);
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: nextSort,
|
||||
dir: parseDir(prev.dir, nextSort),
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
model.onSortChange(value);
|
||||
},
|
||||
[model.featuredOnly, model.onSortChange, model.onToggleFeatured, navigate],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
model.onQueryChange("");
|
||||
if (model.featuredOnly) model.onToggleFeatured();
|
||||
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
|
||||
}, [
|
||||
model.featuredOnly,
|
||||
model.onQueryChange,
|
||||
model.onToggleFeatured,
|
||||
model.onToggleNonSuspicious,
|
||||
model.nonSuspiciousOnly,
|
||||
]);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(slug: string | undefined) => {
|
||||
if (slug) {
|
||||
@@ -103,9 +147,8 @@ export function SkillsIndex() {
|
||||
const activeCategory = useMemo(() => {
|
||||
if (!model.query) return undefined;
|
||||
return (
|
||||
SKILL_CATEGORIES.find((c) =>
|
||||
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
|
||||
)?.slug ?? undefined
|
||||
SKILL_CATEGORIES.find((c) => c.keywords.some((k) => k === model.query.trim().toLowerCase()))
|
||||
?.slug ?? undefined
|
||||
);
|
||||
}, [model.query]);
|
||||
|
||||
@@ -122,9 +165,7 @@ export function SkillsIndex() {
|
||||
</button>
|
||||
<h1 className="browse-title">
|
||||
Skills
|
||||
{totalSkillsText ? (
|
||||
<span className="browse-count">{totalSkillsText}</span>
|
||||
) : null}
|
||||
{totalSkillsText ? <span className="browse-count">{totalSkillsText}</span> : null}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="browse-page-search">
|
||||
@@ -142,11 +183,10 @@ export function SkillsIndex() {
|
||||
categories={SKILL_CATEGORIES}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
sortOptions={sortOptionsWithRelevance}
|
||||
activeSort={model.sort}
|
||||
onSortChange={model.onSortChange}
|
||||
sortOptions={[{ value: "featured", label: "Featured" }, ...sortOptionsWithRelevance]}
|
||||
activeSort={model.featuredOnly ? "featured" : model.sort}
|
||||
onSortChange={handleSortChange}
|
||||
filters={[
|
||||
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
|
||||
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
|
||||
]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
@@ -154,19 +194,9 @@ export function SkillsIndex() {
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{model.isLoadingSkills
|
||||
? "\u2014"
|
||||
: `${model.sorted.length} results`}
|
||||
{(model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly) ? (
|
||||
<button
|
||||
className="browse-clear-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
model.onQueryChange("");
|
||||
if (model.highlightedOnly) model.onToggleHighlighted();
|
||||
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
|
||||
}}
|
||||
>
|
||||
{model.isLoadingSkills ? "\u2014" : `${model.sorted.length} results`}
|
||||
{model.hasQuery || model.featuredOnly || model.nonSuspiciousOnly ? (
|
||||
<button className="browse-clear-btn" type="button" onClick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -64,14 +64,23 @@ function SoulsHoldingPage() {
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/users" search={{ q: undefined }}>
|
||||
Browse Users
|
||||
</Link>
|
||||
<Link to="/users">Browse Users</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { convexHttp } from "../../convex/client";
|
||||
import type { PublicUser } from "../../lib/publicUser";
|
||||
|
||||
type UserSearchState = {
|
||||
q?: string;
|
||||
};
|
||||
|
||||
type UsersLoaderResult = { items: PublicUser[]; total: number };
|
||||
|
||||
export const Route = createFileRoute("/users/")({
|
||||
validateSearch: (search): UserSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
}),
|
||||
component: UsersIndex,
|
||||
});
|
||||
|
||||
function UsersIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchUsers = useCallback(async (q?: string) => {
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await convexHttp.query(api.users.listPublic, {
|
||||
limit: 48,
|
||||
search: q,
|
||||
});
|
||||
setResult(data as UsersLoaderResult);
|
||||
} finally {
|
||||
@@ -41,9 +29,8 @@ function UsersIndex() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
void fetchUsers(search.q);
|
||||
}, [search.q, fetchUsers]);
|
||||
void fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const users = result?.items ?? [];
|
||||
|
||||
@@ -57,25 +44,6 @@ function UsersIndex() {
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
<form
|
||||
className="browse-page-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: {
|
||||
q: query.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
placeholder="Search users..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
@@ -90,8 +58,7 @@ function UsersIndex() {
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No users found</p>
|
||||
<p className="empty-state-body">Try a different handle or name.</p>
|
||||
<p className="empty-state-title">No users yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
|
||||
+97
-197
@@ -775,6 +775,11 @@ code {
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar-search-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navbar-search:focus-within {
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 2px var(--input-focus-ring);
|
||||
@@ -807,6 +812,98 @@ code {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-section + .navbar-search-typeahead-section {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-heading {
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row:hover,
|
||||
.navbar-search-typeahead-row.is-active {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row.is-footer {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-title,
|
||||
.navbar-search-typeahead-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-meta {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-status {
|
||||
padding: 12px;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.navbar-search-home {
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
@@ -8510,21 +8607,6 @@ code {
|
||||
}
|
||||
|
||||
/* Headline */
|
||||
.home-v2-hero-label {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--hv2-text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition:
|
||||
color 0.2s,
|
||||
text-shadow 0.3s;
|
||||
}
|
||||
.home-v2-headline {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700;
|
||||
@@ -8613,159 +8695,6 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ SLOT MACHINE EASTER EGG ═══ */
|
||||
.home-v2-hero-label:hover {
|
||||
color: var(--hv2-accent);
|
||||
}
|
||||
.home-v2-hero-label-active {
|
||||
color: var(--hv2-accent) !important;
|
||||
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
|
||||
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes home-v2-labelPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-headline-slots {
|
||||
min-height: 1.15em;
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-weight: 800;
|
||||
color: var(--hv2-accent);
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel.spinning .home-v2-slot-word {
|
||||
animation: home-v2-slotBlur 0.12s steps(1) infinite;
|
||||
}
|
||||
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
|
||||
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
@keyframes home-v2-slotBlur {
|
||||
0% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
filter: blur(1px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes home-v2-slotLand {
|
||||
0% {
|
||||
transform: translateY(-8px) scale(1.1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
60% {
|
||||
transform: translateY(2px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-headline-jackpot {
|
||||
animation: home-v2-jackpot 0.5s ease-out;
|
||||
}
|
||||
@keyframes home-v2-jackpot {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.home-v2-headline-jackpot .home-v2-slot-word {
|
||||
color: #ffd93d !important;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 217, 61, 0.6),
|
||||
0 0 40px rgba(255, 217, 61, 0.3);
|
||||
}
|
||||
|
||||
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
|
||||
.home-v2-headline-hack .home-v2-slot-word {
|
||||
color: #22d3ee !important;
|
||||
text-shadow:
|
||||
0 0 24px rgba(34, 211, 238, 0.6),
|
||||
0 0 48px rgba(6, 182, 212, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
.home-v2-headline-hack .home-v2-sep {
|
||||
border-color: #22d3ee;
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
|
||||
}
|
||||
.home-v2-hack-lobster {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5))
|
||||
drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
|
||||
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes home-v2-lobsterReveal {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.18;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0deg);
|
||||
opacity: 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-confetti {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-v2-sub {
|
||||
color: var(--hv2-text-tertiary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 36px;
|
||||
max-width: 580px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.home-v2-sub-clear {
|
||||
color: var(--hv2-text-secondary);
|
||||
max-width: 820px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.home-v2-motto {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8830,16 +8759,6 @@ code {
|
||||
.home-v2-search-bar input::placeholder {
|
||||
color: var(--hv2-text-tertiary);
|
||||
}
|
||||
.home-v2-search-bar kbd {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--hv2-border);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
color: var(--hv2-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-v2-search-go {
|
||||
background: var(--hv2-accent-fill);
|
||||
color: var(--hv2-accent);
|
||||
@@ -8877,12 +8796,6 @@ code {
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.home-v2-suggestions-label {
|
||||
color: var(--hv2-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.home-v2-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8918,11 +8831,6 @@ code {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.home-v2-suggestions-label {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.home-v2-suggestion {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
@@ -9714,11 +9622,6 @@ code {
|
||||
[data-theme-resolved="light"] .home-v2-search-bar input::placeholder {
|
||||
color: #9c8b7a;
|
||||
}
|
||||
[data-theme-resolved="light"] .home-v2-search-bar kbd {
|
||||
border-color: rgba(170, 125, 80, 0.2);
|
||||
color: #9c8b7a;
|
||||
}
|
||||
|
||||
/* Light — search button */
|
||||
[data-theme-resolved="light"] .home-v2-search-go {
|
||||
background: rgba(196, 58, 47, 0.06);
|
||||
@@ -9901,9 +9804,6 @@ code {
|
||||
.home-v2-search-go {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.home-v2-search-bar kbd {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.home-v2-c-icon {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user