mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8d53fa5f | ||
|
|
f6661a7a4c | ||
|
|
d2c2e7b509 | ||
|
|
70f6a435e2 | ||
|
|
929c9de50e | ||
|
|
ea67295794 | ||
|
|
f5a3cba962 | ||
|
|
b2a173ab75 | ||
|
|
2e74c9bad2 |
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
@@ -51,10 +50,10 @@ describe("search route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the users type filter", () => {
|
||||
it("ignores the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => ({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
userCount: 0,
|
||||
isSearching: false,
|
||||
}),
|
||||
}));
|
||||
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
|
||||
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
@@ -64,7 +59,7 @@ describe("search route", () => {
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const rendered = render(<Component />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
|
||||
expect(input.value).toBe("first");
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft" } });
|
||||
@@ -74,7 +69,16 @@ describe("search route", () => {
|
||||
rendered.rerender(<Component />);
|
||||
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
|
||||
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
|
||||
).toBe("second");
|
||||
});
|
||||
|
||||
it("does not render a public users search tab", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryMock = vi.fn();
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/users/index")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
validateSearch?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("users route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
queryMock.mockReset();
|
||||
queryMock.mockResolvedValue({ items: [], total: 0 });
|
||||
});
|
||||
|
||||
it("does not expose public user search", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
await waitFor(() => expect(queryMock).toHaveBeenCalled());
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
|
||||
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
|
||||
expect(route.__config.validateSearch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+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}`;
|
||||
|
||||
+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 };
|
||||
}
|
||||
|
||||
+25
-336
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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