@86d-app/search 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/search/src/__tests__/admin-settings.test.js +262 -0
- package/dist/modules/search/src/__tests__/controllers.test.js +853 -0
- package/dist/modules/search/src/__tests__/embedding-provider.test.js +150 -0
- package/dist/modules/search/src/__tests__/endpoint-security.test.js +250 -0
- package/dist/modules/search/src/__tests__/meilisearch-provider.test.js +318 -0
- package/dist/modules/search/src/__tests__/service-impl.test.js +703 -0
- package/dist/modules/search/src/__tests__/store-endpoints.test.js +295 -0
- package/dist/{admin/components/index.d.ts → modules/search/src/admin/components/index.jsx} +0 -1
- package/dist/modules/search/src/admin/components/search-analytics.jsx +230 -0
- package/dist/modules/search/src/admin/endpoints/analytics.js +9 -0
- package/dist/modules/search/src/admin/endpoints/bulk-index.js +26 -0
- package/dist/modules/search/src/admin/endpoints/click-analytics.js +9 -0
- package/dist/modules/search/src/admin/endpoints/get-settings.js +97 -0
- package/dist/modules/search/src/admin/endpoints/index-manage.js +32 -0
- package/dist/modules/search/src/admin/endpoints/index.js +21 -0
- package/dist/modules/search/src/admin/endpoints/popular.js +11 -0
- package/dist/modules/search/src/admin/endpoints/synonyms.js +30 -0
- package/dist/modules/search/src/admin/endpoints/zero-results.js +11 -0
- package/dist/modules/search/src/embedding-provider.js +77 -0
- package/dist/modules/search/src/index.js +75 -0
- package/dist/modules/search/src/meilisearch-provider.js +138 -0
- package/dist/modules/search/src/schema.js +61 -0
- package/dist/modules/search/src/service-impl.js +770 -0
- package/dist/modules/search/src/service.js +1 -0
- package/dist/modules/search/src/store/components/_hooks.js +10 -0
- package/dist/modules/search/src/store/components/index.jsx +9 -0
- package/dist/modules/search/src/store/components/search-bar.jsx +91 -0
- package/dist/modules/search/src/store/components/search-page.jsx +17 -0
- package/dist/modules/search/src/store/components/search-results.jsx +51 -0
- package/dist/modules/search/src/store/endpoints/click.js +15 -0
- package/dist/modules/search/src/store/endpoints/index.js +12 -0
- package/dist/modules/search/src/store/endpoints/recent.js +18 -0
- package/dist/modules/search/src/store/endpoints/search.js +57 -0
- package/dist/modules/search/src/store/endpoints/store-search.js +33 -0
- package/dist/modules/search/src/store/endpoints/suggest.js +12 -0
- package/package.json +1 -1
- package/src/__tests__/admin-settings.test.ts +367 -0
- package/src/__tests__/store-endpoints.test.ts +392 -0
- package/src/admin/endpoints/get-settings.ts +77 -0
- package/dist/__tests__/controllers.test.d.ts +0 -2
- package/dist/__tests__/controllers.test.d.ts.map +0 -1
- package/dist/__tests__/embedding-provider.test.d.ts +0 -2
- package/dist/__tests__/embedding-provider.test.d.ts.map +0 -1
- package/dist/__tests__/endpoint-security.test.d.ts +0 -2
- package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
- package/dist/__tests__/meilisearch-provider.test.d.ts +0 -2
- package/dist/__tests__/meilisearch-provider.test.d.ts.map +0 -1
- package/dist/__tests__/service-impl.test.d.ts +0 -2
- package/dist/__tests__/service-impl.test.d.ts.map +0 -1
- package/dist/admin/components/index.d.ts.map +0 -1
- package/dist/admin/components/search-analytics.d.ts +0 -2
- package/dist/admin/components/search-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/analytics.d.ts +0 -15
- package/dist/admin/endpoints/analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/bulk-index.d.ts +0 -20
- package/dist/admin/endpoints/bulk-index.d.ts.map +0 -1
- package/dist/admin/endpoints/click-analytics.d.ts +0 -7
- package/dist/admin/endpoints/click-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/get-settings.d.ts +0 -17
- package/dist/admin/endpoints/get-settings.d.ts.map +0 -1
- package/dist/admin/endpoints/index-manage.d.ts +0 -26
- package/dist/admin/endpoints/index-manage.d.ts.map +0 -1
- package/dist/admin/endpoints/index.d.ts +0 -125
- package/dist/admin/endpoints/index.d.ts.map +0 -1
- package/dist/admin/endpoints/popular.d.ts +0 -10
- package/dist/admin/endpoints/popular.d.ts.map +0 -1
- package/dist/admin/endpoints/synonyms.d.ts +0 -30
- package/dist/admin/endpoints/synonyms.d.ts.map +0 -1
- package/dist/admin/endpoints/zero-results.d.ts +0 -10
- package/dist/admin/endpoints/zero-results.d.ts.map +0 -1
- package/dist/embedding-provider.d.ts +0 -28
- package/dist/embedding-provider.d.ts.map +0 -1
- package/dist/index.d.ts +0 -23
- package/dist/index.d.ts.map +0 -1
- package/dist/meilisearch-provider.d.ts +0 -104
- package/dist/meilisearch-provider.d.ts.map +0 -1
- package/dist/schema.d.ts +0 -133
- package/dist/schema.d.ts.map +0 -1
- package/dist/service-impl.d.ts +0 -6
- package/dist/service-impl.d.ts.map +0 -1
- package/dist/service.d.ts +0 -127
- package/dist/service.d.ts.map +0 -1
- package/dist/store/components/_hooks.d.ts +0 -6
- package/dist/store/components/_hooks.d.ts.map +0 -1
- package/dist/store/components/index.d.ts +0 -10
- package/dist/store/components/index.d.ts.map +0 -1
- package/dist/store/components/search-bar.d.ts +0 -7
- package/dist/store/components/search-bar.d.ts.map +0 -1
- package/dist/store/components/search-page.d.ts +0 -4
- package/dist/store/components/search-page.d.ts.map +0 -1
- package/dist/store/components/search-results.d.ts +0 -9
- package/dist/store/components/search-results.d.ts.map +0 -1
- package/dist/store/endpoints/click.d.ts +0 -14
- package/dist/store/endpoints/click.d.ts.map +0 -1
- package/dist/store/endpoints/index.d.ts +0 -85
- package/dist/store/endpoints/index.d.ts.map +0 -1
- package/dist/store/endpoints/recent.d.ts +0 -15
- package/dist/store/endpoints/recent.d.ts.map +0 -1
- package/dist/store/endpoints/search.d.ts +0 -36
- package/dist/store/endpoints/search.d.ts.map +0 -1
- package/dist/store/endpoints/store-search.d.ts +0 -16
- package/dist/store/endpoints/store-search.d.ts.map +0 -1
- package/dist/store/endpoints/suggest.d.ts +0 -11
- package/dist/store/endpoints/suggest.d.ts.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
3
|
+
export function useSearchApi() {
|
|
4
|
+
const client = useModuleClient();
|
|
5
|
+
return {
|
|
6
|
+
search: client.module("search").store["/search"],
|
|
7
|
+
suggest: client.module("search").store["/search/suggest"],
|
|
8
|
+
recent: client.module("search").store["/search/recent"],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useSearchApi } from "./_hooks";
|
|
4
|
+
export function SearchBar({ placeholder = "Search...", onSearch, }) {
|
|
5
|
+
const api = useSearchApi();
|
|
6
|
+
const [query, setQuery] = useState("");
|
|
7
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
8
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
9
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
10
|
+
const inputRef = useRef(null);
|
|
11
|
+
const containerRef = useRef(null);
|
|
12
|
+
const { data: suggestData } = query.trim().length >= 2
|
|
13
|
+
? api.suggest.useQuery({ q: query.trim(), limit: "8" })
|
|
14
|
+
: { data: undefined };
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (suggestData?.suggestions) {
|
|
17
|
+
setSuggestions(suggestData.suggestions);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
setSuggestions([]);
|
|
21
|
+
}
|
|
22
|
+
}, [suggestData]);
|
|
23
|
+
const handleSubmit = useCallback((term) => {
|
|
24
|
+
const trimmed = term.trim();
|
|
25
|
+
if (trimmed.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
setShowSuggestions(false);
|
|
28
|
+
setSelectedIndex(-1);
|
|
29
|
+
onSearch?.(trimmed);
|
|
30
|
+
}, [onSearch]);
|
|
31
|
+
const handleKeyDown = (e) => {
|
|
32
|
+
if (e.key === "ArrowDown") {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1));
|
|
35
|
+
}
|
|
36
|
+
else if (e.key === "ArrowUp") {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
setSelectedIndex((i) => Math.max(i - 1, -1));
|
|
39
|
+
}
|
|
40
|
+
else if (e.key === "Enter") {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
|
43
|
+
setQuery(suggestions[selectedIndex]);
|
|
44
|
+
handleSubmit(suggestions[selectedIndex]);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
handleSubmit(query);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (e.key === "Escape") {
|
|
51
|
+
setShowSuggestions(false);
|
|
52
|
+
setSelectedIndex(-1);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
// Close suggestions on click outside
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const handleClick = (e) => {
|
|
58
|
+
if (containerRef.current &&
|
|
59
|
+
!containerRef.current.contains(e.target)) {
|
|
60
|
+
setShowSuggestions(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
document.addEventListener("mousedown", handleClick);
|
|
64
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
65
|
+
}, []);
|
|
66
|
+
return (<div ref={containerRef} className="relative w-full">
|
|
67
|
+
<div className="relative">
|
|
68
|
+
<svg className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" aria-hidden="true">
|
|
69
|
+
<title>Search</title>
|
|
70
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
|
|
71
|
+
</svg>
|
|
72
|
+
<input ref={inputRef} type="search" value={query} onChange={(e) => {
|
|
73
|
+
setQuery(e.target.value);
|
|
74
|
+
setShowSuggestions(true);
|
|
75
|
+
setSelectedIndex(-1);
|
|
76
|
+
}} onFocus={() => setShowSuggestions(true)} onKeyDown={handleKeyDown} placeholder={placeholder} className="w-full rounded-lg border border-border bg-background py-2.5 pr-4 pl-10 text-foreground text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" aria-label="Search" aria-autocomplete="list" aria-expanded={showSuggestions && suggestions.length > 0} role="combobox"/>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{showSuggestions && suggestions.length > 0 && (<div role="listbox" className="absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-border bg-background shadow-lg">
|
|
80
|
+
{suggestions.map((suggestion, index) => (<div key={suggestion} role="option" tabIndex={-1} aria-selected={index === selectedIndex} className={`cursor-pointer px-4 py-2 text-sm ${index === selectedIndex
|
|
81
|
+
? "bg-muted text-foreground"
|
|
82
|
+
: "text-foreground hover:bg-muted/50"}`} onMouseDown={(e) => {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
setQuery(suggestion);
|
|
85
|
+
handleSubmit(suggestion);
|
|
86
|
+
}} onMouseEnter={() => setSelectedIndex(index)}>
|
|
87
|
+
{suggestion}
|
|
88
|
+
</div>))}
|
|
89
|
+
</div>)}
|
|
90
|
+
</div>);
|
|
91
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import { SearchBar } from "./search-bar";
|
|
4
|
+
import { SearchResults } from "./search-results";
|
|
5
|
+
export function SearchPage({ sessionId }) {
|
|
6
|
+
const [query, setQuery] = useState("");
|
|
7
|
+
const handleSearch = useCallback((term) => {
|
|
8
|
+
setQuery(term);
|
|
9
|
+
}, []);
|
|
10
|
+
return (<div className="mx-auto max-w-2xl px-4 py-8">
|
|
11
|
+
<h1 className="mb-6 font-semibold text-2xl text-foreground">Search</h1>
|
|
12
|
+
<SearchBar placeholder="Search products, articles..." onSearch={handleSearch}/>
|
|
13
|
+
<div className="mt-6">
|
|
14
|
+
<SearchResults query={query} sessionId={sessionId}/>
|
|
15
|
+
</div>
|
|
16
|
+
</div>);
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSearchApi } from "./_hooks";
|
|
3
|
+
export function SearchResults({ query, entityType, sessionId, limit = 20, }) {
|
|
4
|
+
const api = useSearchApi();
|
|
5
|
+
const { data, isLoading } = query.trim().length > 0
|
|
6
|
+
? api.search.useQuery({
|
|
7
|
+
q: query.trim(),
|
|
8
|
+
type: entityType,
|
|
9
|
+
limit: String(limit),
|
|
10
|
+
sessionId,
|
|
11
|
+
})
|
|
12
|
+
: { data: undefined, isLoading: false };
|
|
13
|
+
const results = data?.results ?? [];
|
|
14
|
+
const total = data?.total ?? 0;
|
|
15
|
+
if (!query.trim())
|
|
16
|
+
return null;
|
|
17
|
+
if (isLoading) {
|
|
18
|
+
return (<div className="py-12 text-center">
|
|
19
|
+
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-muted border-t-foreground"/>
|
|
20
|
+
<p className="mt-3 text-muted-foreground text-sm">Searching...</p>
|
|
21
|
+
</div>);
|
|
22
|
+
}
|
|
23
|
+
if (results.length === 0) {
|
|
24
|
+
return (<div className="py-12 text-center">
|
|
25
|
+
<p className="text-foreground">
|
|
26
|
+
No results found for “{query}”
|
|
27
|
+
</p>
|
|
28
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
29
|
+
Try different keywords or check the spelling.
|
|
30
|
+
</p>
|
|
31
|
+
</div>);
|
|
32
|
+
}
|
|
33
|
+
return (<div>
|
|
34
|
+
<p className="mb-4 text-muted-foreground text-sm">
|
|
35
|
+
{total} result{total !== 1 ? "s" : ""} for “{query}”
|
|
36
|
+
</p>
|
|
37
|
+
<div className="space-y-3">
|
|
38
|
+
{results.map((result) => (<a key={result.id} href={result.url} className="block rounded-lg border border-border p-4 transition-colors hover:bg-muted/50">
|
|
39
|
+
<div className="flex items-start gap-4">
|
|
40
|
+
{result.image && (<img src={result.image} alt="" className="h-16 w-16 rounded-md object-cover"/>)}
|
|
41
|
+
<div className="min-w-0 flex-1">
|
|
42
|
+
<h3 className="font-medium text-foreground">{result.title}</h3>
|
|
43
|
+
<p className="mt-0.5 text-muted-foreground text-xs capitalize">
|
|
44
|
+
{result.entityType}
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</a>))}
|
|
49
|
+
</div>
|
|
50
|
+
</div>);
|
|
51
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
|
|
2
|
+
export const clickEndpoint = createStoreEndpoint("/search/click", {
|
|
3
|
+
method: "POST",
|
|
4
|
+
body: z.object({
|
|
5
|
+
queryId: z.string().min(1).max(200),
|
|
6
|
+
term: z.string().min(1).max(500).transform(sanitizeText),
|
|
7
|
+
entityType: z.string().min(1).max(100).transform(sanitizeText),
|
|
8
|
+
entityId: z.string().min(1).max(200),
|
|
9
|
+
position: z.number().int().min(0).max(1000),
|
|
10
|
+
}),
|
|
11
|
+
}, async (ctx) => {
|
|
12
|
+
const controller = ctx.context.controllers.search;
|
|
13
|
+
const click = await controller.recordClick(ctx.body);
|
|
14
|
+
return { id: click.id };
|
|
15
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { clickEndpoint } from "./click";
|
|
2
|
+
import { recentEndpoint } from "./recent";
|
|
3
|
+
import { searchEndpoint } from "./search";
|
|
4
|
+
import { storeSearch } from "./store-search";
|
|
5
|
+
import { suggestEndpoint } from "./suggest";
|
|
6
|
+
export const storeEndpoints = {
|
|
7
|
+
"/search/store-search": storeSearch,
|
|
8
|
+
"/search": searchEndpoint,
|
|
9
|
+
"/search/suggest": suggestEndpoint,
|
|
10
|
+
"/search/recent": recentEndpoint,
|
|
11
|
+
"/search/click": clickEndpoint,
|
|
12
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
+
export const recentEndpoint = createStoreEndpoint("/search/recent", {
|
|
3
|
+
method: "GET",
|
|
4
|
+
query: z.object({
|
|
5
|
+
sessionId: z.string().min(1).max(128),
|
|
6
|
+
limit: z.coerce.number().int().min(1).max(20).optional(),
|
|
7
|
+
}),
|
|
8
|
+
}, async (ctx) => {
|
|
9
|
+
const controller = ctx.context.controllers.search;
|
|
10
|
+
const queries = await controller.getRecentQueries(ctx.query.sessionId, ctx.query.limit ?? 10);
|
|
11
|
+
return {
|
|
12
|
+
recent: queries.map((q) => ({
|
|
13
|
+
term: q.term,
|
|
14
|
+
resultCount: q.resultCount,
|
|
15
|
+
searchedAt: q.searchedAt,
|
|
16
|
+
})),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
|
|
2
|
+
const sortFields = [
|
|
3
|
+
"relevance",
|
|
4
|
+
"newest",
|
|
5
|
+
"oldest",
|
|
6
|
+
"title_asc",
|
|
7
|
+
"title_desc",
|
|
8
|
+
];
|
|
9
|
+
export const searchEndpoint = createStoreEndpoint("/search", {
|
|
10
|
+
method: "GET",
|
|
11
|
+
query: z.object({
|
|
12
|
+
q: z.string().min(1).max(500).transform(sanitizeText),
|
|
13
|
+
type: z.string().max(100).transform(sanitizeText).optional(),
|
|
14
|
+
tags: z.string().max(2000).transform(sanitizeText).optional(),
|
|
15
|
+
sort: z.enum(sortFields).optional(),
|
|
16
|
+
fuzzy: z.coerce.boolean().optional(),
|
|
17
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
18
|
+
skip: z.coerce.number().int().min(0).optional(),
|
|
19
|
+
sessionId: z.string().max(200).optional(),
|
|
20
|
+
}),
|
|
21
|
+
}, async (ctx) => {
|
|
22
|
+
const controller = ctx.context.controllers.search;
|
|
23
|
+
const parsedTags = ctx.query.tags
|
|
24
|
+
? ctx.query.tags
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((t) => t.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
: undefined;
|
|
29
|
+
const { results, total, facets, didYouMean } = await controller.search(ctx.query.q, {
|
|
30
|
+
entityType: ctx.query.type,
|
|
31
|
+
tags: parsedTags,
|
|
32
|
+
sort: ctx.query.sort,
|
|
33
|
+
fuzzy: ctx.query.fuzzy,
|
|
34
|
+
limit: ctx.query.limit ?? 20,
|
|
35
|
+
skip: ctx.query.skip ?? 0,
|
|
36
|
+
});
|
|
37
|
+
// Record query for analytics (fire-and-forget)
|
|
38
|
+
controller
|
|
39
|
+
.recordQuery(ctx.query.q, total, ctx.query.sessionId)
|
|
40
|
+
.catch(() => { });
|
|
41
|
+
return {
|
|
42
|
+
results: results.map((r) => ({
|
|
43
|
+
id: r.item.id,
|
|
44
|
+
entityType: r.item.entityType,
|
|
45
|
+
entityId: r.item.entityId,
|
|
46
|
+
title: r.item.title,
|
|
47
|
+
url: r.item.url,
|
|
48
|
+
image: r.item.image,
|
|
49
|
+
tags: r.item.tags,
|
|
50
|
+
score: r.score,
|
|
51
|
+
highlights: r.highlights,
|
|
52
|
+
})),
|
|
53
|
+
total,
|
|
54
|
+
facets,
|
|
55
|
+
didYouMean,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
|
|
2
|
+
export const storeSearch = createStoreEndpoint("/search/store-search", {
|
|
3
|
+
method: "GET",
|
|
4
|
+
query: z.object({
|
|
5
|
+
q: z.string().min(0).max(500).transform(sanitizeText),
|
|
6
|
+
limit: z.string().max(10).optional(),
|
|
7
|
+
}),
|
|
8
|
+
}, async (ctx) => {
|
|
9
|
+
const controller = ctx.context.controllers.search;
|
|
10
|
+
const limit = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 5;
|
|
11
|
+
const q = ctx.query.q.trim();
|
|
12
|
+
if (q.length === 0) {
|
|
13
|
+
return {
|
|
14
|
+
results: [
|
|
15
|
+
{
|
|
16
|
+
id: "search",
|
|
17
|
+
label: "Search",
|
|
18
|
+
href: "/search",
|
|
19
|
+
group: "Pages",
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const { results } = await controller.search(q, { limit });
|
|
25
|
+
return {
|
|
26
|
+
results: results.map((r) => ({
|
|
27
|
+
id: r.item.id,
|
|
28
|
+
label: r.item.title,
|
|
29
|
+
href: r.item.url,
|
|
30
|
+
group: r.item.entityType,
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createStoreEndpoint, sanitizeText, z } from "@86d-app/core";
|
|
2
|
+
export const suggestEndpoint = createStoreEndpoint("/search/suggest", {
|
|
3
|
+
method: "GET",
|
|
4
|
+
query: z.object({
|
|
5
|
+
q: z.string().min(1).max(200).transform(sanitizeText),
|
|
6
|
+
limit: z.coerce.number().int().min(1).max(20).optional(),
|
|
7
|
+
}),
|
|
8
|
+
}, async (ctx) => {
|
|
9
|
+
const controller = ctx.context.controllers.search;
|
|
10
|
+
const suggestions = await controller.suggest(ctx.query.q, ctx.query.limit ?? 8);
|
|
11
|
+
return { suggestions };
|
|
12
|
+
});
|