@meeovi/layer-search 1.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 +181 -0
- package/app/components/README.md +3 -0
- package/app/components/atoms/BaseButton.vue +36 -0
- package/app/components/atoms/BaseCard.vue +13 -0
- package/app/components/atoms/BaseCheckbox.vue +51 -0
- package/app/components/atoms/BaseLogo.vue +19 -0
- package/app/components/atoms/BaseText.vue +17 -0
- package/app/components/atoms/BaseTitle.vue +23 -0
- package/app/components/atoms/DiscordIcon.vue +14 -0
- package/app/components/atoms/GithubIcon.vue +14 -0
- package/app/components/atoms/HalfSolidStarIcon.vue +5 -0
- package/app/components/atoms/SelectArrow.vue +5 -0
- package/app/components/atoms/SolidStarIcon.vue +5 -0
- package/app/components/atoms/StarIcon.vue +5 -0
- package/app/components/atoms/TwitterIcon.vue +14 -0
- package/app/components/atoms/WebIcon.vue +12 -0
- package/app/components/atoms/XIcon.vue +5 -0
- package/app/components/features/aiSearch.vue +0 -0
- package/app/components/features/allSearch.vue +0 -0
- package/app/components/features/autocomplete.vue +0 -0
- package/app/components/features/imageSearch.vue +0 -0
- package/app/components/features/videoSearch.vue +0 -0
- package/app/components/filters/filters.vue +0 -0
- package/app/components/molecules/BaseSelect.vue +53 -0
- package/app/components/molecules/PageNumber.vue +54 -0
- package/app/components/molecules/RangeSlider.vue +37 -0
- package/app/components/molecules/SearchInput.vue +32 -0
- package/app/components/molecules/SocialLink.vue +42 -0
- package/app/components/molecules/StarRating.vue +48 -0
- package/app/components/organisms/LoadingIndicator.vue +12 -0
- package/app/components/organisms/MeiliSearchBar.vue +15 -0
- package/app/components/organisms/MeiliSearchFacetFilter.vue +116 -0
- package/app/components/organisms/MeiliSearchLoadingProvider.vue +29 -0
- package/app/components/organisms/MeiliSearchPagination.vue +40 -0
- package/app/components/organisms/MeiliSearchProvider.vue +51 -0
- package/app/components/organisms/MeiliSearchRangeFilter.vue +52 -0
- package/app/components/organisms/MeiliSearchRatingFilter.vue +47 -0
- package/app/components/organisms/MeiliSearchResults.vue +35 -0
- package/app/components/organisms/MeiliSearchSorting.vue +23 -0
- package/app/components/organisms/MeiliSearchStats.vue +13 -0
- package/app/components/organisms/ProductCard.vue +80 -0
- package/app/components/organisms/TheNavbar.vue +71 -0
- package/app/components/results/audioSearch.vue +7 -0
- package/app/components/results/booksSearch.vue +7 -0
- package/app/components/results/financeSearch.vue +93 -0
- package/app/components/results/imageSearch.vue +7 -0
- package/app/components/results/musicSearch.vue +93 -0
- package/app/components/results/newsSearch.vue +93 -0
- package/app/components/results/spaceSearch.vue +93 -0
- package/app/components/results/spacesSearch.vue +7 -0
- package/app/components/results/travelSearch.vue +93 -0
- package/app/components/results/videoSearch.vue +7 -0
- package/app/components/search.vue +87 -0
- package/app/components/templates/HomeTemplate.vue +44 -0
- package/app/components/widgets/ClearRefinements.vue +27 -0
- package/app/components/widgets/NoResults.vue +125 -0
- package/app/components/widgets/PriceSlider.css +58 -0
- package/app/composables/adapter/meilisearch.ts +58 -0
- package/app/composables/adapter/mock.ts +34 -0
- package/app/composables/adapter/opensearch.ts +66 -0
- package/app/composables/adapter/types.ts +14 -0
- package/app/composables/bridges/instantsearch.ts +20 -0
- package/app/composables/bridges/react.ts +40 -0
- package/app/composables/bridges/vue.ts +38 -0
- package/app/composables/cli.ts +85 -0
- package/app/composables/config/schema.ts +16 -0
- package/app/composables/config.ts +20 -0
- package/app/composables/core/Facets.ts +9 -0
- package/app/composables/core/Filters.ts +13 -0
- package/app/composables/core/Normalizers.ts +0 -0
- package/app/composables/core/Pipeline.ts +20 -0
- package/app/composables/core/QueryBuilder.ts +27 -0
- package/app/composables/core/SearchContext.ts +54 -0
- package/app/composables/core/SearchManager.ts +27 -0
- package/app/composables/events.ts +6 -0
- package/app/composables/index.ts +9 -0
- package/app/composables/module.ts +72 -0
- package/app/composables/types/api/global-search.ts +8 -0
- package/app/composables/types.d.ts +12 -0
- package/app/composables/utils/normalizers.ts +6 -0
- package/app/pages/results.vue +85 -0
- package/app/plugins/instantsearch.js +35 -0
- package/app/plugins/search.js +20 -0
- package/nuxt.config.ts +11 -0
- package/package.json +43 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="state && state.results && state.results.nbHits === 0">
|
|
3
|
+
<div class="hits-empty-state">
|
|
4
|
+
<svg
|
|
5
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
6
|
+
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
7
|
+
width="138"
|
|
8
|
+
height="138"
|
|
9
|
+
class="hits-empty-state-image"
|
|
10
|
+
>
|
|
11
|
+
<defs>
|
|
12
|
+
<linearGradient id="c" x1="50%" x2="50%" y1="100%" y2="0%">
|
|
13
|
+
<stop offset="0%" stop-color="#F5F5FA" />
|
|
14
|
+
<stop offset="100%" stop-color="#FFF" />
|
|
15
|
+
</linearGradient>
|
|
16
|
+
<path
|
|
17
|
+
id="b"
|
|
18
|
+
d="M68.71 114.25a45.54 45.54 0 1 1 0-91.08 45.54 45.54 0 0 1 0 91.08z"
|
|
19
|
+
/>
|
|
20
|
+
<filter
|
|
21
|
+
id="a"
|
|
22
|
+
width="140.6%"
|
|
23
|
+
height="140.6%"
|
|
24
|
+
x="-20.3%"
|
|
25
|
+
y="-15.9%"
|
|
26
|
+
filterUnits="objectBoundingBox"
|
|
27
|
+
>
|
|
28
|
+
<feOffset dy="4" in="SourceAlpha" result="shadowOffsetOuter1" />
|
|
29
|
+
<feGaussianBlur
|
|
30
|
+
in="shadowOffsetOuter1"
|
|
31
|
+
result="shadowBlurOuter1"
|
|
32
|
+
stdDeviation="5.5"
|
|
33
|
+
/>
|
|
34
|
+
<feColorMatrix
|
|
35
|
+
in="shadowBlurOuter1"
|
|
36
|
+
result="shadowMatrixOuter1"
|
|
37
|
+
values="0 0 0 0 0.145098039 0 0 0 0 0.17254902 0 0 0 0 0.380392157 0 0 0 0.15 0"
|
|
38
|
+
/>
|
|
39
|
+
<feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter2" />
|
|
40
|
+
<feGaussianBlur
|
|
41
|
+
in="shadowOffsetOuter2"
|
|
42
|
+
result="shadowBlurOuter2"
|
|
43
|
+
stdDeviation="1.5"
|
|
44
|
+
/>
|
|
45
|
+
<feColorMatrix
|
|
46
|
+
in="shadowBlurOuter2"
|
|
47
|
+
result="shadowMatrixOuter2"
|
|
48
|
+
values="0 0 0 0 0.364705882 0 0 0 0 0.392156863 0 0 0 0 0.580392157 0 0 0 0.2 0"
|
|
49
|
+
/>
|
|
50
|
+
<feMerge>
|
|
51
|
+
<feMergeNode in="shadowMatrixOuter1" />
|
|
52
|
+
<feMergeNode in="shadowMatrixOuter2" />
|
|
53
|
+
</feMerge>
|
|
54
|
+
</filter>
|
|
55
|
+
</defs>
|
|
56
|
+
<g fill="none" fill-rule="evenodd">
|
|
57
|
+
<circle
|
|
58
|
+
cx="68.85"
|
|
59
|
+
cy="68.85"
|
|
60
|
+
r="68.85"
|
|
61
|
+
fill="#5468FF"
|
|
62
|
+
opacity=".07"
|
|
63
|
+
/>
|
|
64
|
+
<circle
|
|
65
|
+
cx="68.85"
|
|
66
|
+
cy="68.85"
|
|
67
|
+
r="52.95"
|
|
68
|
+
fill="#5468FF"
|
|
69
|
+
opacity=".08"
|
|
70
|
+
/>
|
|
71
|
+
<use fill="#000" filter="url(#a)" xlink:href="#b" />
|
|
72
|
+
<use fill="url(#c)" xlink:href="#b" />
|
|
73
|
+
<path
|
|
74
|
+
d="M76.01 75.44c5-5 5.03-13.06.07-18.01a12.73 12.73 0 0 0-18 .07c-5 4.99-5.03 13.05-.07 18a12.73 12.73 0 0 0 18-.06zm2.5 2.5a16.28 16.28 0 0 1-23.02.09A16.29 16.29 0 0 1 55.57 55a16.28 16.28 0 0 1 23.03-.1 16.28 16.28 0 0 1-.08 23.04zm1.08-1.08l-2.15 2.16 8.6 8.6 2.16-2.15-8.6-8.6z"
|
|
75
|
+
fill="#5369FF"
|
|
76
|
+
/>
|
|
77
|
+
</g>
|
|
78
|
+
</svg>
|
|
79
|
+
|
|
80
|
+
<p class="hits-empty-state-title">
|
|
81
|
+
Sorry, we can't find any matches to your query!
|
|
82
|
+
</p>
|
|
83
|
+
<p class="hits-empty-state-description">
|
|
84
|
+
{{
|
|
85
|
+
state.results.getRefinements().length > 0
|
|
86
|
+
? 'Try to reset your applied filters.'
|
|
87
|
+
: 'Please try another query.'
|
|
88
|
+
}}
|
|
89
|
+
</p>
|
|
90
|
+
|
|
91
|
+
<NuxtLinkis-clear-refinements>
|
|
92
|
+
<template #resetLabel>
|
|
93
|
+
<div class="clear-filters">
|
|
94
|
+
<svg
|
|
95
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
96
|
+
width="11"
|
|
97
|
+
height="11"
|
|
98
|
+
viewBox="0 0 11 11"
|
|
99
|
+
>
|
|
100
|
+
<g fill="none" fill-rule="evenodd">
|
|
101
|
+
<path d="M0 0h11v11H0z" />
|
|
102
|
+
<path
|
|
103
|
+
fill="#000"
|
|
104
|
+
fill-rule="nonzero"
|
|
105
|
+
d="M8.26 2.75a3.896 3.896 0 1 0 1.102 3.262l.007-.056a.49.49 0 0 1 .485-.456c.253 0 .451.206.437.457 0 0 .012-.109-.006.061a4.813 4.813 0 1 1-1.348-3.887v-.987a.458.458 0 1 1 .917.002v2.062a.459.459 0 0 1-.459.459H7.334a.458.458 0 1 1-.002-.917h.928z"
|
|
106
|
+
/>
|
|
107
|
+
</g>
|
|
108
|
+
</svg>
|
|
109
|
+
Clear filters
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
</ais-clear-refinements>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<script>
|
|
118
|
+
import { connectHits } from 'instantsearch.js/es/connectors';
|
|
119
|
+
import { createWidgetMixin } from 'vue-instantsearch';
|
|
120
|
+
|
|
121
|
+
export default {
|
|
122
|
+
name: 'AppNoResults',
|
|
123
|
+
mixins: [createWidgetMixin({ connector: connectHits })],
|
|
124
|
+
};
|
|
125
|
+
</script>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
.vue-slider {
|
|
2
|
+
display: flex;
|
|
3
|
+
justify-content: center;
|
|
4
|
+
margin-top: 1.5rem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.vue-slider-rail {
|
|
8
|
+
background-color: rgba(65, 66, 71, 0.08);
|
|
9
|
+
border-radius: 3px;
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
height: 4px;
|
|
12
|
+
width: calc(100% - 10px);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.vue-slider-process {
|
|
16
|
+
background-color: #e2a400;
|
|
17
|
+
border-radius: 3px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.vue-slider-dot-tooltip {
|
|
21
|
+
cursor: grab;
|
|
22
|
+
display: flex;
|
|
23
|
+
font-size: 0.75rem;
|
|
24
|
+
font-weight: bold;
|
|
25
|
+
margin-top: 5px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.vue-slider-dot-tooltip::before {
|
|
29
|
+
color: #e2a400;
|
|
30
|
+
content: '$';
|
|
31
|
+
font-size: 0.6;
|
|
32
|
+
margin-right: 4px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.vue-slider-dot-tooltip-text .vue-slider-dot-tooltip-inner {
|
|
36
|
+
cursor: -webkit-grabbing;
|
|
37
|
+
cursor: -moz-grabbing;
|
|
38
|
+
text-align: center;
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.vue-slider-dot-handle {
|
|
43
|
+
background-image: linear-gradient(to top, #f5f5fa, #fff);
|
|
44
|
+
border-radius: 50%;
|
|
45
|
+
box-shadow: 0 4px 11px 0 rgba(37, 44, 97, 0.15),
|
|
46
|
+
0 2px 3px 0 rgba(93, 100, 148, 0.2);
|
|
47
|
+
cursor: -webkit-grabbing;
|
|
48
|
+
cursor: -moz-grabbing;
|
|
49
|
+
height: 100%;
|
|
50
|
+
width: 100%;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (max-width: 899px) {
|
|
54
|
+
.vue-slider-dot {
|
|
55
|
+
height: 24px !important;
|
|
56
|
+
width: 24px !important;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAlternateAdapter,
|
|
3
|
+
type SearchAdapter,
|
|
4
|
+
type SearchAdapterConfig,
|
|
5
|
+
type SearchResult
|
|
6
|
+
} from '@meeovi/core'
|
|
7
|
+
|
|
8
|
+
import type { MeeoviSearchItem } from './types'
|
|
9
|
+
import type { BuiltSearchQuery } from '../core/QueryBuilder'
|
|
10
|
+
|
|
11
|
+
interface MeiliConfig extends SearchAdapterConfig {
|
|
12
|
+
host: string
|
|
13
|
+
apiKey?: string
|
|
14
|
+
index: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createMeilisearchAdapter(config: MeiliConfig) {
|
|
18
|
+
const headers: Record<string, string> = {
|
|
19
|
+
'Content-Type': 'application/json'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (config.apiKey) {
|
|
23
|
+
headers['Authorization'] = `Bearer ${config.apiKey}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const adapter: SearchAdapter<MeeoviSearchItem> = {
|
|
27
|
+
id: 'search:meilisearch',
|
|
28
|
+
type: 'search',
|
|
29
|
+
config,
|
|
30
|
+
|
|
31
|
+
async search(query: BuiltSearchQuery): Promise<SearchResult<MeeoviSearchItem>> {
|
|
32
|
+
const params = new URLSearchParams({
|
|
33
|
+
q: query.term,
|
|
34
|
+
offset: String((query.page - 1) * query.pageSize),
|
|
35
|
+
limit: String(query.pageSize)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const res = await fetch(`${config.host}/indexes/${config.index}/search?${params}`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers,
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
filter: Object.entries(query.filters).map(([field, value]) => `${field} = ${value}`)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const json = await res.json()
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
items: json.hits as MeeoviSearchItem[],
|
|
50
|
+
total: json.estimatedTotalHits ?? json.hits.length,
|
|
51
|
+
page: query.page,
|
|
52
|
+
pageSize: query.pageSize
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return defineAlternateAdapter(adapter)
|
|
58
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAlternateAdapter,
|
|
3
|
+
type SearchAdapter,
|
|
4
|
+
type SearchAdapterConfig,
|
|
5
|
+
type SearchResult
|
|
6
|
+
} from '@meeovi/core'
|
|
7
|
+
|
|
8
|
+
import type { MeeoviSearchItem } from './types'
|
|
9
|
+
import type { BuiltSearchQuery } from '../core/QueryBuilder'
|
|
10
|
+
|
|
11
|
+
export function createMockSearchAdapter(items: MeeoviSearchItem[] = []) {
|
|
12
|
+
const cfg: SearchAdapterConfig = { provider: 'mock' }
|
|
13
|
+
|
|
14
|
+
const adapter: SearchAdapter<MeeoviSearchItem> = {
|
|
15
|
+
id: 'search:mock',
|
|
16
|
+
type: 'search',
|
|
17
|
+
config: cfg,
|
|
18
|
+
|
|
19
|
+
async search(query: BuiltSearchQuery): Promise<SearchResult<MeeoviSearchItem>> {
|
|
20
|
+
const filtered = items.filter((item) =>
|
|
21
|
+
item.title.toLowerCase().includes(query.term.toLowerCase())
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
items: filtered,
|
|
26
|
+
total: filtered.length,
|
|
27
|
+
page: 1,
|
|
28
|
+
pageSize: filtered.length
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return defineAlternateAdapter(adapter)
|
|
34
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAlternateAdapter,
|
|
3
|
+
type SearchAdapter,
|
|
4
|
+
type SearchAdapterConfig,
|
|
5
|
+
type SearchResult
|
|
6
|
+
} from '@meeovi/core'
|
|
7
|
+
|
|
8
|
+
import type { MeeoviSearchItem } from './types'
|
|
9
|
+
import type { BuiltSearchQuery } from '../core/QueryBuilder'
|
|
10
|
+
import { normalizeOpenSearchHit } from '../utils/normalizers'
|
|
11
|
+
|
|
12
|
+
interface OpenSearchConfig extends SearchAdapterConfig {
|
|
13
|
+
endpoint: string
|
|
14
|
+
index: string
|
|
15
|
+
apiKey?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createOpenSearchAdapter(config: OpenSearchConfig) {
|
|
19
|
+
const adapter: SearchAdapter<MeeoviSearchItem> = {
|
|
20
|
+
id: 'search:opensearch',
|
|
21
|
+
type: 'search',
|
|
22
|
+
config,
|
|
23
|
+
|
|
24
|
+
async search(query: BuiltSearchQuery): Promise<SearchResult<MeeoviSearchItem>> {
|
|
25
|
+
const body: any = {
|
|
26
|
+
query: {
|
|
27
|
+
bool: {
|
|
28
|
+
must: [
|
|
29
|
+
{
|
|
30
|
+
multi_match: {
|
|
31
|
+
query: query.term,
|
|
32
|
+
fields: ['title^3', 'description', 'tags']
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
filter: Object.entries(query.filters).map(([field, value]) => ({
|
|
37
|
+
term: { [field]: value }
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
from: (query.page - 1) * query.pageSize,
|
|
42
|
+
size: query.pageSize
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const res = await fetch(`${config.endpoint}/${config.index}/_search`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {})
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const json = await res.json()
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
items: json.hits.hits.map(normalizeOpenSearchHit),
|
|
58
|
+
total: json.hits.total.value,
|
|
59
|
+
page: query.page,
|
|
60
|
+
pageSize: query.pageSize
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return defineAlternateAdapter(adapter)
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SearchAdapter, SearchResult } from '@meeovi/core'
|
|
2
|
+
import type { BuiltSearchQuery } from '../core/QueryBuilder'
|
|
3
|
+
|
|
4
|
+
export interface MeeoviSearchItem {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
price?: number
|
|
9
|
+
[key: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MeeoviSearchAdapter = SearchAdapter<MeeoviSearchItem> & {
|
|
13
|
+
search(query: BuiltSearchQuery): Promise<SearchResult<MeeoviSearchItem>>
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SearchManager } from '../core/SearchManager'
|
|
2
|
+
|
|
3
|
+
export function createInstantSearchBridge(manager: SearchManager) {
|
|
4
|
+
return {
|
|
5
|
+
searchFunction(helper: any) {
|
|
6
|
+
manager.context.setQuery(helper.state.query || '')
|
|
7
|
+
manager.context.setPage(helper.state.page || 1)
|
|
8
|
+
// map filters if needed from helper.state
|
|
9
|
+
|
|
10
|
+
return manager.search().then((result) => {
|
|
11
|
+
helper.setResults({
|
|
12
|
+
hits: result.items,
|
|
13
|
+
nbHits: result.total,
|
|
14
|
+
page: result.page - 1,
|
|
15
|
+
hitsPerPage: result.pageSize
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
import { useAlternateContext } from '@meeovi/core'
|
|
3
|
+
import type { SearchManager } from '../core/SearchManager'
|
|
4
|
+
|
|
5
|
+
export function useReactSearch() {
|
|
6
|
+
const ctx = useAlternateContext() as any
|
|
7
|
+
const manager = ctx.searchManager as SearchManager
|
|
8
|
+
|
|
9
|
+
const [query, setQuery] = useState(manager.context.state.query)
|
|
10
|
+
const [page, setPage] = useState(manager.context.state.page)
|
|
11
|
+
const [pageSize, setPageSize] = useState(manager.context.state.pageSize)
|
|
12
|
+
const [results, setResults] = useState<any[]>([])
|
|
13
|
+
const [total, setTotal] = useState(0)
|
|
14
|
+
const [loading, setLoading] = useState(false)
|
|
15
|
+
|
|
16
|
+
const search = useCallback(async () => {
|
|
17
|
+
setLoading(true)
|
|
18
|
+
manager.context.setQuery(query)
|
|
19
|
+
manager.context.setPage(page)
|
|
20
|
+
manager.context.setPageSize(pageSize)
|
|
21
|
+
|
|
22
|
+
const res = await manager.search()
|
|
23
|
+
setResults(res.items)
|
|
24
|
+
setTotal(res.total)
|
|
25
|
+
setLoading(false)
|
|
26
|
+
}, [query, page, pageSize, manager])
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
query,
|
|
30
|
+
setQuery,
|
|
31
|
+
page,
|
|
32
|
+
setPage,
|
|
33
|
+
pageSize,
|
|
34
|
+
setPageSize,
|
|
35
|
+
results,
|
|
36
|
+
total,
|
|
37
|
+
loading,
|
|
38
|
+
search
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
import { useAlternateContext } from '@meeovi/core'
|
|
3
|
+
import type { SearchManager } from '../core/SearchManager'
|
|
4
|
+
|
|
5
|
+
export function useSearch() {
|
|
6
|
+
const ctx = useAlternateContext() as any
|
|
7
|
+
const manager = ctx.searchManager as SearchManager
|
|
8
|
+
|
|
9
|
+
const query = ref(manager.context.state.query)
|
|
10
|
+
const page = ref(manager.context.state.page)
|
|
11
|
+
const pageSize = ref(manager.context.state.pageSize)
|
|
12
|
+
|
|
13
|
+
const results = ref<any[]>([])
|
|
14
|
+
const total = ref(0)
|
|
15
|
+
const loading = ref(false)
|
|
16
|
+
|
|
17
|
+
async function run() {
|
|
18
|
+
loading.value = true
|
|
19
|
+
manager.context.setQuery(query.value)
|
|
20
|
+
manager.context.setPage(page.value)
|
|
21
|
+
manager.context.setPageSize(pageSize.value)
|
|
22
|
+
|
|
23
|
+
const res = await manager.search()
|
|
24
|
+
results.value = res.items
|
|
25
|
+
total.value = res.total
|
|
26
|
+
loading.value = false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
query,
|
|
31
|
+
page,
|
|
32
|
+
pageSize,
|
|
33
|
+
results: computed(() => results.value),
|
|
34
|
+
total: computed(() => total.value),
|
|
35
|
+
loading: computed(() => loading.value),
|
|
36
|
+
search: run
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { createAlternateApp } from '@meeovi/core'
|
|
7
|
+
import searchModule from './module'
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
|
|
11
|
+
async function run() {
|
|
12
|
+
const [,, command, arg] = process.argv
|
|
13
|
+
|
|
14
|
+
const defaultProvider = process.env.SEARCH_PROVIDER === 'meilisearch' ? 'meilisearch' : 'opensearch'
|
|
15
|
+
|
|
16
|
+
const app = createAlternateApp({
|
|
17
|
+
config: {
|
|
18
|
+
env: 'production',
|
|
19
|
+
search: {
|
|
20
|
+
defaultProvider,
|
|
21
|
+
providers: {
|
|
22
|
+
opensearch: {
|
|
23
|
+
endpoint: process.env.OPENSEARCH_ENDPOINT,
|
|
24
|
+
index: process.env.OPENSEARCH_INDEX
|
|
25
|
+
},
|
|
26
|
+
meilisearch: {
|
|
27
|
+
host: process.env.MEILI_HOST,
|
|
28
|
+
index: process.env.MEILI_INDEX,
|
|
29
|
+
apiKey: process.env.MEILI_KEY
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
modules: [searchModule]
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const ctx = await app.start()
|
|
38
|
+
const search = ctx.getAdapter('search')
|
|
39
|
+
|
|
40
|
+
if (!search) {
|
|
41
|
+
console.error('No search adapter registered')
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (command === 'warmup') {
|
|
46
|
+
console.log('Warming up search provider...')
|
|
47
|
+
await search.search({ term: 'warmup' })
|
|
48
|
+
console.log('Warmup complete')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (command === 'index') {
|
|
53
|
+
if (!arg) {
|
|
54
|
+
console.error('Missing file path: meeovi-search index <file.json>')
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const filePath = path.resolve(process.cwd(), arg)
|
|
59
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
60
|
+
|
|
61
|
+
console.log(`Indexing ${data.length} items...`)
|
|
62
|
+
|
|
63
|
+
if (search.id === 'search:opensearch') {
|
|
64
|
+
// TODO: implement bulk indexing
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (search.id === 'search:meilisearch') {
|
|
68
|
+
await fetch(`${process.env.MEILI_HOST}/indexes/${process.env.MEILI_INDEX}/documents`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
Authorization: `Bearer ${process.env.MEILI_KEY}`
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(data)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log('Indexing complete')
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`Unknown command: ${command}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
run()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface SearchModuleConfig {
|
|
2
|
+
defaultProvider: 'opensearch' | 'meilisearch'
|
|
3
|
+
providers: Record<string, unknown>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function validateSearchConfig(config: SearchModuleConfig) {
|
|
7
|
+
if (!config.defaultProvider) {
|
|
8
|
+
throw new Error('[@meeovi/search] Missing defaultProvider')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!config.providers[config.defaultProvider]) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`[@meeovi/search] Provider "${config.defaultProvider}" not found in config.providers`
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
export interface SearchConfig {
|
|
3
|
+
searchProvider: string
|
|
4
|
+
searchUrl?: string
|
|
5
|
+
apiKey?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let config: SearchConfig = {
|
|
9
|
+
searchProvider: 'searchkit',
|
|
10
|
+
searchUrl: '',
|
|
11
|
+
apiKey: ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setSearchConfig(newConfig: Partial<SearchConfig>) {
|
|
15
|
+
config = { ...config, ...newConfig }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSearchConfig(): SearchConfig {
|
|
19
|
+
return config
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const Filters = {
|
|
2
|
+
term(field: string, value: string) {
|
|
3
|
+
return { type: 'term', field, value }
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
range(field: string, min?: number, max?: number) {
|
|
7
|
+
return { type: 'range', field, min, max }
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
boolean(field: string, value: boolean) {
|
|
11
|
+
return { type: 'boolean', field, value }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class SearchPipeline {
|
|
2
|
+
private before: ((q: any) => any)[] = []
|
|
3
|
+
private after: ((r: any) => any)[] = []
|
|
4
|
+
|
|
5
|
+
useBefore(fn: (query: any) => any) {
|
|
6
|
+
this.before.push(fn)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
useAfter(fn: (result: any) => any) {
|
|
10
|
+
this.after.push(fn)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
runBefore(query: any) {
|
|
14
|
+
return this.before.reduce((q, fn) => fn(q), query)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
runAfter(result: any) {
|
|
18
|
+
return this.after.reduce((r, fn) => fn(r), result)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SearchContext } from './SearchContext'
|
|
2
|
+
|
|
3
|
+
export interface BuiltSearchQuery {
|
|
4
|
+
term: string
|
|
5
|
+
page: number
|
|
6
|
+
pageSize: number
|
|
7
|
+
sort?: string
|
|
8
|
+
filters: Record<string, any>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class QueryBuilder {
|
|
12
|
+
private context: SearchContext
|
|
13
|
+
|
|
14
|
+
constructor(context: SearchContext) {
|
|
15
|
+
this.context = context
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
build(): BuiltSearchQuery {
|
|
19
|
+
return {
|
|
20
|
+
term: this.context.state.query,
|
|
21
|
+
page: this.context.state.page,
|
|
22
|
+
pageSize: this.context.state.pageSize,
|
|
23
|
+
sort: this.context.state.sort,
|
|
24
|
+
filters: this.context.state.filters
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|