fix: drop unrelated conflict carryover from PR #1879
Some checks failed
Security Gate: Secret Scanning / Scan for Verified Secrets (push) Has been cancelled

This commit is contained in:
momothemage 2026-05-07 10:42:51 +08:00
parent f6661a7a4c
commit ef8d53fa5f
No known key found for this signature in database
13 changed files with 789 additions and 733 deletions

View File

@ -583,35 +583,33 @@ function makeInsertReleaseCtx(
indexName: string,
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
if (indexName === "by_package") {
return {
collect: vi.fn().mockResolvedValue(priorReleases),
};
}
if (indexName === "by_package_version") {
const filters = new Map<string, unknown>();
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return {
unique: vi
.fn()
.mockResolvedValue(
priorReleases.find(
(release) =>
release.packageId === filters.get("packageId") &&
release.version === filters.get("version"),
) ?? null,
),
};
}
if (indexName === "by_package") {
return {
unique: vi.fn().mockResolvedValue(null),
collect: vi.fn().mockResolvedValue(priorReleases),
};
}
if (indexName === "by_package_version") {
const filters = new Map<string, unknown>();
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return {
unique: vi.fn().mockResolvedValue(
priorReleases.find(
(release) =>
release.packageId === filters.get("packageId") &&
release.version === filters.get("version"),
) ?? null,
),
};
}
return {
unique: vi.fn().mockResolvedValue(null),
};
},
),
};

View File

@ -394,12 +394,11 @@ export const list = query({
});
export const listPublic = query({
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 40, 1, 100);
const result = await queryUsersForPublicList(ctx, {
limit,
search: args.search,
});
return {
items: result.items

View File

@ -1,9 +1,8 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Header from "../components/Header";
type HeaderAuthStatus = {
isAuthenticated: boolean;
@ -13,6 +12,52 @@ type HeaderAuthStatus = {
const siteModeMock = vi.fn(() => "souls");
const navigateMock = vi.fn();
const { useUnifiedSearchMock } = vi.hoisted(() => ({
useUnifiedSearchMock: vi.fn(),
}));
const defaultUnifiedSearchResult = {
results: [],
skillResults: [
{
type: "skill",
ownerHandle: "local",
score: 10,
skill: {
_id: "skills:weather",
slug: "weather",
displayName: "Weather Skill",
ownerUserId: "users:local",
stats: { downloads: 1, stars: 2 },
createdAt: 1,
updatedAt: 2,
},
},
],
pluginResults: [
{
type: "plugin",
plugin: {
name: "weather-plugin",
displayName: "Weather Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
summary: "Plugin weather tools.",
ownerHandle: "local",
createdAt: 1,
updatedAt: 2,
latestVersion: "1.0.0",
capabilityTags: [],
executesCode: true,
verificationTier: null,
},
},
],
skillCount: 1,
pluginCount: 1,
isSearching: false,
};
vi.mock("@tanstack/react-router", () => ({
Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => (
@ -90,6 +135,10 @@ vi.mock("../lib/gravatar", () => ({
gravatarUrl: vi.fn(),
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => useUnifiedSearchMock(),
}));
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@ -105,6 +154,8 @@ vi.mock("../components/ui/toggle-group", () => ({
),
}));
import Header from "../components/Header";
describe("Header", () => {
beforeEach(() => {
authStatusMock.mockReturnValue({
@ -113,6 +164,7 @@ describe("Header", () => {
me: null,
});
siteModeMock.mockReturnValue("souls");
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
});
it("hides Packages navigation in soul mode on mobile and desktop", () => {
@ -136,7 +188,7 @@ describe("Header", () => {
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
expect(setModeMock).toHaveBeenCalledWith("dark");
@ -148,6 +200,96 @@ describe("Header", () => {
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
it("shows grouped skills and plugins typeahead without users", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText("Skills")).toBeTruthy();
expect(screen.getByText("Weather Skill")).toBeTruthy();
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
expect(screen.getByText("Weather Plugin")).toBeTruthy();
expect(within(typeahead).queryByText("Users")).toBeNull();
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
});
it("falls back to typed skill search when a typeahead skill has no owner handle", () => {
siteModeMock.mockReturnValue("skills");
navigateMock.mockReset();
useUnifiedSearchMock.mockReturnValue({
...defaultUnifiedSearchResult,
skillResults: [
{
...defaultUnifiedSearchResult.skillResults[0],
ownerHandle: null,
skill: {
...defaultUnifiedSearchResult.skillResults[0].skill,
ownerUserId: "users:opaque-id",
ownerPublisherId: "publishers:opaque-id",
},
},
],
pluginResults: [],
pluginCount: 0,
});
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "weather" } });
fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i }));
expect(navigateMock).toHaveBeenCalledWith({
to: "/search",
search: { q: "weather", type: "skills" },
});
expect(navigateMock).not.toHaveBeenCalledWith(
expect.objectContaining({
to: "/publishers%3Aopaque-id/weather",
}),
);
});
it("shows a single no-results state without section footers", () => {
siteModeMock.mockReturnValue("skills");
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
isSearching: false,
});
render(<Header />);
const input = screen.getByPlaceholderText("Search skills and plugins");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "zzzz" } });
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy();
expect(within(typeahead).queryByText("Skills")).toBeNull();
expect(within(typeahead).queryByText("Plugins")).toBeNull();
expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull();
expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull();
});
it("shows Home above Skills in the mobile menu", () => {
siteModeMock.mockReturnValue("skills");

View File

@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
vi.mock("../convex/client", () => ({
convex: {},
convexHttp: { query: vi.fn() },
@ -51,10 +50,10 @@ describe("search route", () => {
});
});
it("accepts the users type filter", () => {
it("ignores the users type filter", () => {
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
q: "vincent",
type: "users",
type: undefined,
});
});

View File

@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const navigateMock = vi.fn();
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useSearch: () => searchMock,
}),
useNavigate: () => navigateMock,
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => ({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
userCount: 0,
isSearching: false,
}),
}));
@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
@ -64,7 +59,7 @@ describe("search route", () => {
const Component = route.__config.component as ComponentType;
const rendered = render(<Component />);
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
expect(input.value).toBe("first");
fireEvent.change(input, { target: { value: "draft" } });
@ -74,7 +69,16 @@ describe("search route", () => {
rendered.rerender(<Component />);
expect(
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
).toBe("second");
});
it("does not render a public users search tab", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
});
});

View File

@ -0,0 +1,56 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const queryMock = vi.fn();
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
__config: config,
}),
}));
vi.mock("../components/UserListItem", () => ({
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
}));
vi.mock("../components/ui/card", () => ({
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
async function loadRoute() {
return (await import("../routes/users/index")).Route as unknown as {
__config: {
component?: ComponentType;
validateSearch?: unknown;
};
};
}
describe("users route", () => {
beforeEach(() => {
vi.resetModules();
queryMock.mockReset();
queryMock.mockResolvedValue({ items: [], total: 0 });
});
it("does not expose public user search", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
await waitFor(() => expect(queryMock).toHaveBeenCalled());
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
expect(route.__config.validateSearch).toBeUndefined();
});
});

View File

@ -1,7 +1,7 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useState } from "react";
import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useEffect, useMemo, useRef, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import { filterNavItems, type NavIconName, PRIMARY_NAV_ITEMS } from "../lib/nav-items";
@ -10,6 +10,11 @@ import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import {
useUnifiedSearch,
type UnifiedPluginResult,
type UnifiedSkillResult,
} from "../lib/useUnifiedSearch";
import { Button } from "./ui/button";
import {
DropdownMenu,
@ -33,6 +38,24 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
ghost: Ghost,
};
type TypeaheadItem =
| {
kind: "skill";
key: string;
result: UnifiedSkillResult;
}
| {
kind: "plugin";
key: string;
result: UnifiedPluginResult;
}
| {
kind: "footer";
key: string;
section: "skills" | "plugins";
label: string;
};
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
@ -58,10 +81,67 @@ export default function Header() {
const signInRedirectTo = getCurrentRelativeUrl();
const [navSearchQuery, setNavSearchQuery] = useState("");
const [typeaheadOpen, setTypeaheadOpen] = useState(false);
const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const searchWrapRef = useRef<HTMLDivElement | null>(null);
const ThemeModeIcon = getThemeModeIcon(mode);
const nextThemeMode = getNextThemeMode(mode);
const trimmedNavSearchQuery = navSearchQuery.trim();
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
const {
skillResults,
skillCount,
pluginResults,
pluginCount,
isSearching: typeaheadSearching,
} = useUnifiedSearch(navSearchQuery, "all", {
debounceMs: 180,
enabled: showTypeahead,
limits: { skills: 4, plugins: 4 },
});
const typeaheadItems = useMemo<TypeaheadItem[]>(() => {
if (!showTypeahead) return [];
const items: TypeaheadItem[] = [];
for (const result of skillResults) {
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
}
if (skillCount > 0) {
items.push({
kind: "footer",
key: "footer-skills",
section: "skills",
label: `See skill results for "${trimmedNavSearchQuery}"`,
});
}
for (const result of pluginResults) {
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
}
if (pluginCount > 0) {
items.push({
kind: "footer",
key: "footer-plugins",
section: "plugins",
label: `See plugin results for "${trimmedNavSearchQuery}"`,
});
}
return items;
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
useEffect(() => {
setTypeaheadActiveIndex(0);
}, [trimmedNavSearchQuery]);
useEffect(() => {
if (!typeaheadOpen) return () => {};
const handlePointerDown = (event: PointerEvent) => {
if (searchWrapRef.current?.contains(event.target as Node)) return;
setTypeaheadOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [typeaheadOpen]);
const setThemeMode = (next: "system" | "light" | "dark") => {
applyTheme(next, theme);
@ -85,9 +165,72 @@ export default function Header() {
: { q, type: undefined },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const navigateToTypeaheadItem = (item: TypeaheadItem) => {
if (item.kind === "skill") {
const resultOwnerHandle = item.result.ownerHandle?.trim();
if (!resultOwnerHandle) {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: "skills" },
});
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
return;
}
void navigate({
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
});
} else if (item.kind === "plugin") {
void navigate({
to: "/plugins/$name",
params: { name: item.result.plugin.name },
});
} else {
void navigate({
to: "/search",
search: { q: trimmedNavSearchQuery, type: item.section },
});
}
setNavSearchQuery("");
setTypeaheadOpen(false);
setMobileSearchOpen(false);
};
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (isSoulMode) return;
if (event.key === "Escape") {
setTypeaheadOpen(false);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return;
if (!showTypeahead || typeaheadItems.length === 0) {
if (event.key === "ArrowDown" && trimmedNavSearchQuery) {
setTypeaheadOpen(true);
event.preventDefault();
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setTypeaheadActiveIndex(
(index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length,
);
} else if (event.key === "Enter") {
const activeItem = typeaheadItems[typeaheadActiveIndex];
if (!activeItem) return;
event.preventDefault();
navigateToTypeaheadItem(activeItem);
}
};
return (
<header className="navbar">
<div className="navbar-inner">
@ -172,22 +315,42 @@ export default function Header() {
<span className="brand-name brand-name-responsive">{siteName}</span>
</Link>
<form
className="navbar-search"
onSubmit={handleNavSearch}
role="search"
aria-label="Site search"
>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
aria-label="Search"
/>
</form>
<div className="navbar-search-wrap" ref={searchWrapRef}>
<form
className="navbar-search"
onSubmit={handleNavSearch}
role="search"
aria-label="Site search"
>
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="navbar-search-input"
type="search"
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => {
setNavSearchQuery(e.target.value);
setTypeaheadOpen(true);
}}
onFocus={() => setTypeaheadOpen(true)}
onKeyDown={handleSearchKeyDown}
aria-label="Search"
aria-expanded={showTypeahead}
aria-controls="navbar-search-typeahead"
autoComplete="off"
/>
</form>
{showTypeahead ? (
<SearchTypeahead
activeIndex={typeaheadActiveIndex}
items={typeaheadItems}
loading={typeaheadSearching}
onHoverItem={setTypeaheadActiveIndex}
onSelectItem={navigateToTypeaheadItem}
query={trimmedNavSearchQuery}
/>
) : null}
</div>
<nav className="navbar-top-links" aria-label="Primary">
{isSoulMode ? (
@ -305,7 +468,7 @@ export default function Header() {
<input
className="navbar-search-input"
type="text"
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => setNavSearchQuery(e.target.value)}
autoFocus
@ -317,6 +480,167 @@ export default function Header() {
);
}
function SearchTypeahead({
activeIndex,
items,
loading,
onHoverItem,
onSelectItem,
query,
}: {
activeIndex: number;
items: TypeaheadItem[];
loading: boolean;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
query: string;
}) {
const skillItems = items.filter((item) => item.kind === "skill");
const pluginItems = items.filter((item) => item.kind === "plugin");
const footerItems = items.filter((item) => item.kind === "footer");
const skillsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "skills",
);
const pluginsFooter = footerItems.find(
(item) => item.kind === "footer" && item.section === "plugins",
);
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
return (
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Skills"
sectionItems={skillItems}
footer={skillsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
<TypeaheadSection
activeIndex={activeIndex}
items={items}
label="Plugins"
sectionItems={pluginItems}
footer={pluginsFooter}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
{loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">Searching...</div>
) : null}
{!loading && !hasMatches ? (
<div className="navbar-search-typeahead-status">
No skills or plugins found for "{query}"
</div>
) : null}
</div>
);
}
function TypeaheadSection({
activeIndex,
footer,
items,
label,
onHoverItem,
onSelectItem,
sectionItems,
}: {
activeIndex: number;
footer: TypeaheadItem | undefined;
items: TypeaheadItem[];
label: string;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
sectionItems: TypeaheadItem[];
}) {
if (sectionItems.length === 0 && !footer) return null;
return (
<div className="navbar-search-typeahead-section">
<div className="navbar-search-typeahead-heading">{label}</div>
{sectionItems.map((item) => (
<TypeaheadRow
key={item.key}
active={items[activeIndex]?.key === item.key}
item={item}
index={items.findIndex((candidate) => candidate.key === item.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
))}
{footer ? (
<TypeaheadRow
active={items[activeIndex]?.key === footer.key}
item={footer}
index={items.findIndex((candidate) => candidate.key === footer.key)}
onHoverItem={onHoverItem}
onSelectItem={onSelectItem}
/>
) : null}
</div>
);
}
function TypeaheadRow({
active,
index,
item,
onHoverItem,
onSelectItem,
}: {
active: boolean;
index: number;
item: TypeaheadItem;
onHoverItem: (index: number) => void;
onSelectItem: (item: TypeaheadItem) => void;
}) {
const body = getTypeaheadRowBody(item);
return (
<button
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => onHoverItem(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelectItem(item)}
>
{body.icon ? <span className="navbar-search-typeahead-icon">{body.icon}</span> : null}
<span className="navbar-search-typeahead-copy">
<span className="navbar-search-typeahead-title">{body.title}</span>
{body.meta ? <span className="navbar-search-typeahead-meta">{body.meta}</span> : null}
</span>
{item.kind === "footer" ? <ArrowRight size={14} aria-hidden="true" /> : null}
</button>
);
}
function getTypeaheadRowBody(item: TypeaheadItem) {
if (item.kind === "skill") {
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
return {
icon: "S",
title: item.result.skill.displayName,
meta: `${owner} / ${item.result.skill.slug}`,
};
}
if (item.kind === "plugin") {
return {
icon: "P",
title: item.result.plugin.displayName,
meta: item.result.plugin.ownerHandle
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
: item.result.plugin.name,
};
}
return {
icon: null,
title: item.label,
meta: null,
};
}
function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;

View File

@ -1,9 +1,9 @@
import { useAction } from "convex/react";
import { useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
import type { PublicUser } from "./publicUser";
export type UnifiedSearchType = "all" | "skills" | "plugins";
export type UnifiedSkillResult = {
type: "skill";
@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
plugin: PackageListItem;
};
export type UnifiedUserResult = {
type: "user";
user: PublicUser;
};
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
type UnifiedSearchOptions = {
debounceMs?: number;
enabled?: boolean;
limits?: {
skills?: number;
plugins?: number;
};
};
export function useUnifiedSearch(
query: string,
activeType: "all" | "skills" | "plugins" | "users",
activeType: UnifiedSearchType,
options: UnifiedSearchOptions = {},
) {
const searchSkills = useAction(api.search.searchSkills);
const [results, setResults] = useState<UnifiedResult[]>([]);
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
const [skillCount, setSkillCount] = useState(0);
const [pluginCount, setPluginCount] = useState(0);
const [userCount, setUserCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const requestRef = useRef(0);
const debounceMs = options.debounceMs ?? 300;
const enabled = options.enabled ?? true;
const skillLimit = options.limits?.skills ?? 25;
const pluginLimit = options.limits?.plugins ?? 25;
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
if (!enabled || !trimmed) {
requestRef.current += 1;
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return () => {};
}
@ -65,40 +76,34 @@ export function useUnifiedSearch(
const handle = window.setTimeout(() => {
void (async () => {
try {
const promises: [
Promise<unknown> | null,
Promise<{ items: PackageListItem[] }> | null,
Promise<{ items: PublicUser[] }> | null,
] = [null, null, null];
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
[null, null];
if (activeType === "all" || activeType === "skills") {
promises[0] = searchSkills({
query: trimmed,
limit: 25,
limit: skillLimit,
nonSuspiciousOnly: true,
});
}
if (activeType === "all" || activeType === "plugins") {
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
}
if (activeType === "all" || activeType === "users") {
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
}
const settled = await Promise.allSettled(
promises.map((p) => p ?? Promise.resolve(null)),
);
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
if (requestId !== requestRef.current) return;
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
const skillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
const nextSkillResults: UnifiedSkillResult[] = (
(skillsRaw as Array<{
skill: UnifiedSkillResult["skill"];
ownerHandle: string | null;
score: number;
}>) ?? []
).map((entry) => ({
type: "skill" as const,
skill: entry.skill,
@ -106,32 +111,25 @@ export function useUnifiedSearch(
score: entry.score,
}));
const pluginResults: UnifiedPluginResult[] = (
const nextPluginResults: UnifiedPluginResult[] = (
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
).map((item) => ({
type: "plugin" as const,
plugin: item,
}));
setSkillCount(skillResults.length);
setPluginCount(pluginResults.length);
const userResults: UnifiedUserResult[] = (
(usersRaw as { items: PublicUser[] })?.items ?? []
).map((user) => ({
type: "user" as const,
user,
}));
setUserCount(userResults.length);
setSkillCount(nextSkillResults.length);
setPluginCount(nextPluginResults.length);
setSkillResults(nextSkillResults);
setPluginResults(nextPluginResults);
const merged: UnifiedResult[] = [];
if (activeType === "all") {
merged.push(...skillResults, ...pluginResults, ...userResults);
merged.push(...nextSkillResults, ...nextPluginResults);
} else if (activeType === "skills") {
merged.push(...skillResults);
} else if (activeType === "plugins") {
merged.push(...pluginResults);
merged.push(...nextSkillResults);
} else {
merged.push(...userResults);
merged.push(...nextPluginResults);
}
setResults(merged);
@ -139,9 +137,10 @@ export function useUnifiedSearch(
console.error("Unified search failed:", error);
if (requestId === requestRef.current) {
setResults([]);
setSkillResults([]);
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setUserCount(0);
}
} finally {
if (requestId === requestRef.current) {
@ -149,10 +148,10 @@ export function useUnifiedSearch(
}
}
})();
}, 300);
}, debounceMs);
return () => window.clearTimeout(handle);
}, [query, activeType, searchSkills]);
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
return { results, skillCount, pluginCount, userCount, isSearching };
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
}

View File

@ -1,6 +1,5 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useAction, useQuery } from "convex/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
@ -12,6 +11,7 @@ import {
Star,
Users,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
@ -28,13 +28,6 @@ function Home() {
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
}
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
const SLOT_WORDS = [
"Equip", "Install", "Unleash", "Ship", "Build",
"Create", "Deploy", "Launch", "Hack", "Scale",
"Forge", "Craft", "Wield",
];
function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill;
@ -104,262 +97,8 @@ function SkillsHome() {
// Build carousel cards from highlighted data
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
// ═══ SLOT MACHINE EASTER EGG ═══
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
const clickTimesRef = useRef<number[]>([]);
const [slotState, setSlotState] = useState<
| null
| { phase: "spinning" }
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
>(null);
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
const confettiRef = useRef<HTMLCanvasElement>(null);
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cooldownUntilRef = useRef<number>(0);
// Clean up timers/intervals if the component unmounts mid-spin
useEffect(() => {
return () => {
for (const t of slotTimersRef.current) clearTimeout(t);
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
};
}, []);
const triggerSlots = useCallback(() => {
// Clean up any previous timers
for (const t of slotTimersRef.current) clearTimeout(t);
slotTimersRef.current = [];
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
setSlotState({ phase: "spinning" });
setStoppedReels(new Set());
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
let r0: number, r1: number, r2: number;
const isJackpot = Math.random() < 1 / 25;
if (isJackpot) {
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
const isHack = Math.random() < 0.25;
if (isHack) {
r0 = HACK_INDEX;
} else {
// Pick any word except Hack
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
if (idx >= HACK_INDEX) idx++;
r0 = idx;
}
r1 = r0;
r2 = r0;
} else {
// Normal spin — re-roll if accidental triple match
do {
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
} while (r0 === r1 && r1 === r2);
}
const results: [number, number, number] = [r0, r1, r2];
const landed = new Set<number>();
// Animate fast offset cycling — only cycle reels that haven't landed
let frame = 0;
const spinInterval = setInterval(() => {
frame++;
setSlotReelOffsets((prev) => [
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
]);
}, 60);
spinIntervalRef.current = spinInterval;
// Stop reels sequentially with a satisfying stagger
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
const t = setTimeout(() => {
landed.add(reelIdx);
setStoppedReels((prev) => new Set(prev).add(reelIdx));
setSlotReelOffsets((prev) => {
const next = [...prev] as [number, number, number];
next[reelIdx] = results[reelIdx];
return next;
});
}, delay);
slotTimersRef.current.push(t);
};
stopReel(0, 1200);
stopReel(1, 1800);
const tFinal = setTimeout(() => {
clearInterval(spinInterval);
spinIntervalRef.current = null;
landed.add(2);
setStoppedReels(new Set([0, 1, 2]));
setSlotReelOffsets(results);
const won = r0 === r1 && r1 === r2;
const isHackJackpot = won && r0 === HACK_INDEX;
setSlotState({ phase: "stopped", results, won, isHackJackpot });
if (won) {
fireConfetti(isHackJackpot);
}
// Cooldown: 18s after win, 3s after loss
const displayTime = won ? 10000 : 2400;
const cooldownTime = won ? 18000 : 3000;
cooldownUntilRef.current = Date.now() + cooldownTime;
const tReset = setTimeout(() => {
setSlotState(null);
setStoppedReels(new Set());
}, displayTime);
slotTimersRef.current.push(tReset);
}, 2400);
slotTimersRef.current.push(tFinal);
}, []);
const handleLabelClick = useCallback(() => {
const now = Date.now();
// Respect cooldown period
if (now < cooldownUntilRef.current) return;
clickTimesRef.current.push(now);
// Keep only last 3 clicks
if (clickTimesRef.current.length > 3) {
clickTimesRef.current = clickTimesRef.current.slice(-3);
}
if (clickTimesRef.current.length === 3) {
const first = clickTimesRef.current[0];
const last = clickTimesRef.current[2];
if (last - first < 800 && !slotState) {
clickTimesRef.current = [];
triggerSlots();
}
}
}, [slotState, triggerSlots]);
const fireConfetti = (isHackJackpot: boolean) => {
const canvas = confettiRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.display = "block";
const STANDARD_COLORS = [
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
];
const OCEAN_COLORS = [
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
"#d4453a", "#ff6b6b",
];
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
type Particle = {
x: number; y: number; vx: number; vy: number;
w: number; h: number; color: string; rot: number; vr: number;
life: number; shape: "rect" | "bubble" | "claw";
};
const particles: Particle[] = [];
const count = isHackJackpot ? 200 : 150;
for (let i = 0; i < count; i++) {
const isBubble = isHackJackpot && Math.random() < 0.35;
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
particles.push({
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
y: canvas.height * 0.35,
vx: (Math.random() - 0.5) * 18,
vy: isHackJackpot
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
: -Math.random() * 16 - 4,
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
h: isBubble ? 0 : Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.3,
life: isHackJackpot ? 1.3 : 1,
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
});
}
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
// Simple lobster claw shape
context.beginPath();
context.moveTo(0, size * 0.5);
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
context.closePath();
context.fill();
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
for (const p of particles) {
if (p.life <= 0) continue;
alive = true;
p.x += p.vx;
p.y += p.vy;
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
p.vx *= 0.99;
p.rot += p.vr;
p.life -= isHackJackpot ? 0.005 : 0.008;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.fillStyle = p.color;
if (p.shape === "bubble") {
ctx.beginPath();
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
ctx.strokeStyle = p.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha *= 0.7;
ctx.stroke();
ctx.globalAlpha *= 0.15;
ctx.fill();
} else if (p.shape === "claw") {
drawClaw(ctx, p.w);
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
if (alive) {
requestAnimationFrame(draw);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
}
};
requestAnimationFrame(draw);
};
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
const offset = slotReelOffsets[reelIdx];
const word = SLOT_WORDS[offset];
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
return (
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
<span className="home-v2-slot-word">{word}</span>
</span>
);
};
return (
<main className="home-v2-main">
{/* Confetti canvas for slot machine wins */}
<canvas
ref={confettiRef}
className="home-v2-confetti"
style={{ display: "none" }}
/>
{/* ═══ HERO ═══ */}
<section className="home-v2-hero">
<div className="home-v2-hero-bg">
@ -370,81 +109,41 @@ function SkillsHome() {
<div className="home-v2-ring home-v2-ring-3" />
</div>
<div
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
onClick={handleLabelClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
>
BUILT BY THE COMMUNITY.
</div>
{slotState ? (
<h1 className={`home-v2-headline home-v2-headline-slots${
slotState.phase === "stopped" && slotState.won
? slotState.isHackJackpot
? " home-v2-headline-jackpot home-v2-headline-hack"
: " home-v2-headline-jackpot"
: ""
}`}>
{slotState.phase === "stopped" && slotState.isHackJackpot && (
<img
src="/clawd-mark.png"
alt=""
aria-hidden="true"
className="home-v2-hack-lobster"
/>
)}
<span className="home-v2-headline-inner">
{renderSlotReel(0)}
<span className="home-v2-sep" />
{renderSlotReel(1)}
<span className="home-v2-sep" />
{renderSlotReel(2)}
</span>
</h1>
) : (
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
</span>
<h1 className="home-v2-headline">
<span className="home-v2-headline-inner">
<span className="home-v2-action-word">Equip</span>
<span className="home-v2-sep" />
<span className="home-v2-action-word">Install</span>
<span className="home-v2-sep" />
<span className="home-v2-cycle-wrap">
<span className="home-v2-cycle-track">
<span className="home-v2-cycle-word">Unleash.</span>
<span className="home-v2-cycle-word">Ship.</span>
<span className="home-v2-cycle-word">Build.</span>
<span className="home-v2-cycle-word">Create.</span>
<span className="home-v2-cycle-word">Unleash.</span>
</span>
</span>
</h1>
)}
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
</span>
</h1>
<div className="home-v2-search-container">
<form className="home-v2-search-bar" onSubmit={handleSearch}>
<Search className="home-v2-search-icon" size={20} />
<input
autoFocus
type="text"
placeholder="What are you looking for?"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<kbd>/</kbd>
<button type="submit" className="home-v2-search-go">
<span className="home-v2-search-go-label">Search</span>{" "}
<ArrowRight size={16} />
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
</button>
</form>
</div>
<div className="home-v2-suggestions">
<span className="home-v2-suggestions-label">Try</span>
<button
type="button"
className="home-v2-suggestion"
@ -516,12 +215,10 @@ function SkillsHome() {
<div className="home-v2-c-footer">
<div className="home-v2-c-stats">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-c-install">
@ -554,12 +251,10 @@ function SkillsHome() {
<div className="home-v2-c-footer">
<div className="home-v2-c-stats">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-c-install">
@ -673,11 +368,7 @@ function SkillsHome() {
</div>
<div className="home-v2-trending-grid">
{popular.slice(0, 6).map((entry) => (
<Link
key={entry.skill._id}
to={skillLink(entry)}
className="home-v2-trend-card"
>
<Link key={entry.skill._id} to={skillLink(entry)} className="home-v2-trend-card">
<div className="home-v2-trend-head">
<div className="home-v2-trend-title">
{entry.skill.displayName || entry.skill.slug}
@ -692,12 +383,10 @@ function SkillsHome() {
<div className="home-v2-trend-bottom">
<div className="home-v2-trend-signals">
<span>
<Star size={12} />{" "}
{formatStat(entry.skill.stats?.stars)}
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} />{" "}
{formatStat(entry.skill.stats?.downloads)}
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-trend-install">

View File

@ -3,28 +3,24 @@ import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { PluginListItem } from "../components/PluginListItem";
import { SkillListItem } from "../components/SkillListItem";
import { UserListItem } from "../components/UserListItem";
import { Card } from "../components/ui/card";
import type { PublicSkill, PublicUser } from "../lib/publicUser";
import type { PublicSkill } from "../lib/publicUser";
import {
useUnifiedSearch,
type UnifiedSearchType,
type UnifiedPluginResult,
type UnifiedSkillResult,
type UnifiedUserResult,
} from "../lib/useUnifiedSearch";
type SearchState = {
q?: string;
type?: "all" | "skills" | "plugins" | "users";
type?: UnifiedSearchType;
};
export const Route = createFileRoute("/search")({
validateSearch: (search): SearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
type:
search.type === "skills" || search.type === "plugins" || search.type === "users"
? search.type
: undefined,
type: search.type === "skills" || search.type === "plugins" ? search.type : undefined,
}),
component: UnifiedSearchPage,
});
@ -39,7 +35,7 @@ function UnifiedSearchPage() {
setQuery(search.q ?? "");
}, [search.q]);
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
search.q ?? "",
activeType,
);
@ -52,7 +48,7 @@ function UnifiedSearchPage() {
});
};
const setType = (type: "all" | "skills" | "plugins" | "users") => {
const setType = (type: UnifiedSearchType) => {
void navigate({
to: "/search",
search: { q: search.q, type: type === "all" ? undefined : type },
@ -74,11 +70,11 @@ function UnifiedSearchPage() {
<form className="search-page-form" onSubmit={handleSearch}>
<div className="browse-search-bar max-w-[560px] flex-1">
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
type="text"
placeholder="Search skills, plugins, users..."
placeholder="Search skills and plugins..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
@ -100,9 +96,7 @@ function UnifiedSearchPage() {
onClick={() => setType("skills")}
>
Skills
{skillCount > 0 ? (
<span className="search-tab-count">{skillCount}</span>
) : null}
{skillCount > 0 ? <span className="search-tab-count">{skillCount}</span> : null}
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
@ -110,17 +104,7 @@ function UnifiedSearchPage() {
onClick={() => setType("plugins")}
>
Plugins
{pluginCount > 0 ? (
<span className="search-tab-count">{pluginCount}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setType("users")}
>
Users
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
{pluginCount > 0 ? <span className="search-tab-count">{pluginCount}</span> : null}
</button>
</div>
@ -130,9 +114,7 @@ function UnifiedSearchPage() {
</Card>
) : !search.q ? (
<Card className="text-center p-10">
<p className="text-ink-soft">
Enter a search term to find skills, plugins, and users
</p>
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
</Card>
) : results.length === 0 ? (
<Card className="text-center p-10">
@ -143,10 +125,8 @@ function UnifiedSearchPage() {
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : item.type === "plugin" ? (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
) : (
<UserResultRow key={`user-${item.user._id}`} result={item} />
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
@ -157,19 +137,9 @@ function UnifiedSearchPage() {
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
const skill = result.skill as unknown as PublicSkill;
return (
<SkillListItem
skill={skill}
ownerHandle={result.ownerHandle}
/>
);
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
}
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
return <PluginListItem item={result.plugin} />;
}
function UserResultRow({ result }: { result: UnifiedUserResult }) {
const user = result.user as PublicUser;
return <UserListItem user={user} />;
}

View File

@ -64,14 +64,23 @@ function SoulsHoldingPage() {
</div>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
<Link
to="/skills"
search={{
q: undefined,
sort: "downloads",
dir: "desc",
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
>
Browse Skills
</Link>
</Button>
<Button asChild>
<Link to="/users" search={{ q: undefined }}>
Browse Users
</Link>
<Link to="/users">Browse Users</Link>
</Button>
</div>
</section>

View File

@ -1,38 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { UserListItem } from "../../components/UserListItem";
import { Card } from "../../components/ui/card";
import { UserListItem } from "../../components/UserListItem";
import { convexHttp } from "../../convex/client";
import type { PublicUser } from "../../lib/publicUser";
type UserSearchState = {
q?: string;
};
type UsersLoaderResult = { items: PublicUser[]; total: number };
export const Route = createFileRoute("/users/")({
validateSearch: (search): UserSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
}),
component: UsersIndex,
});
function UsersIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const [query, setQuery] = useState(search.q ?? "");
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
const [loading, setLoading] = useState(true);
const fetchUsers = useCallback(async (q?: string) => {
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const data = await convexHttp.query(api.users.listPublic, {
limit: 48,
search: q,
});
setResult(data as UsersLoaderResult);
} finally {
@ -41,9 +29,8 @@ function UsersIndex() {
}, []);
useEffect(() => {
setQuery(search.q ?? "");
void fetchUsers(search.q);
}, [search.q, fetchUsers]);
void fetchUsers();
}, [fetchUsers]);
const users = result?.items ?? [];
@ -57,25 +44,6 @@ function UsersIndex() {
) : null}
</h1>
</div>
<form
className="browse-page-search"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: {
q: query.trim() || undefined,
},
});
}}
>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
className="browse-search-input"
placeholder="Search users..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
<div className="browse-results">
<div className="browse-results-toolbar">
@ -90,8 +58,7 @@ function UsersIndex() {
</Card>
) : users.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No users found</p>
<p className="empty-state-body">Try a different handle or name.</p>
<p className="empty-state-title">No users yet</p>
</div>
) : (
<div className="results-list">

View File

@ -775,6 +775,11 @@ code {
box-shadow 0.15s ease;
}
.navbar-search-wrap {
position: relative;
min-width: 0;
}
.navbar-search:focus-within {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px var(--input-focus-ring);
@ -807,6 +812,98 @@ code {
opacity: 0.7;
}
.navbar-search-typeahead {
position: absolute;
z-index: 50;
top: calc(100% + 8px);
left: 0;
right: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
box-shadow: var(--shadow-lg);
}
.navbar-search-typeahead-section + .navbar-search-typeahead-section {
border-top: 1px solid var(--line);
}
.navbar-search-typeahead-heading {
padding: 8px 12px;
background: var(--surface-muted);
color: var(--ink);
font-size: var(--fs-sm);
font-weight: 700;
}
.navbar-search-typeahead-row {
all: unset;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 8px 12px;
border-top: 1px solid var(--line);
color: var(--ink);
cursor: pointer;
}
.navbar-search-typeahead-row:hover,
.navbar-search-typeahead-row.is-active {
background: var(--surface-muted);
}
.navbar-search-typeahead-row.is-footer {
color: var(--ink-soft);
}
.navbar-search-typeahead-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
font-size: var(--fs-xs);
font-weight: 700;
flex: 0 0 auto;
}
.navbar-search-typeahead-copy {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.navbar-search-typeahead-title,
.navbar-search-typeahead-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-search-typeahead-title {
font-size: var(--fs-sm);
font-weight: 600;
}
.navbar-search-typeahead-meta {
color: var(--ink-soft);
font-size: var(--fs-xs);
}
.navbar-search-typeahead-status {
padding: 12px;
color: var(--ink-soft);
font-size: var(--fs-sm);
}
.navbar-search-home {
justify-content: space-between;
cursor: pointer;
@ -8510,21 +8607,6 @@ code {
}
/* Headline */
.home-v2-hero-label {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--hv2-text-tertiary);
margin-bottom: 20px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition:
color 0.2s,
text-shadow 0.3s;
}
.home-v2-headline {
font-family: "Inter", sans-serif;
font-weight: 700;
@ -8613,159 +8695,6 @@ code {
}
}
/* ═══ SLOT MACHINE EASTER EGG ═══ */
.home-v2-hero-label:hover {
color: var(--hv2-accent);
}
.home-v2-hero-label-active {
color: var(--hv2-accent) !important;
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
}
@keyframes home-v2-labelPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.home-v2-headline-slots {
min-height: 1.15em;
position: relative;
}
.home-v2-slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 180px;
text-align: center;
font-family: "JetBrains Mono", monospace;
font-weight: 800;
color: var(--hv2-accent);
position: relative;
}
.home-v2-slot-reel.spinning .home-v2-slot-word {
animation: home-v2-slotBlur 0.12s steps(1) infinite;
}
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes home-v2-slotBlur {
0% {
filter: blur(0px);
opacity: 1;
}
50% {
filter: blur(1px);
opacity: 0.7;
}
100% {
filter: blur(0px);
opacity: 1;
}
}
@keyframes home-v2-slotLand {
0% {
transform: translateY(-8px) scale(1.1);
opacity: 0.5;
}
60% {
transform: translateY(2px) scale(0.98);
}
100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.home-v2-headline-jackpot {
animation: home-v2-jackpot 0.5s ease-out;
}
@keyframes home-v2-jackpot {
0% {
transform: scale(1);
}
30% {
transform: scale(1.08);
}
60% {
transform: scale(0.97);
}
100% {
transform: scale(1);
}
}
.home-v2-headline-jackpot .home-v2-slot-word {
color: #ffd93d !important;
text-shadow:
0 0 20px rgba(255, 217, 61, 0.6),
0 0 40px rgba(255, 217, 61, 0.3);
}
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
.home-v2-headline-hack .home-v2-slot-word {
color: #22d3ee !important;
text-shadow:
0 0 24px rgba(34, 211, 238, 0.6),
0 0 48px rgba(6, 182, 212, 0.3),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
.home-v2-headline-hack .home-v2-sep {
border-color: #22d3ee;
opacity: 0.8;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
}
.home-v2-hack-lobster {
position: absolute;
top: 50%;
left: 50%;
width: 280px;
height: 280px;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5))
drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
z-index: -1;
}
@keyframes home-v2-lobsterReveal {
0% {
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
opacity: 0;
}
50% {
opacity: 0.18;
}
100% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 0.12;
}
}
.home-v2-confetti {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}
.home-v2-sub {
color: var(--hv2-text-tertiary);
font-size: 16px;
line-height: 1.5;
margin-bottom: 36px;
max-width: 580px;
font-weight: 400;
}
.home-v2-sub-clear {
color: var(--hv2-text-secondary);
max-width: 820px;
margin-bottom: 18px;
}
.home-v2-motto {
display: flex;
flex-direction: column;
@ -8830,16 +8759,6 @@ code {
.home-v2-search-bar input::placeholder {
color: var(--hv2-text-tertiary);
}
.home-v2-search-bar kbd {
font-family: "Inter", sans-serif;
font-size: 11px;
background: transparent;
border: 1px solid var(--hv2-border);
border-radius: 5px;
padding: 2px 7px;
color: var(--hv2-text-tertiary);
flex-shrink: 0;
}
.home-v2-search-go {
background: var(--hv2-accent-fill);
color: var(--hv2-accent);
@ -8877,12 +8796,6 @@ code {
margin-top: 16px;
flex-wrap: wrap;
}
.home-v2-suggestions-label {
color: var(--hv2-text-secondary);
font-size: 13px;
font-weight: 500;
margin-right: 4px;
}
.home-v2-suggestion {
display: flex;
align-items: center;
@ -8918,11 +8831,6 @@ code {
margin-top: 12px;
}
.home-v2-suggestions-label {
font-size: 12px;
margin-right: 2px;
}
.home-v2-suggestion {
font-size: 12px;
padding: 5px 10px;
@ -9714,11 +9622,6 @@ code {
[data-theme-resolved="light"] .home-v2-search-bar input::placeholder {
color: #9c8b7a;
}
[data-theme-resolved="light"] .home-v2-search-bar kbd {
border-color: rgba(170, 125, 80, 0.2);
color: #9c8b7a;
}
/* Light — search button */
[data-theme-resolved="light"] .home-v2-search-go {
background: rgba(196, 58, 47, 0.06);
@ -9901,9 +9804,6 @@ code {
.home-v2-search-go {
border-radius: 8px;
}
.home-v2-search-bar kbd {
border-radius: 8px;
}
.home-v2-c-icon {
border-radius: 8px;
}