@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,292 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
interface AnalyticsData {
|
|
7
|
+
totalQueries: number;
|
|
8
|
+
uniqueTerms: number;
|
|
9
|
+
avgResultCount: number;
|
|
10
|
+
zeroResultCount: number;
|
|
11
|
+
zeroResultRate: number;
|
|
12
|
+
indexedItems: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PopularTerm {
|
|
16
|
+
term: string;
|
|
17
|
+
count: number;
|
|
18
|
+
avgResultCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Synonym {
|
|
22
|
+
id: string;
|
|
23
|
+
term: string;
|
|
24
|
+
synonyms: string[];
|
|
25
|
+
createdAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function useSearchAdminApi() {
|
|
29
|
+
const client = useModuleClient();
|
|
30
|
+
return {
|
|
31
|
+
analytics: client.module("search").admin["/admin/search/analytics"],
|
|
32
|
+
popular: client.module("search").admin["/admin/search/popular"],
|
|
33
|
+
zeroResults: client.module("search").admin["/admin/search/zero-results"],
|
|
34
|
+
synonyms: client.module("search").admin["/admin/search/synonyms"],
|
|
35
|
+
addSynonym: client.module("search").admin["/admin/search/synonyms/add"],
|
|
36
|
+
removeSynonym:
|
|
37
|
+
client.module("search").admin["/admin/search/synonyms/:id/delete"],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function StatCard({ label, value }: { label: string; value: string | number }) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="rounded-lg border border-border bg-background p-4">
|
|
44
|
+
<p className="text-muted-foreground text-xs uppercase tracking-wider">
|
|
45
|
+
{label}
|
|
46
|
+
</p>
|
|
47
|
+
<p className="mt-1 font-semibold text-2xl text-foreground">{value}</p>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function SearchAnalytics() {
|
|
53
|
+
const api = useSearchAdminApi();
|
|
54
|
+
const [newTerm, setNewTerm] = useState("");
|
|
55
|
+
const [newSynonyms, setNewSynonyms] = useState("");
|
|
56
|
+
const [error, setError] = useState("");
|
|
57
|
+
|
|
58
|
+
const { data: analyticsData, isLoading: analyticsLoading } =
|
|
59
|
+
api.analytics.useQuery({}) as {
|
|
60
|
+
data: { analytics: AnalyticsData } | undefined;
|
|
61
|
+
isLoading: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const { data: popularData, isLoading: popularLoading } = api.popular.useQuery(
|
|
65
|
+
{ limit: "15" },
|
|
66
|
+
) as {
|
|
67
|
+
data: { terms: PopularTerm[] } | undefined;
|
|
68
|
+
isLoading: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const { data: zeroData, isLoading: zeroLoading } = api.zeroResults.useQuery({
|
|
72
|
+
limit: "15",
|
|
73
|
+
}) as {
|
|
74
|
+
data: { terms: PopularTerm[] } | undefined;
|
|
75
|
+
isLoading: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const { data: synonymsData, isLoading: synonymsLoading } =
|
|
79
|
+
api.synonyms.useQuery({}) as {
|
|
80
|
+
data: { synonyms: Synonym[] } | undefined;
|
|
81
|
+
isLoading: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const addMutation = api.addSynonym.useMutation({
|
|
85
|
+
onSettled: () => {
|
|
86
|
+
void api.synonyms.invalidate();
|
|
87
|
+
setNewTerm("");
|
|
88
|
+
setNewSynonyms("");
|
|
89
|
+
},
|
|
90
|
+
onError: () => {
|
|
91
|
+
setError("Failed to add synonym.");
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const removeMutation = api.removeSynonym.useMutation({
|
|
96
|
+
onSettled: () => {
|
|
97
|
+
void api.synonyms.invalidate();
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const analytics = analyticsData?.analytics;
|
|
102
|
+
const popularTerms = popularData?.terms ?? [];
|
|
103
|
+
const zeroResultTerms = zeroData?.terms ?? [];
|
|
104
|
+
const synonyms = synonymsData?.synonyms ?? [];
|
|
105
|
+
const loading =
|
|
106
|
+
analyticsLoading || popularLoading || zeroLoading || synonymsLoading;
|
|
107
|
+
|
|
108
|
+
const handleAddSynonym = () => {
|
|
109
|
+
setError("");
|
|
110
|
+
const term = newTerm.trim();
|
|
111
|
+
const syns = newSynonyms
|
|
112
|
+
.split(",")
|
|
113
|
+
.map((s) => s.trim())
|
|
114
|
+
.filter((s) => s.length > 0);
|
|
115
|
+
if (!term || syns.length === 0) {
|
|
116
|
+
setError("Enter a term and at least one synonym.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
addMutation.mutate({ term, synonyms: syns });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (loading && !analytics) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="py-16 text-center">
|
|
125
|
+
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-foreground" />
|
|
126
|
+
<p className="mt-4 text-muted-foreground text-sm">
|
|
127
|
+
Loading search analytics...
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="space-y-8">
|
|
135
|
+
{/* Stats overview */}
|
|
136
|
+
{analytics && (
|
|
137
|
+
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
|
138
|
+
<StatCard
|
|
139
|
+
label="Total Searches"
|
|
140
|
+
value={analytics.totalQueries.toLocaleString()}
|
|
141
|
+
/>
|
|
142
|
+
<StatCard
|
|
143
|
+
label="Unique Terms"
|
|
144
|
+
value={analytics.uniqueTerms.toLocaleString()}
|
|
145
|
+
/>
|
|
146
|
+
<StatCard label="Avg Results" value={analytics.avgResultCount} />
|
|
147
|
+
<StatCard
|
|
148
|
+
label="Zero Results"
|
|
149
|
+
value={analytics.zeroResultCount.toLocaleString()}
|
|
150
|
+
/>
|
|
151
|
+
<StatCard
|
|
152
|
+
label="Zero Result Rate"
|
|
153
|
+
value={`${analytics.zeroResultRate}%`}
|
|
154
|
+
/>
|
|
155
|
+
<StatCard
|
|
156
|
+
label="Indexed Items"
|
|
157
|
+
value={analytics.indexedItems.toLocaleString()}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
<div className="grid gap-8 md:grid-cols-2">
|
|
163
|
+
{/* Popular terms */}
|
|
164
|
+
<div className="rounded-lg border border-border bg-background">
|
|
165
|
+
<div className="border-border border-b px-5 py-3">
|
|
166
|
+
<h3 className="font-medium text-foreground text-sm">
|
|
167
|
+
Popular Search Terms
|
|
168
|
+
</h3>
|
|
169
|
+
</div>
|
|
170
|
+
{popularTerms.length === 0 ? (
|
|
171
|
+
<p className="px-5 py-6 text-center text-muted-foreground text-sm">
|
|
172
|
+
No search data yet.
|
|
173
|
+
</p>
|
|
174
|
+
) : (
|
|
175
|
+
<div className="divide-y divide-border">
|
|
176
|
+
{popularTerms.map((t) => (
|
|
177
|
+
<div
|
|
178
|
+
key={t.term}
|
|
179
|
+
className="flex items-center justify-between px-5 py-2.5"
|
|
180
|
+
>
|
|
181
|
+
<span className="text-foreground text-sm">{t.term}</span>
|
|
182
|
+
<span className="text-muted-foreground text-xs">
|
|
183
|
+
{t.count} searches · {t.avgResultCount} avg results
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* Zero result queries */}
|
|
192
|
+
<div className="rounded-lg border border-border bg-background">
|
|
193
|
+
<div className="border-border border-b px-5 py-3">
|
|
194
|
+
<h3 className="font-medium text-foreground text-sm">
|
|
195
|
+
Zero-Result Queries
|
|
196
|
+
</h3>
|
|
197
|
+
</div>
|
|
198
|
+
{zeroResultTerms.length === 0 ? (
|
|
199
|
+
<p className="px-5 py-6 text-center text-muted-foreground text-sm">
|
|
200
|
+
No zero-result queries yet.
|
|
201
|
+
</p>
|
|
202
|
+
) : (
|
|
203
|
+
<div className="divide-y divide-border">
|
|
204
|
+
{zeroResultTerms.map((t) => (
|
|
205
|
+
<div
|
|
206
|
+
key={t.term}
|
|
207
|
+
className="flex items-center justify-between px-5 py-2.5"
|
|
208
|
+
>
|
|
209
|
+
<span className="text-foreground text-sm">{t.term}</span>
|
|
210
|
+
<span className="text-muted-foreground text-xs">
|
|
211
|
+
{t.count} times
|
|
212
|
+
</span>
|
|
213
|
+
</div>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Synonyms management */}
|
|
221
|
+
<div className="rounded-lg border border-border bg-background">
|
|
222
|
+
<div className="border-border border-b px-5 py-3">
|
|
223
|
+
<h3 className="font-medium text-foreground text-sm">
|
|
224
|
+
Search Synonyms
|
|
225
|
+
</h3>
|
|
226
|
+
</div>
|
|
227
|
+
<div className="p-5">
|
|
228
|
+
<div className="mb-4 flex flex-col gap-2 sm:flex-row">
|
|
229
|
+
<input
|
|
230
|
+
type="text"
|
|
231
|
+
value={newTerm}
|
|
232
|
+
onChange={(e) => setNewTerm(e.target.value)}
|
|
233
|
+
placeholder="Term (e.g. tee)"
|
|
234
|
+
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
|
235
|
+
/>
|
|
236
|
+
<input
|
|
237
|
+
type="text"
|
|
238
|
+
value={newSynonyms}
|
|
239
|
+
onChange={(e) => setNewSynonyms(e.target.value)}
|
|
240
|
+
placeholder="Synonyms, comma separated (e.g. t-shirt, shirt)"
|
|
241
|
+
className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
|
242
|
+
/>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={handleAddSynonym}
|
|
246
|
+
disabled={addMutation.isPending}
|
|
247
|
+
className="rounded-md bg-primary px-4 py-1.5 font-medium text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
|
|
248
|
+
>
|
|
249
|
+
Add
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
{error && <p className="mb-3 text-destructive text-sm">{error}</p>}
|
|
253
|
+
{synonyms.length === 0 ? (
|
|
254
|
+
<p className="py-4 text-center text-muted-foreground text-sm">
|
|
255
|
+
No synonyms configured yet.
|
|
256
|
+
</p>
|
|
257
|
+
) : (
|
|
258
|
+
<div className="divide-y divide-border rounded-md border border-border">
|
|
259
|
+
{synonyms.map((syn) => (
|
|
260
|
+
<div
|
|
261
|
+
key={syn.id}
|
|
262
|
+
className="flex items-center justify-between px-4 py-2.5"
|
|
263
|
+
>
|
|
264
|
+
<div className="text-sm">
|
|
265
|
+
<span className="font-medium text-foreground">
|
|
266
|
+
{syn.term}
|
|
267
|
+
</span>
|
|
268
|
+
<span className="mx-2 text-muted-foreground">→</span>
|
|
269
|
+
<span className="text-muted-foreground">
|
|
270
|
+
{syn.synonyms.join(", ")}
|
|
271
|
+
</span>
|
|
272
|
+
</div>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
onClick={() =>
|
|
276
|
+
removeMutation.mutate({
|
|
277
|
+
params: { id: syn.id },
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
className="text-muted-foreground text-xs hover:text-destructive"
|
|
281
|
+
>
|
|
282
|
+
Remove
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
))}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createAdminEndpoint } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const analyticsEndpoint = createAdminEndpoint(
|
|
5
|
+
"/admin/search/analytics",
|
|
6
|
+
{ method: "GET" },
|
|
7
|
+
async (ctx) => {
|
|
8
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
9
|
+
const [analytics, indexCount] = await Promise.all([
|
|
10
|
+
controller.getAnalytics(),
|
|
11
|
+
controller.getIndexCount(),
|
|
12
|
+
]);
|
|
13
|
+
return { analytics: { ...analytics, indexedItems: indexCount } };
|
|
14
|
+
},
|
|
15
|
+
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const indexItem = createAdminEndpoint(
|
|
5
|
+
"/admin/search/index",
|
|
6
|
+
{
|
|
7
|
+
method: "POST",
|
|
8
|
+
body: z.object({
|
|
9
|
+
entityType: z.string().min(1).max(100),
|
|
10
|
+
entityId: z.string().min(1).max(200),
|
|
11
|
+
title: z.string().min(1).max(500),
|
|
12
|
+
body: z.string().max(10000).optional(),
|
|
13
|
+
tags: z.array(z.string().max(100)).max(50).optional(),
|
|
14
|
+
url: z.string().min(1).max(500),
|
|
15
|
+
image: z.string().max(500).optional(),
|
|
16
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
async (ctx) => {
|
|
20
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
21
|
+
const item = await controller.indexItem(ctx.body);
|
|
22
|
+
return { item };
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const removeFromIndex = createAdminEndpoint(
|
|
27
|
+
"/admin/search/index/remove",
|
|
28
|
+
{
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: z.object({
|
|
31
|
+
entityType: z.string().min(1).max(100),
|
|
32
|
+
entityId: z.string().min(1).max(200),
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
async (ctx) => {
|
|
36
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
37
|
+
const removed = await controller.removeFromIndex(
|
|
38
|
+
ctx.body.entityType,
|
|
39
|
+
ctx.body.entityId,
|
|
40
|
+
);
|
|
41
|
+
return { removed };
|
|
42
|
+
},
|
|
43
|
+
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { analyticsEndpoint } from "./analytics";
|
|
2
|
+
import { indexItem, removeFromIndex } from "./index-manage";
|
|
3
|
+
import { popularEndpoint } from "./popular";
|
|
4
|
+
import { addSynonym, listSynonyms, removeSynonym } from "./synonyms";
|
|
5
|
+
import { zeroResultsEndpoint } from "./zero-results";
|
|
6
|
+
|
|
7
|
+
export const adminEndpoints = {
|
|
8
|
+
"/admin/search/analytics": analyticsEndpoint,
|
|
9
|
+
"/admin/search/popular": popularEndpoint,
|
|
10
|
+
"/admin/search/zero-results": zeroResultsEndpoint,
|
|
11
|
+
"/admin/search/synonyms": listSynonyms,
|
|
12
|
+
"/admin/search/synonyms/add": addSynonym,
|
|
13
|
+
"/admin/search/synonyms/:id/delete": removeSynonym,
|
|
14
|
+
"/admin/search/index": indexItem,
|
|
15
|
+
"/admin/search/index/remove": removeFromIndex,
|
|
16
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const popularEndpoint = createAdminEndpoint(
|
|
5
|
+
"/admin/search/popular",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
10
|
+
}),
|
|
11
|
+
},
|
|
12
|
+
async (ctx) => {
|
|
13
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
14
|
+
const terms = await controller.getPopularTerms(ctx.query.limit ?? 20);
|
|
15
|
+
return { terms };
|
|
16
|
+
},
|
|
17
|
+
);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const listSynonyms = createAdminEndpoint(
|
|
5
|
+
"/admin/search/synonyms",
|
|
6
|
+
{ method: "GET" },
|
|
7
|
+
async (ctx) => {
|
|
8
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
9
|
+
const synonyms = await controller.listSynonyms();
|
|
10
|
+
return { synonyms };
|
|
11
|
+
},
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const addSynonym = createAdminEndpoint(
|
|
15
|
+
"/admin/search/synonyms/add",
|
|
16
|
+
{
|
|
17
|
+
method: "POST",
|
|
18
|
+
body: z.object({
|
|
19
|
+
term: z.string().min(1).max(200),
|
|
20
|
+
synonyms: z.array(z.string().min(1).max(200)).min(1).max(50),
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
async (ctx) => {
|
|
24
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
25
|
+
const synonym = await controller.addSynonym(
|
|
26
|
+
ctx.body.term,
|
|
27
|
+
ctx.body.synonyms,
|
|
28
|
+
);
|
|
29
|
+
return { synonym };
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const removeSynonym = createAdminEndpoint(
|
|
34
|
+
"/admin/search/synonyms/:id/delete",
|
|
35
|
+
{
|
|
36
|
+
method: "POST",
|
|
37
|
+
params: z.object({
|
|
38
|
+
id: z.string(),
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
async (ctx) => {
|
|
42
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
43
|
+
const removed = await controller.removeSynonym(ctx.params.id);
|
|
44
|
+
if (!removed) {
|
|
45
|
+
throw new Error("Synonym not found");
|
|
46
|
+
}
|
|
47
|
+
return { success: true };
|
|
48
|
+
},
|
|
49
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { SearchController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const zeroResultsEndpoint = createAdminEndpoint(
|
|
5
|
+
"/admin/search/zero-results",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
10
|
+
}),
|
|
11
|
+
},
|
|
12
|
+
async (ctx) => {
|
|
13
|
+
const controller = ctx.context.controllers.search as SearchController;
|
|
14
|
+
const terms = await controller.getZeroResultQueries(ctx.query.limit ?? 20);
|
|
15
|
+
return { terms };
|
|
16
|
+
},
|
|
17
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Module, ModuleConfig, ModuleContext } from "@86d-app/core";
|
|
2
|
+
import { adminEndpoints } from "./admin/endpoints";
|
|
3
|
+
import { searchSchema } from "./schema";
|
|
4
|
+
import { createSearchController } from "./service-impl";
|
|
5
|
+
import { storeEndpoints } from "./store/endpoints";
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
SearchController,
|
|
9
|
+
SearchIndexItem,
|
|
10
|
+
SearchQuery,
|
|
11
|
+
SearchResult,
|
|
12
|
+
SearchSynonym,
|
|
13
|
+
} from "./service";
|
|
14
|
+
|
|
15
|
+
export interface SearchOptions extends ModuleConfig {
|
|
16
|
+
/** Maximum number of search results per query */
|
|
17
|
+
maxResults?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function search(options?: SearchOptions): Module {
|
|
21
|
+
return {
|
|
22
|
+
id: "search",
|
|
23
|
+
version: "0.0.1",
|
|
24
|
+
schema: searchSchema,
|
|
25
|
+
exports: {
|
|
26
|
+
read: ["searchIndexCount", "popularTerms"],
|
|
27
|
+
},
|
|
28
|
+
events: {
|
|
29
|
+
emits: ["search.queried", "search.indexed", "search.removed"],
|
|
30
|
+
},
|
|
31
|
+
init: async (ctx: ModuleContext) => {
|
|
32
|
+
const controller = createSearchController(ctx.data);
|
|
33
|
+
return { controllers: { search: controller } };
|
|
34
|
+
},
|
|
35
|
+
search: { store: "/search/store-search" },
|
|
36
|
+
endpoints: {
|
|
37
|
+
store: storeEndpoints,
|
|
38
|
+
admin: adminEndpoints,
|
|
39
|
+
},
|
|
40
|
+
admin: {
|
|
41
|
+
pages: [
|
|
42
|
+
{
|
|
43
|
+
path: "/admin/search",
|
|
44
|
+
component: "SearchAnalytics",
|
|
45
|
+
label: "Search",
|
|
46
|
+
icon: "MagnifyingGlass",
|
|
47
|
+
group: "Marketing",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
store: {
|
|
52
|
+
pages: [
|
|
53
|
+
{
|
|
54
|
+
path: "/search",
|
|
55
|
+
component: "SearchPage",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
options,
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/mdx.d.ts
ADDED
package/src/schema.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ModuleSchema } from "@86d-app/core";
|
|
2
|
+
|
|
3
|
+
export const searchSchema = {
|
|
4
|
+
searchIndex: {
|
|
5
|
+
fields: {
|
|
6
|
+
id: { type: "string", required: true },
|
|
7
|
+
entityType: { type: "string", required: true },
|
|
8
|
+
entityId: { type: "string", required: true },
|
|
9
|
+
title: { type: "string", required: true },
|
|
10
|
+
body: { type: "string", required: false },
|
|
11
|
+
tags: { type: "json", required: false, defaultValue: [] },
|
|
12
|
+
url: { type: "string", required: true },
|
|
13
|
+
image: { type: "string", required: false },
|
|
14
|
+
metadata: { type: "json", required: false, defaultValue: {} },
|
|
15
|
+
indexedAt: {
|
|
16
|
+
type: "date",
|
|
17
|
+
required: true,
|
|
18
|
+
defaultValue: () => new Date(),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
searchQuery: {
|
|
23
|
+
fields: {
|
|
24
|
+
id: { type: "string", required: true },
|
|
25
|
+
term: { type: "string", required: true },
|
|
26
|
+
normalizedTerm: { type: "string", required: true },
|
|
27
|
+
resultCount: { type: "number", required: true },
|
|
28
|
+
sessionId: { type: "string", required: false },
|
|
29
|
+
searchedAt: {
|
|
30
|
+
type: "date",
|
|
31
|
+
required: true,
|
|
32
|
+
defaultValue: () => new Date(),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
searchSynonym: {
|
|
37
|
+
fields: {
|
|
38
|
+
id: { type: "string", required: true },
|
|
39
|
+
term: { type: "string", required: true },
|
|
40
|
+
synonyms: { type: "json", required: true },
|
|
41
|
+
createdAt: {
|
|
42
|
+
type: "date",
|
|
43
|
+
required: true,
|
|
44
|
+
defaultValue: () => new Date(),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
} satisfies ModuleSchema;
|