mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix: preload URL skill search results
Preload URL-query skill search results for /skills and /search, seed the client hooks from loader data, and skip duplicate loader-backed skill searches after hydration while preserving pagination state.
This commit is contained in:
@@ -6,17 +6,20 @@ export const convexReactMocks = {
|
||||
};
|
||||
|
||||
export const convexHttpMock = {
|
||||
action: vi.fn(),
|
||||
query: vi.fn(),
|
||||
};
|
||||
|
||||
export function resetConvexReactMocks() {
|
||||
convexReactMocks.useAction.mockReset();
|
||||
convexReactMocks.useQuery.mockReset();
|
||||
convexHttpMock.action.mockReset();
|
||||
convexHttpMock.query.mockReset();
|
||||
}
|
||||
|
||||
export function setupDefaultConvexReactMocks() {
|
||||
convexReactMocks.useAction.mockReturnValue(() => Promise.resolve([]));
|
||||
convexReactMocks.useQuery.mockReturnValue(null);
|
||||
convexHttpMock.action.mockResolvedValue([]);
|
||||
convexHttpMock.query.mockResolvedValue({ page: [], hasMore: false, nextCursor: null });
|
||||
}
|
||||
|
||||
@@ -9,16 +9,24 @@ let searchMock: {
|
||||
q?: string;
|
||||
type?: "all" | "skills" | "plugins" | "creators";
|
||||
} = {};
|
||||
let loaderDataMock: unknown = null;
|
||||
const useUnifiedSearchMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useLoaderData: () => loaderDataMock,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: {
|
||||
action: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: (...args: unknown[]) => useUnifiedSearchMock(...args),
|
||||
}));
|
||||
@@ -67,6 +75,7 @@ async function loadRoute() {
|
||||
describe("search route", () => {
|
||||
beforeEach(() => {
|
||||
searchMock = { q: "first" };
|
||||
loaderDataMock = null;
|
||||
navigateMock.mockReset();
|
||||
useUnifiedSearchMock.mockReset();
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
@@ -469,6 +478,45 @@ describe("search route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes loader-backed skill results to unified search", async () => {
|
||||
searchMock = { q: "japanese-reading-grader" };
|
||||
loaderDataMock = {
|
||||
query: "japanese-reading-grader",
|
||||
activeType: "all",
|
||||
limits: { skills: 25, plugins: 25, creators: 25 },
|
||||
skillResults: [
|
||||
{
|
||||
type: "skill",
|
||||
skill: {
|
||||
_id: "skill-japanese-reading-grader",
|
||||
slug: "japanese-reading-grader",
|
||||
displayName: "Japanese Reading Grader",
|
||||
ownerUserId: "users:1",
|
||||
stats: { downloads: 0, stars: 0 },
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
ownerHandle: "bianmaxingkong",
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
pluginResults: [],
|
||||
creatorResults: [],
|
||||
skillHasMore: false,
|
||||
pluginHasMore: false,
|
||||
creatorHasMore: false,
|
||||
};
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("japanese-reading-grader", "all", {
|
||||
initialData: loaderDataMock,
|
||||
limits: { skills: 25, plugins: 25, creators: 25 },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders creators as their own all-results section and active tab", async () => {
|
||||
searchMock = { q: "weather" };
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
|
||||
@@ -15,6 +15,7 @@ let searchMock: Record<string, unknown> = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (_config: { component: unknown; validateSearch: unknown }) => ({
|
||||
useLoaderData: () => null,
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
@@ -32,6 +33,7 @@ vi.mock("convex/react", () => ({
|
||||
|
||||
vi.mock("../../src/convex/client", () => ({
|
||||
convexHttp: {
|
||||
action: (...args: unknown[]) => convexHttpMock.action(...args),
|
||||
query: (...args: unknown[]) => convexHttpMock.query(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: Record<string, unknown> = {};
|
||||
let loaderDataMock: unknown = null;
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component: unknown; validateSearch: unknown }) => ({
|
||||
__config: config,
|
||||
useLoaderData: () => loaderDataMock,
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
@@ -33,6 +35,7 @@ vi.mock("convex/react", () => ({
|
||||
|
||||
vi.mock("../../src/convex/client", () => ({
|
||||
convexHttp: {
|
||||
action: (...args: unknown[]) => convexHttpMock.action(...args),
|
||||
query: (...args: unknown[]) => convexHttpMock.query(...args),
|
||||
},
|
||||
}));
|
||||
@@ -42,6 +45,7 @@ describe("SkillsIndex", () => {
|
||||
resetConvexReactMocks();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
loaderDataMock = null;
|
||||
setupDefaultConvexReactMocks();
|
||||
});
|
||||
|
||||
@@ -338,6 +342,32 @@ describe("SkillsIndex", () => {
|
||||
expect(screen.queryByText(/Loading skills/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders URL-query skill search results from loader data without a duplicate refresh", async () => {
|
||||
searchMock = { q: "japanese-conversation-scorer" };
|
||||
loaderDataMock = {
|
||||
key: "japanese-conversation-scorer::0::::",
|
||||
limit: 25,
|
||||
results: [
|
||||
{
|
||||
skill: makeListResult("japanese-conversation-scorer", "Japanese Conversation Scorer")
|
||||
.skill,
|
||||
version: null,
|
||||
ownerHandle: "bianmaxingkong",
|
||||
owner: null,
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
const actionFn = vi.fn().mockResolvedValue([]);
|
||||
convexReactMocks.useAction.mockReturnValue(actionFn);
|
||||
|
||||
render(<SkillsIndex />);
|
||||
|
||||
expect(screen.getByText("Japanese Conversation Scorer")).toBeTruthy();
|
||||
expect(screen.queryByText("No skills found")).toBeNull();
|
||||
expect(actionFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips list fetch and calls search when query is set", async () => {
|
||||
searchMock = { q: "remind" };
|
||||
const actionFn = vi.fn().mockResolvedValue([]);
|
||||
|
||||
@@ -81,6 +81,51 @@ describe("useUnifiedSearch", () => {
|
||||
convexQueryMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses matching loader data without repeating the initial skill search", async () => {
|
||||
searchSkillsMock.mockResolvedValue([]);
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
|
||||
convexQueryMock.mockResolvedValue({ page: [], continueCursor: null, isDone: true });
|
||||
|
||||
const initialData = {
|
||||
query: "japanese-reading-grader",
|
||||
activeType: "all" as const,
|
||||
limits: { skills: 25, plugins: 25, creators: 25 },
|
||||
skillResults: [
|
||||
{
|
||||
type: "skill" as const,
|
||||
...makeSkill("japanese-reading-grader"),
|
||||
},
|
||||
],
|
||||
pluginResults: [],
|
||||
creatorResults: [],
|
||||
skillHasMore: true,
|
||||
pluginHasMore: false,
|
||||
creatorHasMore: false,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUnifiedSearch("japanese-reading-grader", "all", {
|
||||
initialData,
|
||||
debounceMs: 0,
|
||||
limits: { skills: 25, plugins: 25, creators: 25 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.skillResults.map((entry) => entry.skill.slug)).toEqual([
|
||||
"japanese-reading-grader",
|
||||
]);
|
||||
expect(result.current.results.map((entry) => entry.type)).toEqual(["skill"]);
|
||||
expect(result.current.skillHasMore).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalled();
|
||||
expect(convexQueryMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(searchSkillsMock).not.toHaveBeenCalled();
|
||||
expect(result.current.skillHasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("requests one extra result and exposes hasMore without inflating counts", async () => {
|
||||
searchSkillsMock.mockResolvedValue([makeSkill("one"), makeSkill("two"), makeSkill("three")]);
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
|
||||
+152
-47
@@ -43,9 +43,26 @@ export type UnifiedCreatorResult = {
|
||||
|
||||
type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedCreatorResult;
|
||||
|
||||
export type UnifiedSearchInitialData = {
|
||||
query: string;
|
||||
activeType: UnifiedSearchType;
|
||||
limits: {
|
||||
skills: number;
|
||||
plugins: number;
|
||||
creators: number;
|
||||
};
|
||||
skillResults: UnifiedSkillResult[];
|
||||
pluginResults: UnifiedPluginResult[];
|
||||
creatorResults: UnifiedCreatorResult[];
|
||||
skillHasMore: boolean;
|
||||
pluginHasMore: boolean;
|
||||
creatorHasMore: boolean;
|
||||
};
|
||||
|
||||
type UnifiedSearchOptions = {
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
initialData?: UnifiedSearchInitialData | null;
|
||||
limits?: {
|
||||
skills?: number;
|
||||
plugins?: number;
|
||||
@@ -53,26 +70,35 @@ type UnifiedSearchOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
function mergeUnifiedResults(
|
||||
activeType: UnifiedSearchType,
|
||||
skillResults: UnifiedSkillResult[],
|
||||
pluginResults: UnifiedPluginResult[],
|
||||
creatorResults: UnifiedCreatorResult[],
|
||||
) {
|
||||
const merged: UnifiedResult[] = [];
|
||||
if (activeType === "all") {
|
||||
merged.push(...skillResults, ...pluginResults, ...creatorResults);
|
||||
} else if (activeType === "skills") {
|
||||
merged.push(...skillResults);
|
||||
} else if (activeType === "plugins") {
|
||||
merged.push(...pluginResults);
|
||||
} else {
|
||||
merged.push(...creatorResults);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function useUnifiedSearch(
|
||||
query: string,
|
||||
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 [creatorResults, setCreatorResults] = useState<UnifiedCreatorResult[]>([]);
|
||||
const [skillCount, setSkillCount] = useState(0);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [creatorCount, setCreatorCount] = useState(0);
|
||||
const [skillHasMore, setSkillHasMore] = useState(false);
|
||||
const [pluginHasMore, setPluginHasMore] = useState(false);
|
||||
const [creatorHasMore, setCreatorHasMore] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
const debounceMs = options.debounceMs ?? 300;
|
||||
const enabled = options.enabled ?? true;
|
||||
const initialData = options.initialData ?? null;
|
||||
const skillLimit = Math.max(0, Math.min(options.limits?.skills ?? 25, MAX_UNIFIED_SEARCH_LIMIT));
|
||||
const pluginLimit = Math.max(
|
||||
0,
|
||||
@@ -83,10 +109,77 @@ export function useUnifiedSearch(
|
||||
Math.min(options.limits?.creators ?? 25, MAX_CREATOR_SEARCH_LIMIT),
|
||||
);
|
||||
const creatorRequestLimit = Math.min(creatorLimit + 1, MAX_CREATOR_SEARCH_LIMIT);
|
||||
const trimmedQuery = query.trim();
|
||||
const matchedInitialData =
|
||||
initialData &&
|
||||
initialData.query === trimmedQuery &&
|
||||
initialData.activeType === activeType &&
|
||||
initialData.limits.skills === skillLimit &&
|
||||
initialData.limits.plugins === pluginLimit &&
|
||||
initialData.limits.creators === creatorLimit
|
||||
? initialData
|
||||
: null;
|
||||
const [results, setResults] = useState<UnifiedResult[]>(() =>
|
||||
matchedInitialData
|
||||
? mergeUnifiedResults(
|
||||
activeType,
|
||||
matchedInitialData.skillResults,
|
||||
matchedInitialData.pluginResults,
|
||||
matchedInitialData.creatorResults,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>(
|
||||
() => matchedInitialData?.skillResults ?? [],
|
||||
);
|
||||
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>(
|
||||
() => matchedInitialData?.pluginResults ?? [],
|
||||
);
|
||||
const [creatorResults, setCreatorResults] = useState<UnifiedCreatorResult[]>(
|
||||
() => matchedInitialData?.creatorResults ?? [],
|
||||
);
|
||||
const [skillCount, setSkillCount] = useState(() => matchedInitialData?.skillResults.length ?? 0);
|
||||
const [pluginCount, setPluginCount] = useState(
|
||||
() => matchedInitialData?.pluginResults.length ?? 0,
|
||||
);
|
||||
const [creatorCount, setCreatorCount] = useState(
|
||||
() => matchedInitialData?.creatorResults.length ?? 0,
|
||||
);
|
||||
const [skillHasMore, setSkillHasMore] = useState(() => matchedInitialData?.skillHasMore ?? false);
|
||||
const [pluginHasMore, setPluginHasMore] = useState(
|
||||
() => matchedInitialData?.pluginHasMore ?? false,
|
||||
);
|
||||
const [creatorHasMore, setCreatorHasMore] = useState(
|
||||
() => matchedInitialData?.creatorHasMore ?? false,
|
||||
);
|
||||
const [isSearching, setIsSearching] = useState(
|
||||
() => enabled && trimmedQuery.length > 0 && !matchedInitialData,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!enabled || !trimmed) {
|
||||
if (!matchedInitialData) return;
|
||||
setSkillResults(matchedInitialData.skillResults);
|
||||
setPluginResults(matchedInitialData.pluginResults);
|
||||
setCreatorResults(matchedInitialData.creatorResults);
|
||||
setSkillCount(matchedInitialData.skillResults.length);
|
||||
setPluginCount(matchedInitialData.pluginResults.length);
|
||||
setCreatorCount(matchedInitialData.creatorResults.length);
|
||||
setSkillHasMore(matchedInitialData.skillHasMore);
|
||||
setPluginHasMore(matchedInitialData.pluginHasMore);
|
||||
setCreatorHasMore(matchedInitialData.creatorHasMore);
|
||||
setResults(
|
||||
mergeUnifiedResults(
|
||||
activeType,
|
||||
matchedInitialData.skillResults,
|
||||
matchedInitialData.pluginResults,
|
||||
matchedInitialData.creatorResults,
|
||||
),
|
||||
);
|
||||
setIsSearching(false);
|
||||
}, [activeType, matchedInitialData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !trimmedQuery) {
|
||||
requestRef.current += 1;
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
@@ -102,6 +195,17 @@ export function useUnifiedSearch(
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const shouldFetchSkills =
|
||||
(activeType === "all" || activeType === "skills") && !matchedInitialData;
|
||||
const shouldFetchPlugins = activeType === "all" || activeType === "plugins";
|
||||
const shouldFetchCreators = activeType === "all" || activeType === "creators";
|
||||
|
||||
if (!shouldFetchSkills && !shouldFetchPlugins && !shouldFetchCreators) {
|
||||
requestRef.current += 1;
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
requestRef.current += 1;
|
||||
const requestId = requestRef.current;
|
||||
const controller = new AbortController();
|
||||
@@ -116,24 +220,24 @@ export function useUnifiedSearch(
|
||||
Promise<{ page: PublicPublisherListItem[]; isDone?: boolean }> | null,
|
||||
] = [null, null, null];
|
||||
|
||||
if (activeType === "all" || activeType === "skills") {
|
||||
if (shouldFetchSkills) {
|
||||
promises[0] = searchSkills({
|
||||
query: trimmed,
|
||||
query: trimmedQuery,
|
||||
limit: skillLimit + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
if (shouldFetchPlugins) {
|
||||
promises[1] = fetchPluginCatalog({
|
||||
q: trimmed,
|
||||
q: trimmedQuery,
|
||||
limit: pluginLimit + 1,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "creators") {
|
||||
if (shouldFetchCreators) {
|
||||
promises[2] = convexHttp.query(api.publishers.listPublicPage, {
|
||||
query: trimmed,
|
||||
query: trimmedQuery,
|
||||
paginationOpts: { cursor: null, numItems: creatorRequestLimit },
|
||||
});
|
||||
}
|
||||
@@ -146,20 +250,22 @@ export function useUnifiedSearch(
|
||||
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
|
||||
const creatorsRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
|
||||
|
||||
const skillMatches: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
owner?: PublicPublisher | null;
|
||||
score: number;
|
||||
}>) ?? []
|
||||
).map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
ownerHandle: entry.ownerHandle,
|
||||
owner: entry.owner ?? null,
|
||||
score: entry.score,
|
||||
}));
|
||||
const skillMatches: UnifiedSkillResult[] =
|
||||
matchedInitialData?.skillResults ??
|
||||
(
|
||||
(skillsRaw as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
owner?: PublicPublisher | null;
|
||||
score: number;
|
||||
}>) ?? []
|
||||
).map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
ownerHandle: entry.ownerHandle,
|
||||
owner: entry.owner ?? null,
|
||||
score: entry.score,
|
||||
}));
|
||||
const nextSkillResults = skillMatches.slice(0, skillLimit);
|
||||
|
||||
const pluginMatches: UnifiedPluginResult[] = (
|
||||
@@ -181,7 +287,9 @@ export function useUnifiedSearch(
|
||||
setSkillCount(nextSkillResults.length);
|
||||
setPluginCount(nextPluginResults.length);
|
||||
setCreatorCount(nextCreatorResults.length);
|
||||
setSkillHasMore(skillMatches.length > skillLimit);
|
||||
setSkillHasMore(
|
||||
matchedInitialData ? matchedInitialData.skillHasMore : skillMatches.length > skillLimit,
|
||||
);
|
||||
setPluginHasMore(pluginMatches.length > pluginLimit);
|
||||
setCreatorHasMore(
|
||||
creatorLimit < MAX_CREATOR_SEARCH_LIMIT &&
|
||||
@@ -192,18 +300,14 @@ export function useUnifiedSearch(
|
||||
setPluginResults(nextPluginResults);
|
||||
setCreatorResults(nextCreatorResults);
|
||||
|
||||
const merged: UnifiedResult[] = [];
|
||||
if (activeType === "all") {
|
||||
merged.push(...nextSkillResults, ...nextPluginResults, ...nextCreatorResults);
|
||||
} else if (activeType === "skills") {
|
||||
merged.push(...nextSkillResults);
|
||||
} else if (activeType === "plugins") {
|
||||
merged.push(...nextPluginResults);
|
||||
} else {
|
||||
merged.push(...nextCreatorResults);
|
||||
}
|
||||
|
||||
setResults(merged);
|
||||
setResults(
|
||||
mergeUnifiedResults(
|
||||
activeType,
|
||||
nextSkillResults,
|
||||
nextPluginResults,
|
||||
nextCreatorResults,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Unified search failed:", error);
|
||||
if (requestId === requestRef.current) {
|
||||
@@ -232,7 +336,7 @@ export function useUnifiedSearch(
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
trimmedQuery,
|
||||
activeType,
|
||||
searchSkills,
|
||||
debounceMs,
|
||||
@@ -241,6 +345,7 @@ export function useUnifiedSearch(
|
||||
pluginLimit,
|
||||
creatorLimit,
|
||||
creatorRequestLimit,
|
||||
matchedInitialData,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Plus, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { PluginListItem } from "../components/PluginListItem";
|
||||
import { PublisherListItem } from "../components/PublisherListItem";
|
||||
import { BrowseResultsSkeleton } from "../components/skeletons/BrowseResultsSkeleton";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedSearchInitialData,
|
||||
type UnifiedSearchType,
|
||||
type UnifiedCreatorResult,
|
||||
type UnifiedPluginResult,
|
||||
@@ -30,11 +33,59 @@ export const Route = createFileRoute("/search")({
|
||||
? search.type
|
||||
: undefined,
|
||||
}),
|
||||
loaderDeps: ({ search }) => ({
|
||||
q: search.q,
|
||||
}),
|
||||
loader: async ({ deps }): Promise<UnifiedSearchInitialData | null> =>
|
||||
await loadInitialSearchResults(deps.q),
|
||||
component: UnifiedSearchPage,
|
||||
});
|
||||
|
||||
async function loadInitialSearchResults(query: string | undefined) {
|
||||
const trimmed = query?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const skillsRaw = (await convexHttp.action(api.search.searchSkills, {
|
||||
query: trimmed,
|
||||
limit: SEARCH_PAGE_SIZE + 1,
|
||||
})) as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
owner?: UnifiedSkillResult["owner"];
|
||||
score: number;
|
||||
}>;
|
||||
const skillMatches = skillsRaw.map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
ownerHandle: entry.ownerHandle,
|
||||
owner: entry.owner ?? null,
|
||||
score: entry.score,
|
||||
}));
|
||||
return {
|
||||
query: trimmed,
|
||||
activeType: "all" as const,
|
||||
limits: {
|
||||
skills: SEARCH_PAGE_SIZE,
|
||||
plugins: SEARCH_PAGE_SIZE,
|
||||
creators: SEARCH_PAGE_SIZE,
|
||||
},
|
||||
skillResults: skillMatches.slice(0, SEARCH_PAGE_SIZE),
|
||||
pluginResults: [],
|
||||
creatorResults: [],
|
||||
skillHasMore: skillMatches.length > SEARCH_PAGE_SIZE,
|
||||
pluginHasMore: false,
|
||||
creatorHasMore: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial search results:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function UnifiedSearchPage() {
|
||||
const search = Route.useSearch();
|
||||
const initialSearch = Route.useLoaderData() as UnifiedSearchInitialData | null | undefined;
|
||||
const navigate = useNavigate();
|
||||
const activeType = search.type ?? "all";
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
@@ -61,6 +112,7 @@ function UnifiedSearchPage() {
|
||||
creatorHasMore,
|
||||
isSearching,
|
||||
} = useUnifiedSearch(search.q ?? "", "all", {
|
||||
...(initialSearch ? { initialData: initialSearch } : null),
|
||||
limits: {
|
||||
skills: resultLimit,
|
||||
plugins: resultLimit,
|
||||
|
||||
@@ -42,6 +42,12 @@ export type SkillsSearchState = {
|
||||
focus?: "search";
|
||||
};
|
||||
|
||||
export type InitialSkillsSearchData = {
|
||||
key: string;
|
||||
limit: number;
|
||||
results: SkillSearchEntry[];
|
||||
} | null;
|
||||
|
||||
type SkillsNavigate = (options: {
|
||||
search: (prev: SkillsSearchState) => SkillsSearchState;
|
||||
replace?: boolean;
|
||||
@@ -49,19 +55,35 @@ type SkillsNavigate = (options: {
|
||||
|
||||
type ListStatus = "loading" | "idle" | "loadingMore" | "done";
|
||||
|
||||
export function buildSkillsSearchKey({
|
||||
categorySlug,
|
||||
featuredOnly,
|
||||
query,
|
||||
topic,
|
||||
}: {
|
||||
categorySlug?: string;
|
||||
featuredOnly: boolean;
|
||||
query: string;
|
||||
topic?: string;
|
||||
}) {
|
||||
const trimmed = query.trim();
|
||||
return trimmed
|
||||
? `${trimmed}::${featuredOnly ? "1" : "0"}::${categorySlug ?? ""}::${topic ?? ""}`
|
||||
: "";
|
||||
}
|
||||
|
||||
export function useSkillsBrowseModel({
|
||||
initialSearch,
|
||||
search,
|
||||
navigate,
|
||||
searchInputRef,
|
||||
}: {
|
||||
initialSearch?: InitialSkillsSearchData;
|
||||
search: SkillsSearchState;
|
||||
navigate: SkillsNavigate;
|
||||
searchInputRef: RefObject<HTMLInputElement | null>;
|
||||
}) {
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([]);
|
||||
const [searchLimit, setSearchLimit] = useState(pageSize);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchRequest = useRef(0);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadMoreInFlightRef = useRef(false);
|
||||
@@ -89,9 +111,22 @@ export function useSkillsBrowseModel({
|
||||
: (requestedSort ?? (hasQuery ? "relevance" : "recommended"));
|
||||
const listSort = sort === "trending" ? undefined : toListSort(sort);
|
||||
const dir = sort === "relevance" ? "desc" : parseDir(search.dir, sort);
|
||||
const searchKey = hasQuery
|
||||
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeCategory?.slug ?? ""}::${activeTopic ?? ""}`
|
||||
: "";
|
||||
const searchKey = buildSkillsSearchKey({
|
||||
query: trimmedQuery,
|
||||
featuredOnly,
|
||||
categorySlug: activeCategory?.slug,
|
||||
topic: activeTopic,
|
||||
});
|
||||
const matchedInitialSearch = initialSearch?.key === searchKey ? initialSearch : null;
|
||||
const initialSearchMatches = matchedInitialSearch !== null;
|
||||
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>(() =>
|
||||
matchedInitialSearch ? matchedInitialSearch.results : [],
|
||||
);
|
||||
const [searchLimit, setSearchLimit] = useState(() =>
|
||||
matchedInitialSearch ? matchedInitialSearch.limit : pageSize,
|
||||
);
|
||||
const [isSearching, setIsSearching] = useState(() => hasQuery && !initialSearchMatches);
|
||||
const appliedInitialSearchKey = useRef(matchedInitialSearch ? matchedInitialSearch.key : null);
|
||||
|
||||
// One-shot paginated fetches (no reactive subscription)
|
||||
const [listResults, setListResults] = useState<SkillListEntry[]>([]);
|
||||
@@ -213,14 +248,30 @@ export function useSkillsBrowseModel({
|
||||
if (!searchKey) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
appliedInitialSearchKey.current = null;
|
||||
return;
|
||||
}
|
||||
if (matchedInitialSearch && appliedInitialSearchKey.current !== matchedInitialSearch.key) {
|
||||
setSearchResults(matchedInitialSearch.results);
|
||||
setSearchLimit(matchedInitialSearch.limit);
|
||||
setIsSearching(false);
|
||||
appliedInitialSearchKey.current = matchedInitialSearch.key;
|
||||
}
|
||||
if (matchedInitialSearch) return;
|
||||
setSearchResults([]);
|
||||
setSearchLimit(pageSize);
|
||||
}, [searchKey]);
|
||||
setIsSearching(true);
|
||||
appliedInitialSearchKey.current = null;
|
||||
}, [matchedInitialSearch, searchKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasQuery) return () => {};
|
||||
if (matchedInitialSearch && searchLimit === matchedInitialSearch.limit) {
|
||||
searchRequest.current += 1;
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
searchRequest.current += 1;
|
||||
const requestId = searchRequest.current;
|
||||
setIsSearching(true);
|
||||
@@ -248,6 +299,7 @@ export function useSkillsBrowseModel({
|
||||
activeTopic,
|
||||
hasQuery,
|
||||
featuredOnly,
|
||||
matchedInitialSearch,
|
||||
searchLimit,
|
||||
searchSkills,
|
||||
trimmedQuery,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
BrowseViewToggle,
|
||||
useBrowseSearchDisclosure,
|
||||
} from "../../components/BrowseControls";
|
||||
import { convexHttp } from "../../convex/client";
|
||||
import { formatBrowseCount } from "../../lib/browseCount";
|
||||
import {
|
||||
parseBrowseTopicFromSearchInput,
|
||||
@@ -26,7 +27,10 @@ import { resolveSkillBrowseCategorySlug, SKILL_CATEGORIES } from "../../lib/cate
|
||||
import { useBrowseTopicSearch } from "../../lib/useBrowseTopicSearch";
|
||||
import { parseDir, parseSort } from "./-params";
|
||||
import { SkillsResults } from "./-SkillsResults";
|
||||
import type { SkillSearchEntry } from "./-types";
|
||||
import {
|
||||
buildSkillsSearchKey,
|
||||
type InitialSkillsSearchData,
|
||||
normalizeSkillsView,
|
||||
useSkillsBrowseModel,
|
||||
type SkillsSearchState,
|
||||
@@ -45,6 +49,7 @@ const SKILLS_SORT_OPTIONS = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "name", label: "Name" },
|
||||
];
|
||||
const SKILLS_INITIAL_SEARCH_LIMIT = 25;
|
||||
|
||||
function parseSkillCategorySlug(value: unknown) {
|
||||
return typeof value === "string" ? resolveSkillBrowseCategorySlug(value) : undefined;
|
||||
@@ -70,16 +75,54 @@ export const Route = createFileRoute("/skills/")({
|
||||
focus: search.focus === "search" ? "search" : undefined,
|
||||
};
|
||||
},
|
||||
loaderDeps: ({ search }) => ({
|
||||
q: search.q,
|
||||
featured: search.featured,
|
||||
highlighted: search.highlighted,
|
||||
category: search.category,
|
||||
topic: search.topic,
|
||||
}),
|
||||
loader: async ({ deps }): Promise<InitialSkillsSearchData> => await loadInitialSkillsSearch(deps),
|
||||
component: SkillsIndex,
|
||||
});
|
||||
|
||||
async function loadInitialSkillsSearch(
|
||||
search: SkillsSearchState,
|
||||
): Promise<InitialSkillsSearchData> {
|
||||
const query = search.q?.trim();
|
||||
if (!query) return null;
|
||||
|
||||
const featuredOnly = search.featured ?? search.highlighted ?? false;
|
||||
const key = buildSkillsSearchKey({
|
||||
query,
|
||||
featuredOnly,
|
||||
categorySlug: search.category,
|
||||
topic: search.topic,
|
||||
});
|
||||
try {
|
||||
const results = (await convexHttp.action(api.search.searchSkills, {
|
||||
query,
|
||||
highlightedOnly: featuredOnly,
|
||||
categorySlug: search.category,
|
||||
topic: search.topic,
|
||||
limit: SKILLS_INITIAL_SEARCH_LIMIT,
|
||||
})) as SkillSearchEntry[];
|
||||
return { key, limit: SKILLS_INITIAL_SEARCH_LIMIT, results };
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial skills search:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SkillsIndex() {
|
||||
const navigate = Route.useNavigate();
|
||||
const routeSearch = Route.useSearch();
|
||||
const initialSearch = Route.useLoaderData() as InitialSkillsSearchData | undefined;
|
||||
const { search, activeTopic } = useBrowseTopicSearch(routeSearch, navigate);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const model = useSkillsBrowseModel({
|
||||
initialSearch,
|
||||
navigate,
|
||||
search,
|
||||
searchInputRef,
|
||||
|
||||
Reference in New Issue
Block a user