Compare commits

...
Author SHA1 Message Date
momothemage ef8d53fa5f fix: drop unrelated conflict carryover from PR #1879 2026-05-07 10:42:51 +08:00
momothemage f6661a7a4c fix(publish): allow grandfathered slugs to update in insertVersion mutations 2026-04-29 12:13:40 +08:00
momothemage d2c2e7b509 style: re-apply oxfmt to packages.public.test.ts after upstream merge 2026-04-29 11:57:34 +08:00
momothemage 70f6a435e2 Merge remote-tracking branch 'upstream/main' into feature/fix_slug_limit
# Conflicts:
#	src/__tests__/header.test.tsx
2026-04-29 11:56:28 +08:00
momothemage 929c9de50e style: format files touched in CI format-check scope
CI computes the PR format-check scope using two-dot diff between the
pull request base SHA (captured at PR open time) and the head SHA, so
any file changed in upstream/main after the PR was opened but before
the PR was merged falls into scope and gets oxfmt --check'd. The three
src/ files below were introduced by upstream PR #1873 (skill upload)
without oxfmt formatting, so they fail CI format:check on this branch
even though they are not logically part of the slug validation fix.

Run oxfmt --write on them to satisfy CI. No behaviour change.

Affected files:
- src/__tests__/header.test.tsx
- src/components/Header.tsx
- src/routes/settings.tsx
2026-04-29 11:53:57 +08:00
momothemage ea67295794 fix(slug): keep read & update paths working for grandfathered slugs 2026-04-29 11:44:39 +08:00
momothemage f5a3cba962 fix(search): restore exact-slug lookup for legacy short slugs 2026-04-29 11:30:15 +08:00
momothemage b2a173ab75 style: apply oxfmt formatting to slug validation PR files 2026-04-29 11:19:21 +08:00
momothemage 2e74c9bad2 fix(slug): tighten skill/soul slug validation with length limits and reserved-word blocklist 2026-04-29 10:56:19 +08:00
19 changed files with 1283 additions and 746 deletions
+21 -7
View File
@@ -1,5 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import { ConvexError } from "convex/values";
import semver from "semver";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -34,6 +34,7 @@ import {
parseFrontmatter,
sanitizePath,
} from "./skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
import { generateSkillSummary } from "./skillSummary";
import { runStaticPublishScan } from "./staticPublishScan";
import type { WebhookSkillPayload } from "./webhooks";
@@ -90,12 +91,16 @@ export async function publishVersionForUser(
options: PublishOptions = {},
): Promise<PublishResult> {
const version = args.version.trim();
const slug = args.slug.trim().toLowerCase();
// Normalize first so we can look up the existing skill before deciding
// how strictly to validate. The reserved-word blocklist and length floor
// are only enforced for brand-new skills; owners of grandfathered slugs
// (reserved, <3 chars, or >48 chars) must still be able to publish new
// versions without being blocked by the write-path validator.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const displayName = args.displayName.trim();
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
}
@@ -104,10 +109,19 @@ export async function publishVersionForUser(
await requireGitHubAccountAge(ctx, userId);
}
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
slug: normalizedSlug,
})) as Doc<"skills"> | null;
const isNewSkill = !existingSkill;
// For new skills, enforce the full write-path rules (length, pattern,
// reserved-word blocklist). For existing skills the slug is already
// persisted and grandfathered — re-validating it would block legitimate
// version publishes on legacy rows.
if (isNewSkill) {
assertValidSkillSlug(normalizedSlug);
}
const slug = normalizedSlug;
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
assertValidSkillSlug,
isReservedSkillSlug,
isSearchableSkillSlugShape,
isValidSkillSlugShape,
normalizeSkillSlug,
normalizeSkillSlugOrNull,
SKILL_SLUG_CONSTRAINTS,
} from "./skillSlugValidator";
describe("normalizeSkillSlug", () => {
it("trims and lowercases", () => {
expect(normalizeSkillSlug(" Hello-World ")).toBe("hello-world");
});
it("returns empty string for nullish", () => {
expect(normalizeSkillSlug(undefined)).toBe("");
expect(normalizeSkillSlug(null)).toBe("");
});
});
describe("normalizeSkillSlugOrNull", () => {
it("returns null for empty input", () => {
expect(normalizeSkillSlugOrNull(" ")).toBeNull();
expect(normalizeSkillSlugOrNull(null)).toBeNull();
});
it("returns normalized slug for non-empty input", () => {
expect(normalizeSkillSlugOrNull(" MySkill ")).toBe("myskill");
});
});
describe("assertValidSkillSlug", () => {
it.each([
"abc",
"my-cool-skill",
"skill-123",
"a1b",
"123",
"abc-def-ghi",
"z".repeat(SKILL_SLUG_CONSTRAINTS.maxLength),
])("accepts valid slug %s", (slug) => {
expect(() => assertValidSkillSlug(slug)).not.toThrow();
expect(assertValidSkillSlug(slug)).toBe(slug.toLowerCase());
});
it("normalizes mixed case and whitespace before validating", () => {
expect(assertValidSkillSlug(" My-Cool-Skill ")).toBe("my-cool-skill");
});
it("silently lowercases uppercase input (legacy-compatible)", () => {
// Historically, write paths did `args.slug.trim().toLowerCase()` before
// validating. We preserve that behaviour: uppercase input is normalized,
// not rejected outright.
expect(assertValidSkillSlug("A-B-C")).toBe("a-b-c");
});
it.each([
["", "required"],
[" ", "required"],
["ab", "at least"],
["a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1), "at most"],
["-abc", "start and end"],
["abc-", "start and end"],
["a--b", "start and end"],
["a---b", "start and end"],
["a_b", "start and end"],
["a.b", "start and end"],
["a b", "start and end"],
["a/b", "start and end"],
])("rejects invalid slug %s", (slug, hint) => {
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
});
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages"])(
"rejects reserved slug %s",
(slug) => {
// Some short reserved entries (e.g. "u") are also blocked by the
// length rule; we only assert that a throw happens for every entry.
expect(() => assertValidSkillSlug(slug)).toThrow();
},
);
it("emits the reserved-specific error for long reserved slugs", () => {
expect(() => assertValidSkillSlug("openclaw")).toThrow(/reserved/i);
});
it("allows reserved slugs when allowReserved is set", () => {
expect(() => assertValidSkillSlug("admin", { allowReserved: true })).not.toThrow();
expect(assertValidSkillSlug("admin", { allowReserved: true })).toBe("admin");
});
});
describe("isValidSkillSlugShape", () => {
it("returns true for well-formed slugs", () => {
expect(isValidSkillSlugShape("abc")).toBe(true);
expect(isValidSkillSlugShape("my-skill-1")).toBe(true);
});
it("returns true for reserved slugs (shape only)", () => {
// The reserved-word blocklist is intentionally NOT consulted here so
// that legacy rows carrying reserved slugs remain lookup-able.
expect(isValidSkillSlugShape("admin")).toBe(true);
});
it("is case-insensitive (normalizes before checking)", () => {
// Matches legacy read-path behaviour: search queries like "My-Skill"
// should still resolve to the slug row.
expect(isValidSkillSlugShape("A-B")).toBe(true);
});
it("returns false for malformed slugs", () => {
expect(isValidSkillSlugShape("a")).toBe(false);
expect(isValidSkillSlugShape("ab")).toBe(false);
expect(isValidSkillSlugShape("a--b")).toBe(false);
expect(isValidSkillSlugShape("-abc")).toBe(false);
expect(isValidSkillSlugShape("abc-")).toBe(false);
expect(isValidSkillSlugShape("a_b")).toBe(false);
expect(isValidSkillSlugShape("")).toBe(false);
expect(isValidSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(false);
});
});
describe("isReservedSkillSlug", () => {
it("identifies reserved slugs case-insensitively", () => {
expect(isReservedSkillSlug("admin")).toBe(true);
expect(isReservedSkillSlug(" ADMIN ")).toBe(true);
expect(isReservedSkillSlug("openclaw")).toBe(true);
});
it("returns false for non-reserved slugs", () => {
expect(isReservedSkillSlug("my-skill")).toBe(false);
expect(isReservedSkillSlug("")).toBe(false);
expect(isReservedSkillSlug(null)).toBe(false);
});
});
describe("isSearchableSkillSlugShape", () => {
it("accepts slugs shorter than the write-path minimum (legacy rows)", () => {
// Single- and two-character slugs predate the min-length floor but may
// still exist in the skills table. Search must surface them via the
// exact-slug fast path.
expect(isSearchableSkillSlugShape("a")).toBe(true);
expect(isSearchableSkillSlugShape("ab")).toBe(true);
expect(isSearchableSkillSlugShape("a1")).toBe(true);
});
it("accepts regular well-formed slugs", () => {
expect(isSearchableSkillSlugShape("abc")).toBe(true);
expect(isSearchableSkillSlugShape("my-skill-1")).toBe(true);
});
it("accepts reserved slugs (read path ignores the blocklist)", () => {
// Legacy data may still carry reserved slugs; they must remain
// searchable even though the write path would now reject them.
expect(isSearchableSkillSlugShape("admin")).toBe(true);
expect(isSearchableSkillSlugShape("u")).toBe(true);
});
it("is case-insensitive", () => {
expect(isSearchableSkillSlugShape("A-B")).toBe(true);
expect(isSearchableSkillSlugShape(" My-Skill ")).toBe(true);
});
it("still rejects malformed shapes", () => {
expect(isSearchableSkillSlugShape("")).toBe(false);
expect(isSearchableSkillSlugShape(" ")).toBe(false);
expect(isSearchableSkillSlugShape("-abc")).toBe(false);
expect(isSearchableSkillSlugShape("abc-")).toBe(false);
expect(isSearchableSkillSlugShape("a--b")).toBe(false);
expect(isSearchableSkillSlugShape("a_b")).toBe(false);
expect(isSearchableSkillSlugShape("a b")).toBe(false);
expect(isSearchableSkillSlugShape("-")).toBe(false);
});
it("accepts slugs longer than the write-path upper bound (legacy rows)", () => {
// Legacy rows predate MAX_SLUG_LENGTH and may exceed 48 chars. The read
// path must still resolve them via the by_slug fast path; otherwise
// searchSkills falls back to scanning only the most recent digests and
// can miss older records entirely.
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength))).toBe(true);
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(true);
expect(isSearchableSkillSlugShape("a".repeat(200))).toBe(true);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { ConvexError } from "convex/values";
// Slug shape rules:
// - Lowercase letters, digits, and single hyphens only.
// - Must start and end with a letter or digit.
// - No consecutive hyphens ("--", "---", ...).
// - Length 3..48 (URL/SEO friendly, aligned with publisher handle).
//
// The pattern enforces first/last char class and forbids consecutive hyphens
// via a negative lookahead. Length bounds are checked separately so we can
// emit precise error messages.
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
const MIN_SLUG_LENGTH = 3;
const MAX_SLUG_LENGTH = 48;
// Reserved slugs. These are blocked because they would:
// 1. Clash semantically with top-level routes under src/routes/*.
// 2. Allow brand/role impersonation (e.g. "official", "clawhub").
// 3. Lock future route expansion (e.g. "api", "auth", "oauth").
//
// Keep this list in sync with:
// - src/routes/*.tsx top-level segments
// - brand names shipped in README.md
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
// Current top-level route segments under src/routes/.
"about",
"admin",
"cli",
"dashboard",
"import",
"management",
"orgs",
"packages",
"plugins",
"publish",
"publish-plugin",
"publish-skill",
"search",
"settings",
"skills",
"souls",
"stars",
"u",
"upload",
"users",
// Reserved for likely future additions.
"api",
"auth",
"oauth",
"callback",
"login",
"logout",
"signin",
"signout",
"signup",
"register",
"docs",
"doc",
"help",
"support",
"status",
"health",
"blog",
"news",
"pricing",
"terms",
"privacy",
"legal",
"contact",
"home",
"explore",
// Brand and project names.
"openclaw",
"clawhub",
"clawd",
"clawdbot",
"onlycrabs",
"soulhub",
// Generic identity / role words.
"me",
"self",
"system",
"root",
"owner",
"official",
"staff",
"team",
"mod",
"moderator",
// Reserved CRUD/action words that would make URLs ambiguous.
"new",
"edit",
"delete",
"create",
"update",
"remove",
"public",
"private",
"internal",
// Literals that would be confusing in URLs.
"null",
"undefined",
"true",
"false",
]);
export interface ValidateSlugOptions {
/**
* Bypass the reserved-word blocklist.
* Intended for admin migrations / internal seeding only.
*/
allowReserved?: boolean;
}
export const SKILL_SLUG_CONSTRAINTS = {
minLength: MIN_SLUG_LENGTH,
maxLength: MAX_SLUG_LENGTH,
pattern: SLUG_PATTERN,
reserved: RESERVED_SKILL_SLUGS,
} as const;
/**
* Lowercase and trim a slug. Does not throw.
*
* Safe to call on any read-path input (query by slug, redirect lookup, ...)
* without rejecting legacy data.
*/
export function normalizeSkillSlug(raw: string | undefined | null): string {
return (raw ?? "").trim().toLowerCase();
}
/**
* Variant that returns null when the input normalizes to an empty string.
* Useful at read-path call sites that want to short-circuit lookup.
*/
export function normalizeSkillSlugOrNull(raw: string | undefined | null): string | null {
const normalized = normalizeSkillSlug(raw);
return normalized.length ? normalized : null;
}
/**
* Check whether a string already matches the full slug shape rules.
* Returns true only when the value is a plausible slug (length, pattern).
*
* Note: this intentionally does NOT consult the reserved-word blocklist
* because legacy rows may still carry reserved slugs and we want to
* keep them readable. It DOES enforce the current min-length floor
* (MIN_SLUG_LENGTH) and is therefore only appropriate for call sites
* that treat a value as a "newly-shaped" slug. For read-only lookups
* (search, redirect) that must stay discoverable for pre-existing
* short slugs, use isSearchableSkillSlugShape instead.
*/
export function isValidSkillSlugShape(value: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(value);
if (normalized.length < MIN_SLUG_LENGTH || normalized.length > MAX_SLUG_LENGTH) {
return false;
}
return SLUG_PATTERN.test(normalized);
}
/**
* Lenient shape check used by read paths (search exact-slug optimization,
* redirect lookups, etc.).
*
* Unlike isValidSkillSlugShape, this predicate intentionally omits:
* - the min-length floor (legacy rows with 1- or 2-char slugs must stay
* retrievable via the by_slug fast path),
* - the max-length cap (rows persisted before MAX_SLUG_LENGTH was
* introduced may exceed 48 chars and must remain lookup-able; the
* indexed point lookup for a missing key is cheap, and upstream
* request-body limits bound the practical query length),
* - the reserved-word blocklist (grandfathered data must stay readable).
*
* Write paths MUST continue to use assertValidSkillSlug, which enforces
* the full validation surface (length floor + length cap + pattern +
* reserved blocklist).
*/
export function isSearchableSkillSlugShape(value: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(value);
if (normalized.length === 0) {
return false;
}
// Single-character legacy slug: a bare [a-z0-9] is searchable. The full
// SLUG_PATTERN requires >=2 chars (separate first/last classes), so we
// handle the single-char case explicitly before delegating to it.
if (normalized.length === 1) {
return /^[a-z0-9]$/.test(normalized);
}
return SLUG_PATTERN.test(normalized);
}
/**
* Returns a normalized slug or throws ConvexError describing the first
* violation encountered. Use this on every write path (publish/rename).
*/
export function assertValidSkillSlug(
rawSlug: string | undefined | null,
options: ValidateSlugOptions = {},
): string {
const normalized = normalizeSkillSlug(rawSlug);
if (!normalized) {
throw new ConvexError("Slug is required.");
}
if (normalized.length < MIN_SLUG_LENGTH) {
throw new ConvexError(`Slug must be at least ${MIN_SLUG_LENGTH} characters.`);
}
if (normalized.length > MAX_SLUG_LENGTH) {
throw new ConvexError(`Slug must be at most ${MAX_SLUG_LENGTH} characters.`);
}
if (!SLUG_PATTERN.test(normalized)) {
throw new ConvexError(
"Slug must start and end with a letter or digit, contain only lowercase letters, " +
"digits, and single hyphens, and not contain consecutive hyphens.",
);
}
if (!options.allowReserved && RESERVED_SKILL_SLUGS.has(normalized)) {
throw new ConvexError(`"${normalized}" is reserved and cannot be used as a slug.`);
}
return normalized;
}
/**
* Convenience predicate: is the slug on the reserved blocklist?
* Exposed so callers (e.g. admin tooling) can pre-check without a throw.
*/
export function isReservedSkillSlug(slug: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(slug);
return RESERVED_SKILL_SLUGS.has(normalized);
}
+20 -6
View File
@@ -1,5 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import { ConvexError } from "convex/values";
import semver from "semver";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -16,6 +16,7 @@ import {
parseFrontmatter,
sanitizePath,
} from "./skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
import { generateSoulChangelogForPublish } from "./soulChangelog";
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
@@ -84,18 +85,31 @@ export async function publishSoulVersionForUser(
args: PublishVersionArgs,
): Promise<PublishResult> {
const version = args.version.trim();
const slug = args.slug.trim().toLowerCase();
// Normalize first so we can look up the existing soul before deciding how
// strictly to validate. Owners of grandfathered slugs (reserved, <3 chars,
// or >48 chars) must still be able to publish new versions; the strict
// write-path rules only apply when creating a brand-new soul.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const displayName = args.displayName.trim();
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
}
await requireGitHubAccountAge(ctx, userId);
// Resolve existing soul before enforcing slug rules so grandfathered rows
// are not blocked. Full validation is only applied on the create path.
const existingSoul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
slug: normalizedSlug,
})) as Doc<"souls"> | null;
if (!existingSoul) {
assertValidSkillSlug(normalizedSlug);
}
const slug = normalizedSlug;
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
+12 -5
View File
@@ -12,6 +12,7 @@ import { matchesExactTokens, tokenize } from "./lib/searchText";
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
import { isSkillSuspicious } from "./lib/skillSafety";
import { digestToHydratableSkill, digestToOwnerInfo } from "./lib/skillSearchDigest";
import { isSearchableSkillSlugShape, normalizeSkillSlug } from "./lib/skillSlugValidator";
type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null };
@@ -123,7 +124,11 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
}
function isSlugLikeQuery(query: string) {
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
// Lenient shape check used by the read path: pattern + upper length cap only.
// The min-length floor and reserved-word blocklist are intentionally omitted
// so legacy rows (grandfathered short/reserved slugs) remain discoverable via
// the exact-slug fast path. Write paths still go through assertValidSkillSlug.
return isSearchableSkillSlugShape(query);
}
function matchesCapabilityTag(
@@ -183,8 +188,7 @@ export const searchSkills: ReturnType<typeof action> = action({
const results = await ctx.vectorSearch("skillEmbeddings", "by_embedding", {
vector,
limit: candidateLimit,
filter: (q) =>
q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
filter: (q) => q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
});
// Only hydrate embedding IDs we haven't seen yet (incremental).
@@ -371,8 +375,11 @@ export const lexicalFallbackSkills = internalQuery({
>();
// Exact slug match via the skills table (only one row, cheap).
const slugQuery = args.query.trim().toLowerCase();
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
// Use the lenient shape predicate so legacy rows with sub-min-length
// slugs stay discoverable; the caller in searchSkills already passes
// skipExactSlugLookup=true after running its own exact-slug lookup.
const slugQuery = normalizeSkillSlug(args.query);
if (!args.skipExactSlugLookup && isSearchableSkillSlugShape(slugQuery)) {
const exactSlugSkill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
+28 -14
View File
@@ -96,6 +96,7 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
import { readCanonicalStat } from "./lib/skillStats";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
@@ -551,7 +552,10 @@ function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
}
function normalizeSkillSlugKey(slug: string) {
return slug.trim().toLowerCase();
// Read-path normalization: lowercase + trim only. Intentionally lenient so
// that legacy rows (pre-validator) remain lookup-able. Write paths must
// use `normalizeSkillSlugForWrite` / `assertValidSkillSlug` instead.
return normalizeSkillSlug(slug);
}
type SkillOwnerRef =
@@ -565,11 +569,9 @@ type SkillOwnerRef =
| undefined;
function normalizeSkillSlugForWrite(slug: string) {
const normalized = normalizeSkillSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
return normalized;
// Write-path: full validation (length, pattern, reserved words,
// no consecutive hyphens). See `lib/skillSlugValidator.ts`.
return assertValidSkillSlug(slug);
}
async function getSkillSlugAliasBySlug(ctx: Pick<QueryCtx | MutationCtx, "db">, slug: string) {
@@ -5789,13 +5791,11 @@ async function renameOwnedSkillByActor(
}
const now = Date.now();
const sourceSlug = sourceSlugArg.trim().toLowerCase();
const newSlug = newSlugArg.trim().toLowerCase();
const sourceSlug = normalizeSkillSlug(sourceSlugArg);
if (!sourceSlug) throw new ConvexError("Current slug required");
if (!newSlug) throw new ConvexError("New slug required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(newSlug)) {
throw new ConvexError("Invalid slug. Use lowercase letters, numbers, and hyphens only.");
}
// Full write-path validation for the new slug: length, pattern,
// reserved-word blocklist, no consecutive hyphens.
const newSlug = assertValidSkillSlug(newSlugArg);
const resolved = await resolveSkillBySlugOrAlias(ctx, sourceSlug);
const skill = resolved.skill;
@@ -6530,7 +6530,16 @@ export const insertVersion = internalMutation({
},
handler: async (ctx, args) => {
const userId = args.userId;
const slug = normalizeSkillSlugForWrite(args.slug);
// Lenient normalization first so we can look up an existing skill row
// before deciding whether to enforce the strict write-path validator.
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
// pre-validator shapes) must remain able to publish new versions; the
// strict reserved/length/pattern rules only apply when creating a brand
// new skill. The caller (publishVersionForUser) performs the same split,
// but the mutation re-validates defensively because it can be invoked on
// its own (e.g. tests, internal schedulers).
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
const personalPublisher = await ensurePersonalPublisherForUser(ctx, user);
@@ -6548,9 +6557,14 @@ export const insertVersion = internalMutation({
let skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.unique();
// Only enforce the strict write-path rules when creating a new skill.
// For existing rows, keep the already-persisted (possibly grandfathered)
// slug as-is so legacy publishers are not locked out of version updates.
const slug = skill ? normalizedSlug : normalizeSkillSlugForWrite(args.slug);
if (!skill) {
const alias = await getSkillSlugAliasBySlug(ctx, slug);
if (alias) {
+21 -8
View File
@@ -7,6 +7,7 @@ import { assertModerator, requireUser, requireUserFromAction } from "./lib/acces
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
import { toPublicSoul, toPublicUser } from "./lib/public";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
import { generateSoulChangelogPreview } from "./lib/soulChangelog";
import { fetchText, type PublishResult, publishSoulVersionForUser } from "./lib/soulPublish";
@@ -70,15 +71,15 @@ function toPublicSoulVersion(
}
function normalizeSoulSlugKey(slug: string) {
return slug.trim().toLowerCase();
// Read-path normalization: lowercase + trim only. Intentionally lenient so
// that legacy rows (pre-validator) remain lookup-able.
return normalizeSkillSlug(slug);
}
function normalizeSoulSlugForWrite(slug: string) {
const normalized = normalizeSoulSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
return normalized;
// Write-path: full validation (length, pattern, reserved words,
// no consecutive hyphens). Souls share the rules with skills.
return assertValidSkillSlug(slug);
}
export const getBySlug = query({
@@ -497,17 +498,29 @@ export const insertVersion = internalMutation({
},
handler: async (ctx, args) => {
const userId = args.userId;
const slug = normalizeSoulSlugForWrite(args.slug);
// Lenient normalization first: we must look up the existing soul row
// before deciding whether to enforce the strict write-path validator.
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
// pre-validator shapes) must remain able to publish new versions; the
// strict rules only apply when creating a brand new soul. The caller
// (publishSoulVersionForUser) performs the same split, but the mutation
// re-validates defensively because it can be invoked on its own.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
const soulMatches = await ctx.db
.query("souls")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.order("desc")
.take(2);
let soul: Doc<"souls"> | null = soulMatches[0] ?? null;
// Only enforce the strict write-path rules when creating a new soul; for
// existing rows keep the already-persisted (possibly grandfathered) slug.
const slug = soul ? normalizedSlug : normalizeSoulSlugForWrite(args.slug);
if (soul && soul.ownerUserId !== userId) {
throw new ConvexError("Only the owner can publish soul updates");
}
+1 -2
View File
@@ -394,12 +394,11 @@ export const list = query({
});
export const listPublic = query({
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 40, 1, 100);
const result = await queryUsersForPublicList(ctx, {
limit,
search: args.search,
});
return {
items: result.items
+145 -3
View File
@@ -1,9 +1,8 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
type HeaderAuthStatus = {
isAuthenticated: boolean;
@@ -13,6 +12,52 @@ type HeaderAuthStatus = {
const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
const { useUnifiedSearchMock } = vi.hoisted(() => ({
useUnifiedSearchMock: vi.fn(),
}));
const defaultUnifiedSearchResult = {
results: [],
skillResults: [
{
type: "skill",
ownerHandle: "local",
score: 10,
skill: {
_id: "skills:weather",
slug: "weather",
displayName: "Weather Skill",
ownerUserId: "users:local",
stats: { downloads: 1, stars: 2 },
createdAt: 1,
updatedAt: 2,
},
},
],
pluginResults: [
{
type: "plugin",
plugin: {
name: "weather-plugin",
displayName: "Weather Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
summary: "Plugin weather tools.",
ownerHandle: "local",
createdAt: 1,
updatedAt: 2,
latestVersion: "1.0.0",
capabilityTags: [],
executesCode: true,
verificationTier: null,
},
},
],
skillCount: 1,
pluginCount: 1,
isSearching: false,
};
vi.mock("@tanstack/react-router", () => ({
Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => (
@@ -90,6 +135,10 @@ vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => useUnifiedSearchMock(),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@@ -105,6 +154,8 @@ vi.mock("../components/ui/toggle-group", () => ({
),
}));
import Header from "../components/Header";
describe("Header", () => {
beforeEach(() => {
authStatusMock.mockReturnValue({
@@ -113,6 +164,7 @@ describe("Header", () => {
me: null,
});
siteModeMock.mockReturnValue("souls");
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
});
it("hides Packages navigation in soul mode on mobile and desktop", () => {
@@ -136,7 +188,7 @@ describe("Header", () => {
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
expect(setModeMock).toHaveBeenCalledWith("dark");
@@ -148,6 +200,96 @@ describe("Header", () => {
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
it("shows grouped skills and plugins typeahead without users", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText("Skills")).toBeTruthy();
expect(screen.getByText("Weather Skill")).toBeTruthy();
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
expect(screen.getByText("Weather Plugin")).toBeTruthy();
expect(within(typeahead).queryByText("Users")).toBeNull();
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
});
it("falls back to typed skill search when a typeahead skill has no owner handle", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
useUnifiedSearchMock.mockReturnValue({
...defaultUnifiedSearchResult,
skillResults: [
{
...defaultUnifiedSearchResult.skillResults[0],
ownerHandle: null,
skill: {
...defaultUnifiedSearchResult.skillResults[0].skill,
ownerUserId: "users:opaque-id",
ownerPublisherId: "publishers:opaque-id",
},
},
],
pluginResults: [],
pluginCount: 0,
});
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
expect(navigateMock).not.toHaveBeenCalledWith(
expect.objectContaining({
to: "/publishers%3Aopaque-id/weather",
}),
);
});
it("shows a single no-results state without section footers", () => {
siteModeMock.mockReturnValue("skills");
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
isSearching: false,
});
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "zzzz" } });
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy();
expect(within(typeahead).queryByText("Skills")).toBeNull();
expect(within(typeahead).queryByText("Plugins")).toBeNull();
expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull();
expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull();
});
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");
+2 -3
View File
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
@@ -51,10 +50,10 @@ describe("search route", () => {
});
});
it("accepts the users type filter", () => {
it("ignores the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
type: undefined,
});
});
+18 -14
View File
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const navigateMock = vi.fn();
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
useNavigate: () => navigateMock,
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => ({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
userCount: 0,
isSearching: false,
}),
}));
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
@@ -64,7 +59,7 @@ describe("search route", () => {
const Component = route.__config.component as ComponentType;
const rendered = render(<Component />);
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
expect(input.value).toBe("first");
fireEvent.change(input, { target: { value: "draft" } });
@@ -74,7 +69,16 @@ describe("search route", () => {
rendered.rerender(<Component />);
expect(
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
).toBe("second");
});
it("does not render a public users search tab", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
});
});
+56
View File
@@ -0,0 +1,56 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const queryMock = vi.fn();
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
}),
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
async function loadRoute() {
return (await import("../routes/users/index")).Route as unknown as {
__config: {
component?: ComponentType;
validateSearch?: unknown;
};
};
}
describe("users route", () => {
beforeEach(() => {
vi.resetModules();
queryMock.mockReset();
queryMock.mockResolvedValue({ items: [], total: 0 });
});
it("does not expose public user search", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
await waitFor(() => expect(queryMock).toHaveBeenCalled());
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
expect(route.__config.validateSearch).toBeUndefined();
});
});
+343 -19
View File
@@ -1,7 +1,7 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useState } from "react";
import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useEffect, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import { filterNavItems, type NavIconName, PRIMARY_NAV_ITEMS } from "../lib/nav-items";
@@ -10,6 +10,11 @@ import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import {
useUnifiedSearch,
type UnifiedPluginResult,
type UnifiedSkillResult,
} from "../lib/useUnifiedSearch";
import { Button } from "./ui/button";
import {
DropdownMenu,
@@ -33,6 +38,24 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
ghost: Ghost,
};
type TypeaheadItem =
| {
kind: "skill";
key: string;
result: UnifiedSkillResult;
}
| {
kind: "plugin";
key: string;
result: UnifiedPluginResult;
}
| {
kind: "footer";
key: string;
section: "skills" | "plugins";
label: string;
};
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
@@ -58,10 +81,67 @@ export default function Header() {
const signInRedirectTo = getCurrentRelativeUrl();
const [navSearchQuery, setNavSearchQuery] = useState("");
const [typeaheadOpen, setTypeaheadOpen] = useState(false);
const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const searchWrapRef = useRef<HTMLDivElement | null>(null);
const ThemeModeIcon = getThemeModeIcon(mode);
const nextThemeMode = getNextThemeMode(mode);
const trimmedNavSearchQuery = navSearchQuery.trim();
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
const {
skillResults,
skillCount,
pluginResults,
pluginCount,
isSearching: typeaheadSearching,
} = useUnifiedSearch(navSearchQuery, "all", {
debounceMs: 180,
enabled: showTypeahead,
limits: { skills: 4, plugins: 4 },
});
const typeaheadItems = useMemo<TypeaheadItem[]>(() => {
if (!showTypeahead) return [];
const items: TypeaheadItem[] = [];
for (const result of skillResults) {
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
}
if (skillCount > 0) {
items.push({
kind: "footer",
key: "footer-skills",
section: "skills",
label: `See skill results for "${trimmedNavSearchQuery}"`,
});
}
for (const result of pluginResults) {
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
}
if (pluginCount > 0) {
items.push({
kind: "footer",
key: "footer-plugins",
section: "plugins",
label: `See plugin results for "${trimmedNavSearchQuery}"`,
});
}
return items;
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
useEffect(() => {
setTypeaheadActiveIndex(0);
}, [trimmedNavSearchQuery]);
useEffect(() => {
if (!typeaheadOpen) return () => {};
const handlePointerDown = (event: PointerEvent) => {
if (searchWrapRef.current?.contains(event.target as Node)) return;
setTypeaheadOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [typeaheadOpen]);
const setThemeMode = (next: "system" | "light" | "dark") => {
applyTheme(next, theme);
@@ -85,9 +165,72 @@ export default function Header() {
: { q, type: undefined },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const navigateToTypeaheadItem = (item: TypeaheadItem) => {
if (item.kind === "skill") {
const resultOwnerHandle = item.result.ownerHandle?.trim();
if (!resultOwnerHandle) {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: "skills" },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
return;
}
void navigate({
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
});
} else if (item.kind === "plugin") {
void navigate({
to: "/plugins/$name",
params: { name: item.result.plugin.name },
});
} else {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: item.section },
});
}
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (isSoulMode) return;
if (event.key === "Escape") {
setTypeaheadOpen(false);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return;
if (!showTypeahead || typeaheadItems.length === 0) {
if (event.key === "ArrowDown" && trimmedNavSearchQuery) {
setTypeaheadOpen(true);
event.preventDefault();
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setTypeaheadActiveIndex(
(index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length,
);
} else if (event.key === "Enter") {
const activeItem = typeaheadItems[typeaheadActiveIndex];
if (!activeItem) return;
event.preventDefault();
navigateToTypeaheadItem(activeItem);
}
};
return (
<header className="navbar">
<div className="navbar-inner">
@@ -172,22 +315,42 @@ export default function Header() {
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
<form
className="navbar-search"
onSubmit={handleNavSearch}
role="search"
aria-label="Site search"
>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
aria-label="Search"
/>
</form>
<div className="navbar-search-wrap" ref={searchWrapRef}>
<form
className="navbar-search"
onSubmit={handleNavSearch}
role="search"
aria-label="Site search"
>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => {
setNavSearchQuery(e.target.value);
setTypeaheadOpen(true);
}}
onFocus={() => setTypeaheadOpen(true)}
onKeyDown={handleSearchKeyDown}
aria-label="Search"
aria-expanded={showTypeahead}
aria-controls="navbar-search-typeahead"
autoComplete="off"
/>
</form>
{showTypeahead ? (
<SearchTypeahead
activeIndex={typeaheadActiveIndex}
items={typeaheadItems}
loading={typeaheadSearching}
onHoverItem={setTypeaheadActiveIndex}
onSelectItem={navigateToTypeaheadItem}
query={trimmedNavSearchQuery}
/>
) : null}
</div>
<nav className="navbar-top-links" aria-label="Primary">
{isSoulMode ? (
@@ -305,7 +468,7 @@ export default function Header() {
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
@@ -317,6 +480,167 @@ export default function Header() {
);
}
function SearchTypeahead({
activeIndex,
items,
loading,
onHoverItem,
onSelectItem,
query,
}: {
activeIndex: number;
items: TypeaheadItem[];
loading: boolean;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
query: string;
}) {
const skillItems = items.filter((item) => item.kind === "skill");
const pluginItems = items.filter((item) => item.kind === "plugin");
const footerItems = items.filter((item) => item.kind === "footer");
const skillsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "skills",
);
const pluginsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "plugins",
);
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
return (
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Skills"
sectionItems={skillItems}
footer={skillsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Plugins"
sectionItems={pluginItems}
footer={pluginsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
{loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">Searching...</div>
) : null}
{!loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">
No skills or plugins found for "{query}"
</div>
) : null}
</div>
);
}
function TypeaheadSection({
activeIndex,
footer,
items,
label,
onHoverItem,
onSelectItem,
sectionItems,
}: {
activeIndex: number;
footer: TypeaheadItem | undefined;
items: TypeaheadItem[];
label: string;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
sectionItems: TypeaheadItem[];
}) {
if (sectionItems.length === 0 && !footer) return null;
return (
<div className="navbar-search-typeahead-section">
<div className="navbar-search-typeahead-heading">{label}</div>
{sectionItems.map((item) => (
<TypeaheadRow
key={item.key}
active={items[activeIndex]?.key === item.key}
item={item}
index={items.findIndex((candidate) => candidate.key === item.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
))}
{footer ? (
<TypeaheadRow
active={items[activeIndex]?.key === footer.key}
item={footer}
index={items.findIndex((candidate) => candidate.key === footer.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
) : null}
</div>
);
}
function TypeaheadRow({
active,
index,
item,
onHoverItem,
onSelectItem,
}: {
active: boolean;
index: number;
item: TypeaheadItem;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
}) {
const body = getTypeaheadRowBody(item);
return (
<button
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => onHoverItem(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelectItem(item)}
>
{body.icon ? <span className="navbar-search-typeahead-icon">{body.icon}</span> : null}
<span className="navbar-search-typeahead-copy">
<span className="navbar-search-typeahead-title">{body.title}</span>
{body.meta ? <span className="navbar-search-typeahead-meta">{body.meta}</span> : null}
</span>
{item.kind === "footer" ? <ArrowRight size={14} aria-hidden="true" /> : null}
</button>
);
}
function getTypeaheadRowBody(item: TypeaheadItem) {
if (item.kind === "skill") {
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
return {
icon: "S",
title: item.result.skill.displayName,
meta: `${owner} / ${item.result.skill.slug}`,
};
}
if (item.kind === "plugin") {
return {
icon: "P",
title: item.result.plugin.displayName,
meta: item.result.plugin.ownerHandle
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
: item.result.plugin.name,
};
}
return {
icon: null,
title: item.label,
meta: null,
};
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
+46 -47
View File
@@ -1,9 +1,9 @@
import { useAction } from "convex/react";
import { useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
import type { PublicUser } from "./publicUser";
export type UnifiedSearchType = "all" | "skills" | "plugins";
export type UnifiedSkillResult = {
type: "skill";
@@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
plugin: PackageListItem;
};
export type UnifiedUserResult = {
type: "user";
user: PublicUser;
};
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
type UnifiedSearchOptions = {
debounceMs?: number;
enabled?: boolean;
limits?: {
skills?: number;
plugins?: number;
};
};
export function useUnifiedSearch(
query: string,
activeType: "all" | "skills" | "plugins" | "users",
activeType: UnifiedSearchType,
options: UnifiedSearchOptions = {},
) {
const searchSkills = useAction(api.search.searchSkills);
const [results, setResults] = useState<UnifiedResult[]>([]);
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
const [skillCount, setSkillCount] = useState(0);
const [pluginCount, setPluginCount] = useState(0);
const [userCount, setUserCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const requestRef = useRef(0);
const debounceMs = options.debounceMs ?? 300;
const enabled = options.enabled ?? true;
const skillLimit = options.limits?.skills ?? 25;
const pluginLimit = options.limits?.plugins ?? 25;
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
if (!enabled || !trimmed) {
requestRef.current += 1;
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return () => {};
}
@@ -65,40 +76,34 @@ export function useUnifiedSearch(
const handle = window.setTimeout(() => {
void (async () => {
try {
const promises: [
Promise<unknown> | null,
Promise<{ items: PackageListItem[] }> | null,
Promise<{ items: PublicUser[] }> | null,
] = [null, null, null];
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
[null, null];
if (activeType === "all" || activeType === "skills") {
promises[0] = searchSkills({
query: trimmed,
limit: 25,
limit: skillLimit,
nonSuspiciousOnly: true,
});
}
if (activeType === "all" || activeType === "plugins") {
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
}
if (activeType === "all" || activeType === "users") {
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
}
const settled = await Promise.allSettled(
promises.map((p) => p ?? Promise.resolve(null)),
);
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
if (requestId !== requestRef.current) return;
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
const skillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
const nextSkillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{
skill: UnifiedSkillResult["skill"];
ownerHandle: string | null;
score: number;
}>) ?? []
).map((entry) => ({
type: "skill" as const,
skill: entry.skill,
@@ -106,32 +111,25 @@ export function useUnifiedSearch(
score: entry.score,
}));
const pluginResults: UnifiedPluginResult[] = (
const nextPluginResults: UnifiedPluginResult[] = (
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
).map((item) => ({
type: "plugin" as const,
plugin: item,
}));
setSkillCount(skillResults.length);
setPluginCount(pluginResults.length);
const userResults: UnifiedUserResult[] = (
(usersRaw as { items: PublicUser[] })?.items ?? []
).map((user) => ({
type: "user" as const,
user,
}));
setUserCount(userResults.length);
setSkillCount(nextSkillResults.length);
setPluginCount(nextPluginResults.length);
setSkillResults(nextSkillResults);
setPluginResults(nextPluginResults);
const merged: UnifiedResult[] = [];
if (activeType === "all") {
merged.push(...skillResults, ...pluginResults, ...userResults);
merged.push(...nextSkillResults, ...nextPluginResults);
} else if (activeType === "skills") {
merged.push(...skillResults);
} else if (activeType === "plugins") {
merged.push(...pluginResults);
merged.push(...nextSkillResults);
} else {
merged.push(...userResults);
merged.push(...nextPluginResults);
}
setResults(merged);
@@ -139,9 +137,10 @@ export function useUnifiedSearch(
console.error("Unified search failed:", error);
if (requestId === requestRef.current) {
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
}
} finally {
if (requestId === requestRef.current) {
@@ -149,10 +148,10 @@ export function useUnifiedSearch(
}
}
})();
}, 300);
}, debounceMs);
return () => window.clearTimeout(handle);
}, [query, activeType, searchSkills]);
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
return { results, skillCount, pluginCount, userCount, isSearching };
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
}
+25 -336
View File
@@ -1,6 +1,5 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
@@ -12,6 +11,7 @@ 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";
@@ -28,13 +28,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;
@@ -104,262 +97,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 +109,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"
@@ -516,12 +215,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 +251,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">
@@ -673,11 +368,7 @@ function SkillsHome() {
</div>
<div className="home-v2-trending-grid">
{popular.slice(0, 6).map((entry) => (
<Link
key={entry.skill._id}
to={skillLink(entry)}
className="home-v2-trend-card"
>
<Link key={entry.skill._id} to={skillLink(entry)} className="home-v2-trend-card">
<div className="home-v2-trend-head">
<div className="home-v2-trend-title">
{entry.skill.displayName || entry.skill.slug}
@@ -692,12 +383,10 @@ function SkillsHome() {
<div className="home-v2-trend-bottom">
<div className="home-v2-trend-signals">
<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-trend-install">
+13 -43
View File
@@ -3,28 +3,24 @@ import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { PluginListItem } from "../components/PluginListItem";
import { SkillListItem } from "../components/SkillListItem";
import { UserListItem } from "../components/UserListItem";
import { Card } from "../components/ui/card";
import type { PublicSkill, PublicUser } from "../lib/publicUser";
import type { PublicSkill } from "../lib/publicUser";
import {
useUnifiedSearch,
type UnifiedSearchType,
type UnifiedPluginResult,
type UnifiedSkillResult,
type UnifiedUserResult,
} from "../lib/useUnifiedSearch";
type SearchState = {
q?: string;
type?: "all" | "skills" | "plugins" | "users";
type?: UnifiedSearchType;
};
export const Route = createFileRoute("/search")({
validateSearch: (search): SearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
type:
search.type === "skills" || search.type === "plugins" || search.type === "users"
? search.type
: undefined,
type: search.type === "skills" || search.type === "plugins" ? search.type : undefined,
}),
component: UnifiedSearchPage,
});
@@ -39,7 +35,7 @@ function UnifiedSearchPage() {
setQuery(search.q ?? "");
}, [search.q]);
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
search.q ?? "",
activeType,
);
@@ -52,7 +48,7 @@ function UnifiedSearchPage() {
});
};
const setType = (type: "all" | "skills" | "plugins" | "users") => {
const setType = (type: UnifiedSearchType) => {
void navigate({
to: "/search",
search: { q: search.q, type: type === "all" ? undefined : type },
@@ -74,11 +70,11 @@ function UnifiedSearchPage() {
<form className="search-page-form" onSubmit={handleSearch}>
<div className="browse-search-bar max-w-[560px] flex-1">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
type="text"
placeholder="Search skills, plugins, users..."
placeholder="Search skills and plugins..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
@@ -100,9 +96,7 @@ function UnifiedSearchPage() {
onClick={() => setType("skills")}
>
Skills
{skillCount > 0 ? (
<span className="search-tab-count">{skillCount}</span>
) : null}
{skillCount > 0 ? <span className="search-tab-count">{skillCount}</span> : null}
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
@@ -110,17 +104,7 @@ function UnifiedSearchPage() {
onClick={() => setType("plugins")}
>
Plugins
{pluginCount > 0 ? (
<span className="search-tab-count">{pluginCount}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setType("users")}
>
Users
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
{pluginCount > 0 ? <span className="search-tab-count">{pluginCount}</span> : null}
</button>
</div>
@@ -130,9 +114,7 @@ function UnifiedSearchPage() {
</Card>
) : !search.q ? (
<Card className="text-center p-10">
<p className="text-ink-soft">
Enter a search term to find skills, plugins, and users
</p>
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
</Card>
) : results.length === 0 ? (
<Card className="text-center p-10">
@@ -143,10 +125,8 @@ function UnifiedSearchPage() {
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : item.type === "plugin" ? (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
) : (
<UserResultRow key={`user-${item.user._id}`} result={item} />
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
@@ -157,19 +137,9 @@ function UnifiedSearchPage() {
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
const skill = result.skill as unknown as PublicSkill;
return (
<SkillListItem
skill={skill}
ownerHandle={result.ownerHandle}
/>
);
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
}
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
return <PluginListItem item={result.plugin} />;
}
function UserResultRow({ result }: { result: UnifiedUserResult }) {
const user = result.user as PublicUser;
return <UserListItem user={user} />;
}
+13 -4
View File
@@ -64,14 +64,23 @@ function SoulsHoldingPage() {
</div>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
<Link
to="/skills"
search={{
q: undefined,
sort: "downloads",
dir: "desc",
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse Skills
</Link>
</Button>
<Button asChild>
<Link to="/users" search={{ q: undefined }}>
Browse Users
</Link>
<Link to="/users">Browse Users</Link>
</Button>
</div>
</section>
+5 -38
View File
@@ -1,38 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { UserListItem } from "../../components/UserListItem";
import { Card } from "../../components/ui/card";
import { UserListItem } from "../../components/UserListItem";
import { convexHttp } from "../../convex/client";
import type { PublicUser } from "../../lib/publicUser";
type UserSearchState = {
q?: string;
};
type UsersLoaderResult = { items: PublicUser[]; total: number };
export const Route = createFileRoute("/users/")({
validateSearch: (search): UserSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
}),
component: UsersIndex,
});
function UsersIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const [query, setQuery] = useState(search.q ?? "");
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
const [loading, setLoading] = useState(true);
const fetchUsers = useCallback(async (q?: string) => {
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const data = await convexHttp.query(api.users.listPublic, {
limit: 48,
search: q,
});
setResult(data as UsersLoaderResult);
} finally {
@@ -41,9 +29,8 @@ function UsersIndex() {
}, []);
useEffect(() => {
setQuery(search.q ?? "");
void fetchUsers(search.q);
}, [search.q, fetchUsers]);
void fetchUsers();
}, [fetchUsers]);
const users = result?.items ?? [];
@@ -57,25 +44,6 @@ function UsersIndex() {
) : null}
</h1>
</div>
<form
className="browse-page-search"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: {
q: query.trim() || undefined,
},
});
}}
>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
placeholder="Search users..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<div className="browse-results">
<div className="browse-results-toolbar">
@@ -90,8 +58,7 @@ function UsersIndex() {
</Card>
) : users.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No users found</p>
<p className="empty-state-body">Try a different handle or name.</p>
<p className="empty-state-title">No users yet</p>
</div>
) : (
<div className="results-list">
+97 -197
View File
@@ -775,6 +775,11 @@ code {
box-shadow 0.15s ease;
}
.navbar-search-wrap {
position: relative;
min-width: 0;
}
.navbar-search:focus-within {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px var(--input-focus-ring);
@@ -807,6 +812,98 @@ code {
opacity: 0.7;
}
.navbar-search-typeahead {
position: absolute;
z-index: 50;
top: calc(100% + 8px);
left: 0;
right: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.navbar-search-typeahead-section + .navbar-search-typeahead-section {
border-top: 1px solid var(--line);
}
.navbar-search-typeahead-heading {
padding: 8px 12px;
background: var(--surface-muted);
color: var(--ink);
font-size: var(--fs-sm);
font-weight: 700;
}
.navbar-search-typeahead-row {
all: unset;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 8px 12px;
border-top: 1px solid var(--line);
color: var(--ink);
cursor: pointer;
}
.navbar-search-typeahead-row:hover,
.navbar-search-typeahead-row.is-active {
background: var(--surface-muted);
}
.navbar-search-typeahead-row.is-footer {
color: var(--ink-soft);
}
.navbar-search-typeahead-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
font-size: var(--fs-xs);
font-weight: 700;
flex: 0 0 auto;
}
.navbar-search-typeahead-copy {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.navbar-search-typeahead-title,
.navbar-search-typeahead-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-search-typeahead-title {
font-size: var(--fs-sm);
font-weight: 600;
}
.navbar-search-typeahead-meta {
color: var(--ink-soft);
font-size: var(--fs-xs);
}
.navbar-search-typeahead-status {
padding: 12px;
color: var(--ink-soft);
font-size: var(--fs-sm);
}
.navbar-search-home {
justify-content: space-between;
cursor: pointer;
@@ -8510,21 +8607,6 @@ code {
}
/* Headline */
.home-v2-hero-label {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--hv2-text-tertiary);
margin-bottom: 20px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition:
color 0.2s,
text-shadow 0.3s;
}
.home-v2-headline {
font-family: "Inter", sans-serif;
font-weight: 700;
@@ -8613,159 +8695,6 @@ code {
}
}
/* ═══ SLOT MACHINE EASTER EGG ═══ */
.home-v2-hero-label:hover {
color: var(--hv2-accent);
}
.home-v2-hero-label-active {
color: var(--hv2-accent) !important;
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
}
@keyframes home-v2-labelPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.home-v2-headline-slots {
min-height: 1.15em;
position: relative;
}
.home-v2-slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 180px;
text-align: center;
font-family: "JetBrains Mono", monospace;
font-weight: 800;
color: var(--hv2-accent);
position: relative;
}
.home-v2-slot-reel.spinning .home-v2-slot-word {
animation: home-v2-slotBlur 0.12s steps(1) infinite;
}
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes home-v2-slotBlur {
0% {
filter: blur(0px);
opacity: 1;
}
50% {
filter: blur(1px);
opacity: 0.7;
}
100% {
filter: blur(0px);
opacity: 1;
}
}
@keyframes home-v2-slotLand {
0% {
transform: translateY(-8px) scale(1.1);
opacity: 0.5;
}
60% {
transform: translateY(2px) scale(0.98);
}
100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.home-v2-headline-jackpot {
animation: home-v2-jackpot 0.5s ease-out;
}
@keyframes home-v2-jackpot {
0% {
transform: scale(1);
}
30% {
transform: scale(1.08);
}
60% {
transform: scale(0.97);
}
100% {
transform: scale(1);
}
}
.home-v2-headline-jackpot .home-v2-slot-word {
color: #ffd93d !important;
text-shadow:
0 0 20px rgba(255, 217, 61, 0.6),
0 0 40px rgba(255, 217, 61, 0.3);
}
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
.home-v2-headline-hack .home-v2-slot-word {
color: #22d3ee !important;
text-shadow:
0 0 24px rgba(34, 211, 238, 0.6),
0 0 48px rgba(6, 182, 212, 0.3),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
.home-v2-headline-hack .home-v2-sep {
border-color: #22d3ee;
opacity: 0.8;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
}
.home-v2-hack-lobster {
position: absolute;
top: 50%;
left: 50%;
width: 280px;
height: 280px;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5))
drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
z-index: -1;
}
@keyframes home-v2-lobsterReveal {
0% {
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
opacity: 0;
}
50% {
opacity: 0.18;
}
100% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 0.12;
}
}
.home-v2-confetti {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}
.home-v2-sub {
color: var(--hv2-text-tertiary);
font-size: 16px;
line-height: 1.5;
margin-bottom: 36px;
max-width: 580px;
font-weight: 400;
}
.home-v2-sub-clear {
color: var(--hv2-text-secondary);
max-width: 820px;
margin-bottom: 18px;
}
.home-v2-motto {
display: flex;
flex-direction: column;
@@ -8830,16 +8759,6 @@ code {
.home-v2-search-bar input::placeholder {
color: var(--hv2-text-tertiary);
}
.home-v2-search-bar kbd {
font-family: "Inter", sans-serif;
font-size: 11px;
background: transparent;
border: 1px solid var(--hv2-border);
border-radius: 5px;
padding: 2px 7px;
color: var(--hv2-text-tertiary);
flex-shrink: 0;
}
.home-v2-search-go {
background: var(--hv2-accent-fill);
color: var(--hv2-accent);
@@ -8877,12 +8796,6 @@ code {
margin-top: 16px;
flex-wrap: wrap;
}
.home-v2-suggestions-label {
color: var(--hv2-text-secondary);
font-size: 13px;
font-weight: 500;
margin-right: 4px;
}
.home-v2-suggestion {
display: flex;
align-items: center;
@@ -8918,11 +8831,6 @@ code {
margin-top: 12px;
}
.home-v2-suggestions-label {
font-size: 12px;
margin-right: 2px;
}
.home-v2-suggestion {
font-size: 12px;
padding: 5px 10px;
@@ -9714,11 +9622,6 @@ code {
[data-theme-resolved="light"] .home-v2-search-bar input::placeholder {
color: #9c8b7a;
}
[data-theme-resolved="light"] .home-v2-search-bar kbd {
border-color: rgba(170, 125, 80, 0.2);
color: #9c8b7a;
}
/* Light — search button */
[data-theme-resolved="light"] .home-v2-search-go {
background: rgba(196, 58, 47, 0.06);
@@ -9901,9 +9804,6 @@ code {
.home-v2-search-go {
border-radius: 8px;
}
.home-v2-search-bar kbd {
border-radius: 8px;
}
.home-v2-c-icon {
border-radius: 8px;
}