@86d-app/search 0.0.3
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/README.md +138 -0
- package/package.json +46 -0
- package/src/__tests__/service-impl.test.ts +467 -0
- package/src/admin/components/index.tsx +1 -0
- package/src/admin/components/search-analytics.tsx +292 -0
- package/src/admin/endpoints/analytics.ts +15 -0
- package/src/admin/endpoints/index-manage.ts +43 -0
- package/src/admin/endpoints/index.ts +16 -0
- package/src/admin/endpoints/popular.ts +17 -0
- package/src/admin/endpoints/synonyms.ts +49 -0
- package/src/admin/endpoints/zero-results.ts +17 -0
- package/src/index.ts +61 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +48 -0
- package/src/service-impl.ts +395 -0
- package/src/service.ts +97 -0
- package/src/store/components/_hooks.ts +12 -0
- package/src/store/components/index.tsx +12 -0
- package/src/store/components/search-bar.tsx +153 -0
- package/src/store/components/search-page.tsx +26 -0
- package/src/store/components/search-results.tsx +102 -0
- package/src/store/endpoints/index.ts +11 -0
- package/src/store/endpoints/recent.ts +27 -0
- package/src/store/endpoints/search.ts +42 -0
- package/src/store/endpoints/store-search.ts +41 -0
- package/src/store/endpoints/suggest.ts +21 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSearchApi } from "./_hooks";
|
|
4
|
+
|
|
5
|
+
interface SearchResult {
|
|
6
|
+
id: string;
|
|
7
|
+
entityType: string;
|
|
8
|
+
entityId: string;
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
image?: string;
|
|
12
|
+
score: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SearchResultsProps {
|
|
16
|
+
query: string;
|
|
17
|
+
entityType?: string | undefined;
|
|
18
|
+
sessionId?: string | undefined;
|
|
19
|
+
limit?: number | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SearchResults({
|
|
23
|
+
query,
|
|
24
|
+
entityType,
|
|
25
|
+
sessionId,
|
|
26
|
+
limit = 20,
|
|
27
|
+
}: SearchResultsProps) {
|
|
28
|
+
const api = useSearchApi();
|
|
29
|
+
|
|
30
|
+
const { data, isLoading } =
|
|
31
|
+
query.trim().length > 0
|
|
32
|
+
? (api.search.useQuery({
|
|
33
|
+
q: query.trim(),
|
|
34
|
+
type: entityType,
|
|
35
|
+
limit: String(limit),
|
|
36
|
+
sessionId,
|
|
37
|
+
}) as {
|
|
38
|
+
data: { results: SearchResult[]; total: number } | undefined;
|
|
39
|
+
isLoading: boolean;
|
|
40
|
+
})
|
|
41
|
+
: { data: undefined, isLoading: false };
|
|
42
|
+
|
|
43
|
+
const results = data?.results ?? [];
|
|
44
|
+
const total = data?.total ?? 0;
|
|
45
|
+
|
|
46
|
+
if (!query.trim()) return null;
|
|
47
|
+
|
|
48
|
+
if (isLoading) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="py-12 text-center">
|
|
51
|
+
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-muted border-t-foreground" />
|
|
52
|
+
<p className="mt-3 text-muted-foreground text-sm">Searching...</p>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (results.length === 0) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="py-12 text-center">
|
|
60
|
+
<p className="text-foreground">
|
|
61
|
+
No results found for “{query}”
|
|
62
|
+
</p>
|
|
63
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
64
|
+
Try different keywords or check the spelling.
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div>
|
|
72
|
+
<p className="mb-4 text-muted-foreground text-sm">
|
|
73
|
+
{total} result{total !== 1 ? "s" : ""} for “{query}”
|
|
74
|
+
</p>
|
|
75
|
+
<div className="space-y-3">
|
|
76
|
+
{results.map((result) => (
|
|
77
|
+
<a
|
|
78
|
+
key={result.id}
|
|
79
|
+
href={result.url}
|
|
80
|
+
className="block rounded-lg border border-border p-4 transition-colors hover:bg-muted/50"
|
|
81
|
+
>
|
|
82
|
+
<div className="flex items-start gap-4">
|
|
83
|
+
{result.image && (
|
|
84
|
+
<img
|
|
85
|
+
src={result.image}
|
|
86
|
+
alt=""
|
|
87
|
+
className="h-16 w-16 rounded-md object-cover"
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
<div className="min-w-0 flex-1">
|
|
91
|
+
<h3 className="font-medium text-foreground">{result.title}</h3>
|
|
92
|
+
<p className="mt-0.5 text-muted-foreground text-xs capitalize">
|
|
93
|
+
{result.entityType}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</a>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { recentEndpoint } from "./recent";
|
|
2
|
+
import { searchEndpoint } from "./search";
|
|
3
|
+
import { storeSearch } from "./store-search";
|
|
4
|
+
import { suggestEndpoint } from "./suggest";
|
|
5
|
+
|
|
6
|
+
export const storeEndpoints = {
|
|
7
|
+
"/search/store-search": storeSearch,
|
|
8
|
+
"/search": searchEndpoint,
|
|
9
|
+
"/search/suggest": suggestEndpoint,
|
|
10
|
+
"/search/recent": recentEndpoint,
|
|
11
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const recentEndpoint = createStoreEndpoint(
|
|
5
|
+
"/search/recent",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
sessionId: z.string().min(1),
|
|
10
|
+
limit: z.coerce.number().int().min(1).max(20).optional(),
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
async (ctx) => {
|
|
14
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
15
|
+
const queries = await controller.getRecentQueries(
|
|
16
|
+
ctx.query.sessionId,
|
|
17
|
+
ctx.query.limit ?? 10,
|
|
18
|
+
);
|
|
19
|
+
return {
|
|
20
|
+
recent: queries.map((q) => ({
|
|
21
|
+
term: q.term,
|
|
22
|
+
resultCount: q.resultCount,
|
|
23
|
+
searchedAt: q.searchedAt,
|
|
24
|
+
})),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const searchEndpoint = createStoreEndpoint(
|
|
5
|
+
"/search",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
q: z.string().min(1).max(500),
|
|
10
|
+
type: z.string().optional(),
|
|
11
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
12
|
+
skip: z.coerce.number().int().min(0).optional(),
|
|
13
|
+
sessionId: z.string().optional(),
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
async (ctx) => {
|
|
17
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
18
|
+
const { results, total } = await controller.search(ctx.query.q, {
|
|
19
|
+
entityType: ctx.query.type,
|
|
20
|
+
limit: ctx.query.limit ?? 20,
|
|
21
|
+
skip: ctx.query.skip ?? 0,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Record query for analytics (fire-and-forget)
|
|
25
|
+
controller
|
|
26
|
+
.recordQuery(ctx.query.q, total, ctx.query.sessionId)
|
|
27
|
+
.catch(() => {});
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
results: results.map((r) => ({
|
|
31
|
+
id: r.item.id,
|
|
32
|
+
entityType: r.item.entityType,
|
|
33
|
+
entityId: r.item.entityId,
|
|
34
|
+
title: r.item.title,
|
|
35
|
+
url: r.item.url,
|
|
36
|
+
image: r.item.image,
|
|
37
|
+
score: r.score,
|
|
38
|
+
})),
|
|
39
|
+
total,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const storeSearch = createStoreEndpoint(
|
|
5
|
+
"/search/store-search",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
q: z.string().min(0).max(500),
|
|
10
|
+
limit: z.string().optional(),
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
async (ctx) => {
|
|
14
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
15
|
+
const limit = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 5;
|
|
16
|
+
const q = ctx.query.q.trim();
|
|
17
|
+
|
|
18
|
+
if (q.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
results: [
|
|
21
|
+
{
|
|
22
|
+
id: "search",
|
|
23
|
+
label: "Search",
|
|
24
|
+
href: "/search",
|
|
25
|
+
group: "Pages",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { results } = await controller.search(q, { limit });
|
|
32
|
+
return {
|
|
33
|
+
results: results.map((r) => ({
|
|
34
|
+
id: r.item.id,
|
|
35
|
+
label: r.item.title,
|
|
36
|
+
href: r.item.url,
|
|
37
|
+
group: r.item.entityType,
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const suggestEndpoint = createStoreEndpoint(
|
|
5
|
+
"/search/suggest",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
q: z.string().min(1).max(200),
|
|
10
|
+
limit: z.coerce.number().int().min(1).max(20).optional(),
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
async (ctx) => {
|
|
14
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
15
|
+
const suggestions = await controller.suggest(
|
|
16
|
+
ctx.query.q,
|
|
17
|
+
ctx.query.limit ?? 8,
|
|
18
|
+
);
|
|
19
|
+
return { suggestions };
|
|
20
|
+
},
|
|
21
|
+
);
|