@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,395 @@
|
|
|
1
|
+
import type { ModuleDataService } from "@86d-app/core";
|
|
2
|
+
import type {
|
|
3
|
+
SearchController,
|
|
4
|
+
SearchIndexItem,
|
|
5
|
+
SearchQuery,
|
|
6
|
+
SearchResult,
|
|
7
|
+
SearchSynonym,
|
|
8
|
+
} from "./service";
|
|
9
|
+
|
|
10
|
+
function normalize(text: string): string {
|
|
11
|
+
return text.toLowerCase().trim().replace(/\s+/g, " ");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function tokenize(text: string): string[] {
|
|
15
|
+
return normalize(text)
|
|
16
|
+
.split(/[\s\-_/,.]+/)
|
|
17
|
+
.filter((t) => t.length > 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scoreMatch(
|
|
21
|
+
item: SearchIndexItem,
|
|
22
|
+
queryTokens: string[],
|
|
23
|
+
expandedTerms: Set<string>,
|
|
24
|
+
): number {
|
|
25
|
+
let score = 0;
|
|
26
|
+
const titleLower = normalize(item.title);
|
|
27
|
+
const bodyLower = item.body ? normalize(item.body) : "";
|
|
28
|
+
const tagLower = item.tags.map((t) => normalize(t));
|
|
29
|
+
|
|
30
|
+
for (const token of queryTokens) {
|
|
31
|
+
const allTerms = [token, ...expandedTerms];
|
|
32
|
+
for (const term of allTerms) {
|
|
33
|
+
// Exact title match is highest value
|
|
34
|
+
if (titleLower === term) {
|
|
35
|
+
score += 100;
|
|
36
|
+
} else if (titleLower.startsWith(term)) {
|
|
37
|
+
score += 50;
|
|
38
|
+
} else if (titleLower.includes(term)) {
|
|
39
|
+
score += 25;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Body match
|
|
43
|
+
if (bodyLower.includes(term)) {
|
|
44
|
+
score += 10;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Tag match
|
|
48
|
+
for (const tag of tagLower) {
|
|
49
|
+
if (tag === term) {
|
|
50
|
+
score += 30;
|
|
51
|
+
} else if (tag.includes(term)) {
|
|
52
|
+
score += 15;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return score;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSearchController(
|
|
62
|
+
data: ModuleDataService,
|
|
63
|
+
): SearchController {
|
|
64
|
+
return {
|
|
65
|
+
async indexItem(params) {
|
|
66
|
+
// Check if already indexed — update if so
|
|
67
|
+
const existing = await data.findMany("searchIndex", {
|
|
68
|
+
where: {
|
|
69
|
+
entityType: params.entityType,
|
|
70
|
+
entityId: params.entityId,
|
|
71
|
+
},
|
|
72
|
+
take: 1,
|
|
73
|
+
});
|
|
74
|
+
const existingItems = existing as unknown as SearchIndexItem[];
|
|
75
|
+
|
|
76
|
+
const id =
|
|
77
|
+
existingItems.length > 0 ? existingItems[0].id : crypto.randomUUID();
|
|
78
|
+
const item: SearchIndexItem = {
|
|
79
|
+
id,
|
|
80
|
+
entityType: params.entityType,
|
|
81
|
+
entityId: params.entityId,
|
|
82
|
+
title: params.title,
|
|
83
|
+
body: params.body,
|
|
84
|
+
tags: params.tags ?? [],
|
|
85
|
+
url: params.url,
|
|
86
|
+
image: params.image,
|
|
87
|
+
metadata: params.metadata ?? {},
|
|
88
|
+
indexedAt: new Date(),
|
|
89
|
+
};
|
|
90
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
91
|
+
await data.upsert("searchIndex", id, item as Record<string, any>);
|
|
92
|
+
return item;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async removeFromIndex(entityType, entityId) {
|
|
96
|
+
const items = await data.findMany("searchIndex", {
|
|
97
|
+
where: { entityType, entityId },
|
|
98
|
+
});
|
|
99
|
+
const found = items as unknown as SearchIndexItem[];
|
|
100
|
+
if (found.length === 0) return false;
|
|
101
|
+
for (const item of found) {
|
|
102
|
+
await data.delete("searchIndex", item.id);
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async search(query, options) {
|
|
108
|
+
const limit = options?.limit ?? 20;
|
|
109
|
+
const skip = options?.skip ?? 0;
|
|
110
|
+
const queryTokens = tokenize(query);
|
|
111
|
+
|
|
112
|
+
if (queryTokens.length === 0) {
|
|
113
|
+
return { results: [], total: 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Load synonyms for query expansion
|
|
117
|
+
const allSynonyms = (await data.findMany(
|
|
118
|
+
"searchSynonym",
|
|
119
|
+
{},
|
|
120
|
+
)) as unknown as SearchSynonym[];
|
|
121
|
+
const expandedTerms = new Set<string>();
|
|
122
|
+
for (const syn of allSynonyms) {
|
|
123
|
+
const synTermNorm = normalize(syn.term);
|
|
124
|
+
for (const token of queryTokens) {
|
|
125
|
+
if (token === synTermNorm) {
|
|
126
|
+
for (const s of syn.synonyms) {
|
|
127
|
+
expandedTerms.add(normalize(s));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Also reverse: if a query token matches a synonym, expand to the term
|
|
131
|
+
for (const s of syn.synonyms) {
|
|
132
|
+
if (normalize(s) === token) {
|
|
133
|
+
expandedTerms.add(synTermNorm);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
|
|
140
|
+
const where: Record<string, any> = {};
|
|
141
|
+
if (options?.entityType) {
|
|
142
|
+
where.entityType = options.entityType;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const allItems = (await data.findMany("searchIndex", {
|
|
146
|
+
...(Object.keys(where).length > 0 ? { where } : {}),
|
|
147
|
+
})) as unknown as SearchIndexItem[];
|
|
148
|
+
|
|
149
|
+
// Score and rank
|
|
150
|
+
const scored: SearchResult[] = [];
|
|
151
|
+
for (const item of allItems) {
|
|
152
|
+
const score = scoreMatch(item, queryTokens, expandedTerms);
|
|
153
|
+
if (score > 0) {
|
|
154
|
+
scored.push({ item, score });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
scored.sort((a, b) => b.score - a.score);
|
|
159
|
+
const total = scored.length;
|
|
160
|
+
const results = scored.slice(skip, skip + limit);
|
|
161
|
+
|
|
162
|
+
return { results, total };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async suggest(prefix, limit = 10) {
|
|
166
|
+
const prefixNorm = normalize(prefix);
|
|
167
|
+
if (prefixNorm.length === 0) return [];
|
|
168
|
+
|
|
169
|
+
// Combine popular terms + index titles
|
|
170
|
+
const allQueries = (await data.findMany(
|
|
171
|
+
"searchQuery",
|
|
172
|
+
{},
|
|
173
|
+
)) as unknown as SearchQuery[];
|
|
174
|
+
|
|
175
|
+
// Count query frequency
|
|
176
|
+
const termCounts = new Map<string, number>();
|
|
177
|
+
for (const q of allQueries) {
|
|
178
|
+
if (q.resultCount > 0 && q.normalizedTerm.startsWith(prefixNorm)) {
|
|
179
|
+
termCounts.set(q.term, (termCounts.get(q.term) ?? 0) + 1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Also match index titles
|
|
184
|
+
const allItems = (await data.findMany(
|
|
185
|
+
"searchIndex",
|
|
186
|
+
{},
|
|
187
|
+
)) as unknown as SearchIndexItem[];
|
|
188
|
+
const titleSuggestions: string[] = [];
|
|
189
|
+
for (const item of allItems) {
|
|
190
|
+
if (normalize(item.title).includes(prefixNorm)) {
|
|
191
|
+
titleSuggestions.push(item.title);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Merge: popular terms first, then title suggestions
|
|
196
|
+
const popularTerms = Array.from(termCounts.entries())
|
|
197
|
+
.sort((a, b) => b[1] - a[1])
|
|
198
|
+
.map(([term]) => term);
|
|
199
|
+
|
|
200
|
+
const seen = new Set<string>();
|
|
201
|
+
const suggestions: string[] = [];
|
|
202
|
+
for (const term of popularTerms) {
|
|
203
|
+
const norm = normalize(term);
|
|
204
|
+
if (!seen.has(norm)) {
|
|
205
|
+
seen.add(norm);
|
|
206
|
+
suggestions.push(term);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const title of titleSuggestions) {
|
|
210
|
+
const norm = normalize(title);
|
|
211
|
+
if (!seen.has(norm)) {
|
|
212
|
+
seen.add(norm);
|
|
213
|
+
suggestions.push(title);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return suggestions.slice(0, limit);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async recordQuery(term, resultCount, sessionId) {
|
|
221
|
+
const id = crypto.randomUUID();
|
|
222
|
+
const query: SearchQuery = {
|
|
223
|
+
id,
|
|
224
|
+
term,
|
|
225
|
+
normalizedTerm: normalize(term),
|
|
226
|
+
resultCount,
|
|
227
|
+
sessionId,
|
|
228
|
+
searchedAt: new Date(),
|
|
229
|
+
};
|
|
230
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
231
|
+
await data.upsert("searchQuery", id, query as Record<string, any>);
|
|
232
|
+
return query;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async getRecentQueries(sessionId, limit = 10) {
|
|
236
|
+
const all = (await data.findMany("searchQuery", {
|
|
237
|
+
where: { sessionId },
|
|
238
|
+
})) as unknown as SearchQuery[];
|
|
239
|
+
|
|
240
|
+
// Sort by date desc, deduplicate by normalized term
|
|
241
|
+
all.sort(
|
|
242
|
+
(a, b) =>
|
|
243
|
+
new Date(b.searchedAt).getTime() - new Date(a.searchedAt).getTime(),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const seen = new Set<string>();
|
|
247
|
+
const results: SearchQuery[] = [];
|
|
248
|
+
for (const q of all) {
|
|
249
|
+
if (!seen.has(q.normalizedTerm)) {
|
|
250
|
+
seen.add(q.normalizedTerm);
|
|
251
|
+
results.push(q);
|
|
252
|
+
}
|
|
253
|
+
if (results.length >= limit) break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return results;
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async getPopularTerms(limit = 20) {
|
|
260
|
+
const all = (await data.findMany(
|
|
261
|
+
"searchQuery",
|
|
262
|
+
{},
|
|
263
|
+
)) as unknown as SearchQuery[];
|
|
264
|
+
|
|
265
|
+
const termStats = new Map<
|
|
266
|
+
string,
|
|
267
|
+
{ term: string; count: number; totalResults: number }
|
|
268
|
+
>();
|
|
269
|
+
for (const q of all) {
|
|
270
|
+
const existing = termStats.get(q.normalizedTerm);
|
|
271
|
+
if (existing) {
|
|
272
|
+
existing.count += 1;
|
|
273
|
+
existing.totalResults += q.resultCount;
|
|
274
|
+
} else {
|
|
275
|
+
termStats.set(q.normalizedTerm, {
|
|
276
|
+
term: q.term,
|
|
277
|
+
count: 1,
|
|
278
|
+
totalResults: q.resultCount,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return Array.from(termStats.values())
|
|
284
|
+
.map((s) => ({
|
|
285
|
+
term: s.term,
|
|
286
|
+
count: s.count,
|
|
287
|
+
avgResultCount: Math.round(s.totalResults / s.count),
|
|
288
|
+
}))
|
|
289
|
+
.sort((a, b) => b.count - a.count)
|
|
290
|
+
.slice(0, limit);
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async getZeroResultQueries(limit = 20) {
|
|
294
|
+
const all = (await data.findMany(
|
|
295
|
+
"searchQuery",
|
|
296
|
+
{},
|
|
297
|
+
)) as unknown as SearchQuery[];
|
|
298
|
+
|
|
299
|
+
const termStats = new Map<string, { term: string; count: number }>();
|
|
300
|
+
for (const q of all) {
|
|
301
|
+
if (q.resultCount === 0) {
|
|
302
|
+
const existing = termStats.get(q.normalizedTerm);
|
|
303
|
+
if (existing) {
|
|
304
|
+
existing.count += 1;
|
|
305
|
+
} else {
|
|
306
|
+
termStats.set(q.normalizedTerm, {
|
|
307
|
+
term: q.term,
|
|
308
|
+
count: 1,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return Array.from(termStats.values())
|
|
315
|
+
.map((s) => ({
|
|
316
|
+
term: s.term,
|
|
317
|
+
count: s.count,
|
|
318
|
+
avgResultCount: 0,
|
|
319
|
+
}))
|
|
320
|
+
.sort((a, b) => b.count - a.count)
|
|
321
|
+
.slice(0, limit);
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async getAnalytics() {
|
|
325
|
+
const all = (await data.findMany(
|
|
326
|
+
"searchQuery",
|
|
327
|
+
{},
|
|
328
|
+
)) as unknown as SearchQuery[];
|
|
329
|
+
|
|
330
|
+
if (all.length === 0) {
|
|
331
|
+
return {
|
|
332
|
+
totalQueries: 0,
|
|
333
|
+
uniqueTerms: 0,
|
|
334
|
+
avgResultCount: 0,
|
|
335
|
+
zeroResultCount: 0,
|
|
336
|
+
zeroResultRate: 0,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const uniqueTerms = new Set(all.map((q) => q.normalizedTerm));
|
|
341
|
+
const totalResults = all.reduce((sum, q) => sum + q.resultCount, 0);
|
|
342
|
+
const zeroResultCount = all.filter((q) => q.resultCount === 0).length;
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
totalQueries: all.length,
|
|
346
|
+
uniqueTerms: uniqueTerms.size,
|
|
347
|
+
avgResultCount: Math.round(totalResults / all.length),
|
|
348
|
+
zeroResultCount,
|
|
349
|
+
zeroResultRate: Math.round((zeroResultCount / all.length) * 100),
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
async addSynonym(term, synonyms) {
|
|
354
|
+
// Check if synonym for this term already exists
|
|
355
|
+
const existing = await data.findMany("searchSynonym", {
|
|
356
|
+
where: { term: normalize(term) },
|
|
357
|
+
take: 1,
|
|
358
|
+
});
|
|
359
|
+
const existingItems = existing as unknown as SearchSynonym[];
|
|
360
|
+
|
|
361
|
+
const id =
|
|
362
|
+
existingItems.length > 0 ? existingItems[0].id : crypto.randomUUID();
|
|
363
|
+
const synonym: SearchSynonym = {
|
|
364
|
+
id,
|
|
365
|
+
term: normalize(term),
|
|
366
|
+
synonyms: synonyms.map((s) => s.trim()),
|
|
367
|
+
createdAt:
|
|
368
|
+
existingItems.length > 0 ? existingItems[0].createdAt : new Date(),
|
|
369
|
+
};
|
|
370
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
371
|
+
await data.upsert("searchSynonym", id, synonym as Record<string, any>);
|
|
372
|
+
return synonym;
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
async removeSynonym(id) {
|
|
376
|
+
const existing = await data.get("searchSynonym", id);
|
|
377
|
+
if (!existing) return false;
|
|
378
|
+
await data.delete("searchSynonym", id);
|
|
379
|
+
return true;
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
async listSynonyms() {
|
|
383
|
+
const all = (await data.findMany(
|
|
384
|
+
"searchSynonym",
|
|
385
|
+
{},
|
|
386
|
+
)) as unknown as SearchSynonym[];
|
|
387
|
+
return all;
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
async getIndexCount() {
|
|
391
|
+
const all = await data.findMany("searchIndex", {});
|
|
392
|
+
return all.length;
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ModuleController } from "@86d-app/core";
|
|
2
|
+
|
|
3
|
+
export interface SearchIndexItem {
|
|
4
|
+
id: string;
|
|
5
|
+
entityType: string;
|
|
6
|
+
entityId: string;
|
|
7
|
+
title: string;
|
|
8
|
+
body?: string | undefined;
|
|
9
|
+
tags: string[];
|
|
10
|
+
url: string;
|
|
11
|
+
image?: string | undefined;
|
|
12
|
+
metadata: Record<string, unknown>;
|
|
13
|
+
indexedAt: Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SearchQuery {
|
|
17
|
+
id: string;
|
|
18
|
+
term: string;
|
|
19
|
+
normalizedTerm: string;
|
|
20
|
+
resultCount: number;
|
|
21
|
+
sessionId?: string | undefined;
|
|
22
|
+
searchedAt: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SearchSynonym {
|
|
26
|
+
id: string;
|
|
27
|
+
term: string;
|
|
28
|
+
synonyms: string[];
|
|
29
|
+
createdAt: Date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SearchResult {
|
|
33
|
+
item: SearchIndexItem;
|
|
34
|
+
score: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SearchAnalyticsSummary {
|
|
38
|
+
totalQueries: number;
|
|
39
|
+
uniqueTerms: number;
|
|
40
|
+
avgResultCount: number;
|
|
41
|
+
zeroResultCount: number;
|
|
42
|
+
zeroResultRate: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PopularTerm {
|
|
46
|
+
term: string;
|
|
47
|
+
count: number;
|
|
48
|
+
avgResultCount: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SearchController extends ModuleController {
|
|
52
|
+
indexItem(params: {
|
|
53
|
+
entityType: string;
|
|
54
|
+
entityId: string;
|
|
55
|
+
title: string;
|
|
56
|
+
body?: string | undefined;
|
|
57
|
+
tags?: string[] | undefined;
|
|
58
|
+
url: string;
|
|
59
|
+
image?: string | undefined;
|
|
60
|
+
metadata?: Record<string, unknown> | undefined;
|
|
61
|
+
}): Promise<SearchIndexItem>;
|
|
62
|
+
|
|
63
|
+
removeFromIndex(entityType: string, entityId: string): Promise<boolean>;
|
|
64
|
+
|
|
65
|
+
search(
|
|
66
|
+
query: string,
|
|
67
|
+
options?: {
|
|
68
|
+
entityType?: string | undefined;
|
|
69
|
+
limit?: number | undefined;
|
|
70
|
+
skip?: number | undefined;
|
|
71
|
+
},
|
|
72
|
+
): Promise<{ results: SearchResult[]; total: number }>;
|
|
73
|
+
|
|
74
|
+
suggest(prefix: string, limit?: number): Promise<string[]>;
|
|
75
|
+
|
|
76
|
+
recordQuery(
|
|
77
|
+
term: string,
|
|
78
|
+
resultCount: number,
|
|
79
|
+
sessionId?: string | undefined,
|
|
80
|
+
): Promise<SearchQuery>;
|
|
81
|
+
|
|
82
|
+
getRecentQueries(sessionId: string, limit?: number): Promise<SearchQuery[]>;
|
|
83
|
+
|
|
84
|
+
getPopularTerms(limit?: number): Promise<PopularTerm[]>;
|
|
85
|
+
|
|
86
|
+
getZeroResultQueries(limit?: number): Promise<PopularTerm[]>;
|
|
87
|
+
|
|
88
|
+
getAnalytics(): Promise<SearchAnalyticsSummary>;
|
|
89
|
+
|
|
90
|
+
addSynonym(term: string, synonyms: string[]): Promise<SearchSynonym>;
|
|
91
|
+
|
|
92
|
+
removeSynonym(id: string): Promise<boolean>;
|
|
93
|
+
|
|
94
|
+
listSynonyms(): Promise<SearchSynonym[]>;
|
|
95
|
+
|
|
96
|
+
getIndexCount(): Promise<number>;
|
|
97
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
|
|
5
|
+
export function useSearchApi() {
|
|
6
|
+
const client = useModuleClient();
|
|
7
|
+
return {
|
|
8
|
+
search: client.module("search").store["/search"],
|
|
9
|
+
suggest: client.module("search").store["/search/suggest"],
|
|
10
|
+
recent: client.module("search").store["/search/recent"],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { MDXComponents } from "mdx/types";
|
|
4
|
+
import { SearchBar } from "./search-bar";
|
|
5
|
+
import { SearchPage } from "./search-page";
|
|
6
|
+
import { SearchResults } from "./search-results";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
SearchBar,
|
|
10
|
+
SearchResults,
|
|
11
|
+
SearchPage,
|
|
12
|
+
} satisfies MDXComponents;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useSearchApi } from "./_hooks";
|
|
5
|
+
|
|
6
|
+
interface SearchBarProps {
|
|
7
|
+
placeholder?: string | undefined;
|
|
8
|
+
onSearch?: ((query: string) => void) | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SearchBar({
|
|
12
|
+
placeholder = "Search...",
|
|
13
|
+
onSearch,
|
|
14
|
+
}: SearchBarProps) {
|
|
15
|
+
const api = useSearchApi();
|
|
16
|
+
const [query, setQuery] = useState("");
|
|
17
|
+
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
18
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
19
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
20
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
21
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
|
|
23
|
+
const { data: suggestData } =
|
|
24
|
+
query.trim().length >= 2
|
|
25
|
+
? (api.suggest.useQuery({ q: query.trim(), limit: "8" }) as {
|
|
26
|
+
data: { suggestions: string[] } | undefined;
|
|
27
|
+
})
|
|
28
|
+
: { data: undefined };
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (suggestData?.suggestions) {
|
|
32
|
+
setSuggestions(suggestData.suggestions);
|
|
33
|
+
} else {
|
|
34
|
+
setSuggestions([]);
|
|
35
|
+
}
|
|
36
|
+
}, [suggestData]);
|
|
37
|
+
|
|
38
|
+
const handleSubmit = useCallback(
|
|
39
|
+
(term: string) => {
|
|
40
|
+
const trimmed = term.trim();
|
|
41
|
+
if (trimmed.length === 0) return;
|
|
42
|
+
setShowSuggestions(false);
|
|
43
|
+
setSelectedIndex(-1);
|
|
44
|
+
onSearch?.(trimmed);
|
|
45
|
+
},
|
|
46
|
+
[onSearch],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
50
|
+
if (e.key === "ArrowDown") {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1));
|
|
53
|
+
} else if (e.key === "ArrowUp") {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
setSelectedIndex((i) => Math.max(i - 1, -1));
|
|
56
|
+
} else if (e.key === "Enter") {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
|
59
|
+
setQuery(suggestions[selectedIndex]);
|
|
60
|
+
handleSubmit(suggestions[selectedIndex]);
|
|
61
|
+
} else {
|
|
62
|
+
handleSubmit(query);
|
|
63
|
+
}
|
|
64
|
+
} else if (e.key === "Escape") {
|
|
65
|
+
setShowSuggestions(false);
|
|
66
|
+
setSelectedIndex(-1);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Close suggestions on click outside
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const handleClick = (e: MouseEvent) => {
|
|
73
|
+
if (
|
|
74
|
+
containerRef.current &&
|
|
75
|
+
!containerRef.current.contains(e.target as Node)
|
|
76
|
+
) {
|
|
77
|
+
setShowSuggestions(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
document.addEventListener("mousedown", handleClick);
|
|
81
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div ref={containerRef} className="relative w-full">
|
|
86
|
+
<div className="relative">
|
|
87
|
+
<svg
|
|
88
|
+
className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
89
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
90
|
+
fill="none"
|
|
91
|
+
viewBox="0 0 24 24"
|
|
92
|
+
strokeWidth={2}
|
|
93
|
+
stroke="currentColor"
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
>
|
|
96
|
+
<title>Search</title>
|
|
97
|
+
<path
|
|
98
|
+
strokeLinecap="round"
|
|
99
|
+
strokeLinejoin="round"
|
|
100
|
+
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"
|
|
101
|
+
/>
|
|
102
|
+
</svg>
|
|
103
|
+
<input
|
|
104
|
+
ref={inputRef}
|
|
105
|
+
type="search"
|
|
106
|
+
value={query}
|
|
107
|
+
onChange={(e) => {
|
|
108
|
+
setQuery(e.target.value);
|
|
109
|
+
setShowSuggestions(true);
|
|
110
|
+
setSelectedIndex(-1);
|
|
111
|
+
}}
|
|
112
|
+
onFocus={() => setShowSuggestions(true)}
|
|
113
|
+
onKeyDown={handleKeyDown}
|
|
114
|
+
placeholder={placeholder}
|
|
115
|
+
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"
|
|
116
|
+
aria-label="Search"
|
|
117
|
+
aria-autocomplete="list"
|
|
118
|
+
aria-expanded={showSuggestions && suggestions.length > 0}
|
|
119
|
+
role="combobox"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{showSuggestions && suggestions.length > 0 && (
|
|
124
|
+
<div
|
|
125
|
+
role="listbox"
|
|
126
|
+
className="absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-border bg-background shadow-lg"
|
|
127
|
+
>
|
|
128
|
+
{suggestions.map((suggestion, index) => (
|
|
129
|
+
<div
|
|
130
|
+
key={suggestion}
|
|
131
|
+
role="option"
|
|
132
|
+
tabIndex={-1}
|
|
133
|
+
aria-selected={index === selectedIndex}
|
|
134
|
+
className={`cursor-pointer px-4 py-2 text-sm ${
|
|
135
|
+
index === selectedIndex
|
|
136
|
+
? "bg-muted text-foreground"
|
|
137
|
+
: "text-foreground hover:bg-muted/50"
|
|
138
|
+
}`}
|
|
139
|
+
onMouseDown={(e) => {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
setQuery(suggestion);
|
|
142
|
+
handleSubmit(suggestion);
|
|
143
|
+
}}
|
|
144
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
145
|
+
>
|
|
146
|
+
{suggestion}
|
|
147
|
+
</div>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { SearchBar } from "./search-bar";
|
|
5
|
+
import { SearchResults } from "./search-results";
|
|
6
|
+
|
|
7
|
+
export function SearchPage({ sessionId }: { sessionId?: string | undefined }) {
|
|
8
|
+
const [query, setQuery] = useState("");
|
|
9
|
+
|
|
10
|
+
const handleSearch = useCallback((term: string) => {
|
|
11
|
+
setQuery(term);
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="mx-auto max-w-2xl px-4 py-8">
|
|
16
|
+
<h1 className="mb-6 font-semibold text-2xl text-foreground">Search</h1>
|
|
17
|
+
<SearchBar
|
|
18
|
+
placeholder="Search products, articles..."
|
|
19
|
+
onSearch={handleSearch}
|
|
20
|
+
/>
|
|
21
|
+
<div className="mt-6">
|
|
22
|
+
<SearchResults query={query} sessionId={sessionId} />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|