@mframework/layer-search 0.0.1
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 +269 -0
- package/app/components/README.md +3 -0
- package/app/components/features/autocomplete.vue +63 -0
- package/app/components/features/searchkitSearch.vue +115 -0
- package/app/components/filters/filters.vue +0 -0
- package/app/components/molecules/SearchInput.vue +39 -0
- package/app/components/molecules/pagination.vue +21 -0
- package/app/components/molecules/resultList.vue +48 -0
- package/app/components/search.vue +87 -0
- package/app/composables/adapter/mock.ts +26 -0
- package/app/composables/adapter/types.ts +21 -0
- package/app/composables/bridges/instantsearch.ts +21 -0
- package/app/composables/bridges/react.ts +39 -0
- package/app/composables/bridges/searchkit-server.ts +51 -0
- package/app/composables/bridges/searchkit.ts +88 -0
- package/app/composables/bridges/vue.ts +38 -0
- package/app/composables/cli.ts +70 -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/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 +26 -0
- package/app/composables/events.ts +5 -0
- package/app/composables/index.ts +12 -0
- package/app/composables/module.ts +48 -0
- package/app/composables/types/api/global-search.ts +8 -0
- package/app/composables/types.d.ts +12 -0
- package/app/composables/useSearchkit.ts +218 -0
- package/app/composables/utils/health.ts +13 -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 +103 -0
- package/app/plugins/searchClient.ts +108 -0
- package/app/utils/env.ts +28 -0
- package/app/utils/search/client.ts +53 -0
- package/nuxt.config.ts +11 -0
- package/package.json +36 -0
- package/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
<!-- packages/search/README.md -->
|
|
2
|
+
# @mframework/layer-search
|
|
3
|
+
|
|
4
|
+
A modular, provider-agnostic search layer for the M Framework. It provides a unified, typed search API with pluggable adapters (Meilisearch, OpenSearch, mock adapters), lightweight bridges for UI integrations (InstantSearch / Searchkit), a small CLI for indexing/warmup, and event hooks.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- Plug-and-play adapters (Meilisearch, OpenSearch, Mock)
|
|
9
|
+
- Fully typed integration with `@mframework/core`
|
|
10
|
+
- Mock adapter for tests
|
|
11
|
+
- Small CLI for indexing and warmup
|
|
12
|
+
- Configuration validation and event hooks (`search:query`, `search:results`)
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Using npm:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @mframework/layer-search
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Using pnpm:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pnpm add @mframework/layer-search
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Usage
|
|
29
|
+
|
|
30
|
+
Register the module with an M Framework app:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createM FrameworkApp } from '@mframework/core'
|
|
34
|
+
import searchModule from '@mframework/layer-search'
|
|
35
|
+
|
|
36
|
+
const app = createM FrameworkApp({
|
|
37
|
+
config: {
|
|
38
|
+
search: {
|
|
39
|
+
defaultProvider: 'opensearch',
|
|
40
|
+
providers: {
|
|
41
|
+
opensearch: {
|
|
42
|
+
host: 'http://localhost:7700',
|
|
43
|
+
index: 'products',
|
|
44
|
+
apiKey: 'masterKey'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
modules: [searchModule]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await app.start()
|
|
53
|
+
|
|
54
|
+
// After app startup you can access the registered `search` adapter via the runtime
|
|
55
|
+
const searchAdapter = app.context.getAdapter('search')
|
|
56
|
+
if (searchAdapter) {
|
|
57
|
+
const results = await searchAdapter.search({ term: 'shoes', page: 1, pageSize: 10 })
|
|
58
|
+
console.log(results.items)
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Adapters
|
|
63
|
+
|
|
64
|
+
Adapters are intentionally provider-agnostic and may be supplied by external packages or by your application.
|
|
65
|
+
The core `@mframework/layer-search` layer does not bundle provider implementations; instead provide an adapter instance at startup
|
|
66
|
+
or register one at runtime so the search manager can be created.
|
|
67
|
+
|
|
68
|
+
Example (external adapter package or custom implementation):
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// import from an external adapter package or your own implementation
|
|
72
|
+
import { createMySearchAdapter } from 'my-search-adapter-package' /* @mframework/adapter-opensearch */
|
|
73
|
+
|
|
74
|
+
const adapter = createMySearchAdapter({ /* provider config */ })
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Register the adapter either when creating the M Framework app or at runtime (both shown below).
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Example `search` config in your app:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"search": {
|
|
86
|
+
"defaultProvider": "opensearch",
|
|
87
|
+
"providers": {
|
|
88
|
+
"opensearch": {
|
|
89
|
+
"endpoint": "https://my-opensearch.com",
|
|
90
|
+
"index": "products"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The module validates that `defaultProvider` and the referenced provider configuration exist, and that required fields for each adapter are present.
|
|
98
|
+
|
|
99
|
+
Note: this repository includes a top-level `.env.example` with recommended variables for Search and other providers; you can manage layer credentials from your main app's `.env` file. See `../.env.example`.
|
|
100
|
+
|
|
101
|
+
## UI Integrations (InstantSearch / Searchkit)
|
|
102
|
+
|
|
103
|
+
This layer includes bridges that let UI code use Algolia InstantSearch or Searchkit clients without coupling the UI to a particular backend provider. Use `createInstantSearchBridge` / `createSearchkitBridge` on the client, and `createSearchkitGraphQLHandler` on the server when exposing a GraphQL endpoint for Searchkit-server.
|
|
104
|
+
|
|
105
|
+
Example (client):
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { createInstantSearchBridge } from '@mframework/layer-search'
|
|
109
|
+
// `manager` is the `SearchManager` instance available on the app context
|
|
110
|
+
const bridge = createInstantSearchBridge(manager)
|
|
111
|
+
|
|
112
|
+
const instantsearchClient = {
|
|
113
|
+
search(requests) {
|
|
114
|
+
return bridge.searchFunction({ state: requests[0].params, setResults: () => {} })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Example (server - Express):
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import express from 'express'
|
|
123
|
+
import { createSearchkitGraphQLHandler } from '@mframework/layer-search'
|
|
124
|
+
|
|
125
|
+
const app = express()
|
|
126
|
+
app.use(express.json())
|
|
127
|
+
app.post('/graphql', createSearchkitGraphQLHandler(manager))
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
These bridges map InstantSearch/Searchkit request shapes into the layer's `SearchManager` and underlying adapters so UI code doesn't need to change when you swap search providers.
|
|
131
|
+
|
|
132
|
+
## Events
|
|
133
|
+
|
|
134
|
+
This module emits bus events that you can listen to:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
bus.on('search:query', ({ term }) => {
|
|
138
|
+
console.log('User searched for:', term)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
bus.on('search:results', ({ term, total }) => {
|
|
142
|
+
console.log(`Search for "${term}" returned ${total} results`)
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Registering external adapters**
|
|
147
|
+
|
|
148
|
+
There are two common ways to register a search adapter so the search layer can use it:
|
|
149
|
+
|
|
150
|
+
- Module-based (recommended at startup): create a small provider module that exposes the adapter via the module `adapters` property. The module registry will register the adapter before modules run.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { createM FrameworkApp } from '@mframework/core'
|
|
154
|
+
import searchModule from '@mframework/layer-search'
|
|
155
|
+
import { createMySearchAdapter } from 'my-search-adapter-package' /* @mframework/adapter-opensearch */
|
|
156
|
+
|
|
157
|
+
const myProviderModule = {
|
|
158
|
+
id: 'search-provider-my',
|
|
159
|
+
adapters: {
|
|
160
|
+
search: createMySearchAdapter({ /* config */ })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const app = createM FrameworkApp({
|
|
165
|
+
config: { /* ... */ },
|
|
166
|
+
modules: [searchModule, myProviderModule]
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
await app.start()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- Runtime registration: register an adapter into the core module registry at runtime. This is useful for registering adapters from other modules or dynamic initialization.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { createM FrameworkApp } from '@mframework/core'
|
|
176
|
+
import searchModule from '@mframework/layer-search'
|
|
177
|
+
import { createMySearchAdapter } from 'my-search-adapter-package' /* @mframework/adapter-opensearch */
|
|
178
|
+
|
|
179
|
+
const app = createM FrameworkApp({ modules: [searchModule] })
|
|
180
|
+
|
|
181
|
+
// register adapter before or after `app.start()`
|
|
182
|
+
app.context.modules.registerAdapter('search', createMySearchAdapter({ /* config */ }))
|
|
183
|
+
|
|
184
|
+
await app.start()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Notes:
|
|
188
|
+
|
|
189
|
+
- Many layers adopt a convention of emitting `adapter:registered` events; the search layer listens for adapter registrations and will initialize its `SearchManager` when a `search` adapter becomes available.
|
|
190
|
+
- If you publish adapters, prefer a small package such as `@your-org/adapter-mysearch` that exports a `createMySearchAdapter` factory so consumers can import and register it using one of the patterns above.
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
## CLI
|
|
194
|
+
|
|
195
|
+
Included CLI commands:
|
|
196
|
+
|
|
197
|
+
- Warmup: `meeovi-search warmup`
|
|
198
|
+
- Index a JSON file: `meeovi-search index ./products.json`
|
|
199
|
+
|
|
200
|
+
Environment variables supported (example for Meilisearch):
|
|
201
|
+
|
|
202
|
+
- `SEARCH_PROVIDER=opensearch`
|
|
203
|
+
- `MEILI_HOST=http://localhost:7700`
|
|
204
|
+
- `MEILI_INDEX=products`
|
|
205
|
+
- `MEILI_KEY=masterKey`
|
|
206
|
+
|
|
207
|
+
Searchkit / Search provider environment variables
|
|
208
|
+
|
|
209
|
+
- `SEARCHKIT_HOST` or `NUXT_PUBLIC_SEARCHKIT_HOST` — full host URL (e.g. `https://search.example.com`)
|
|
210
|
+
- Alternatively compose with:
|
|
211
|
+
- `SEARCHKIT_PROTOCOL` / `NUXT_PUBLIC_SEARCHKIT_PROTOCOL` (defaults to `http`)
|
|
212
|
+
- `SEARCHKIT_HOSTNAME` / `NUXT_PUBLIC_SEARCHKIT_HOSTNAME` (e.g. `search.example.com`)
|
|
213
|
+
- `SEARCHKIT_PORT` / `NUXT_PUBLIC_SEARCHKIT_PORT` (e.g. `9200`)
|
|
214
|
+
- Optional API key: `SEARCHKIT_API_KEY` or `NUXT_PUBLIC_SEARCHKIT_API_KEY`
|
|
215
|
+
|
|
216
|
+
Notes:
|
|
217
|
+
- Use the `NUXT_PUBLIC_` prefix for values that must be available in client-side code (public build). Keep API keys server-only when possible.
|
|
218
|
+
- The plugin logs a runtime validation message on startup if search provider configuration is missing, with examples of env vars to set.
|
|
219
|
+
|
|
220
|
+
Layer env conventions
|
|
221
|
+
|
|
222
|
+
- All layers use the same environment lookup convention: the code checks `KEY` and falls back to `NUXT_PUBLIC_KEY` when appropriate. This lets you manage provider credentials and endpoints centrally from your main application's `.env` file.
|
|
223
|
+
- Example `.env` entries in your main app to configure the Search layer:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
SEARCHKIT_HOST=https://search.example.com
|
|
227
|
+
SEARCHKIT_API_KEY=server-only-key
|
|
228
|
+
# or compose:
|
|
229
|
+
SEARCHKIT_PROTOCOL=https
|
|
230
|
+
SEARCHKIT_HOSTNAME=search.example.com
|
|
231
|
+
SEARCHKIT_PORT=9200
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Because every layer follows this `KEY` / `NUXT_PUBLIC_KEY` pattern, you can place settings in the main app's `.env` and they will be available to the layer at runtime without editing layer files.
|
|
235
|
+
|
|
236
|
+
## Testing
|
|
237
|
+
|
|
238
|
+
Use the mock adapter in tests to avoid external dependencies:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { createMockSearchAdapter } from '@mframework/layer-search'
|
|
242
|
+
|
|
243
|
+
const mock = createMockSearchAdapter([{ id: '1', title: 'Test Product' }])
|
|
244
|
+
const results = await mock.search({ term: 'test' })
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## File structure
|
|
248
|
+
|
|
249
|
+
Typical layout:
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
@mframework/layer-search
|
|
253
|
+
├─ src/
|
|
254
|
+
│ ├─ index.ts
|
|
255
|
+
│ ├─ module.ts
|
|
256
|
+
│ ├─ adapter/
|
|
257
|
+
│ │ ├─ mock.ts
|
|
258
|
+
│ │ └─ types.ts
|
|
259
|
+
│ ├─ config/schema.ts
|
|
260
|
+
│ ├─ events.ts
|
|
261
|
+
│ └─ utils/normalizers.ts
|
|
262
|
+
├─ cli.ts
|
|
263
|
+
├─ package.json
|
|
264
|
+
└─ README.md
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="autocomplete">
|
|
3
|
+
<input
|
|
4
|
+
v-model="input"
|
|
5
|
+
@input="onInput"
|
|
6
|
+
@keydown.enter.prevent="onSelectFirst"
|
|
7
|
+
:placeholder="placeholder"
|
|
8
|
+
class="autocomplete-input"
|
|
9
|
+
/>
|
|
10
|
+
<ul v-if="suggestions.length" class="autocomplete-list">
|
|
11
|
+
<li v-for="(s, i) in suggestions" :key="i" @click="select(s)">
|
|
12
|
+
<div class="title">{{ s.title || s.name || s.label || s.id }}</div>
|
|
13
|
+
<div class="subtitle" v-if="s.description">{{ s.description }}</div>
|
|
14
|
+
</li>
|
|
15
|
+
</ul>
|
|
16
|
+
</div>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { ref, watch } from 'vue'
|
|
21
|
+
import useSearchkit from '../../composables/useSearchkit'
|
|
22
|
+
|
|
23
|
+
const props = defineProps({ placeholder: { type: String, default: 'Search...' } })
|
|
24
|
+
const emits = defineEmits(['select', 'input'])
|
|
25
|
+
|
|
26
|
+
const { autocomplete } = useSearchkit()
|
|
27
|
+
const input = ref('')
|
|
28
|
+
const suggestions = ref<any[]>([])
|
|
29
|
+
let timer: any = null
|
|
30
|
+
|
|
31
|
+
async function onInput() {
|
|
32
|
+
emits('input', input.value)
|
|
33
|
+
clearTimeout(timer)
|
|
34
|
+
timer = setTimeout(async () => {
|
|
35
|
+
if (!input.value || input.value.length < 1) {
|
|
36
|
+
suggestions.value = []
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
suggestions.value = await autocomplete(input.value, 6)
|
|
40
|
+
}, 180)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function select(item: any) {
|
|
44
|
+
emits('select', item)
|
|
45
|
+
suggestions.value = []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function onSelectFirst() {
|
|
49
|
+
if (suggestions.value.length) select(suggestions.value[0])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
watch(input, (v) => {
|
|
53
|
+
if (!v) suggestions.value = []
|
|
54
|
+
})
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.autocomplete { position: relative; }
|
|
59
|
+
.autocomplete-list { position: absolute; left: 0; right: 0; background: white; z-index: 50; list-style: none; margin: 0; padding: 0; border: 1px solid #ddd; }
|
|
60
|
+
.autocomplete-list li { padding: 8px; cursor: pointer; }
|
|
61
|
+
.autocomplete-list li:hover { background: #f5f5f5; }
|
|
62
|
+
.autocomplete-input { width: 100%; padding: 8px; border: 1px solid #ccc; }
|
|
63
|
+
</style>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="searchkit-search">
|
|
3
|
+
<div class="controls">
|
|
4
|
+
<SearchInput v-model="query" @search="onSearch" />
|
|
5
|
+
<autocomplete @input="onInput" @select="onSelect" />
|
|
6
|
+
|
|
7
|
+
<label>
|
|
8
|
+
<select v-model="ranking" @change="onRankingChange">
|
|
9
|
+
<option value="relevance">Relevance</option>
|
|
10
|
+
<option value="newest">Newest</option>
|
|
11
|
+
<option value="popularity">Popularity</option>
|
|
12
|
+
</select>
|
|
13
|
+
</label>
|
|
14
|
+
|
|
15
|
+
<label>
|
|
16
|
+
Semantic:
|
|
17
|
+
<input type="checkbox" v-model="semanticEnabled" @change="toggleSemantic" />
|
|
18
|
+
</label>
|
|
19
|
+
|
|
20
|
+
<label>
|
|
21
|
+
Per page:
|
|
22
|
+
<select v-model.number="perPage" @change="onPerPage">
|
|
23
|
+
<option :value="12">12</option>
|
|
24
|
+
<option :value="24">24</option>
|
|
25
|
+
<option :value="48">48</option>
|
|
26
|
+
</select>
|
|
27
|
+
</label>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="filters">
|
|
31
|
+
<div v-for="(agg, key) in facets" :key="key" class="facet">
|
|
32
|
+
<strong>{{ key }}</strong>
|
|
33
|
+
<ul>
|
|
34
|
+
<li v-for="(b, idx) in bucketsFor(agg)" :key="idx">{{ b.key }} ({{ b.doc_count }})</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<ResultList :hits="hits" :loading="loading">
|
|
40
|
+
<template #item="{ hit }">
|
|
41
|
+
<div>
|
|
42
|
+
<h3>{{ hit.title || hit.name }}</h3>
|
|
43
|
+
<p v-if="hit.description">{{ hit.description }}</p>
|
|
44
|
+
<small v-if="hit.location">{{ hit.location.lat }}, {{ hit.location.lon }}</small>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
47
|
+
</ResultList>
|
|
48
|
+
|
|
49
|
+
<pagination :page="page" :totalPages="totalPages" @change="onPageChange" />
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
import { watch, computed } from 'vue'
|
|
55
|
+
import useSearchkit from '../../composables/useSearchkit'
|
|
56
|
+
import SearchInput from '../molecules/SearchInput.vue'
|
|
57
|
+
import autocomplete from './autocomplete.vue'
|
|
58
|
+
import ResultList from '../molecules/resultList.vue'
|
|
59
|
+
import pagination from '../molecules/pagination.vue'
|
|
60
|
+
|
|
61
|
+
const { query, hits, loading, page, perPage, facets, totalPages, search, setPage, setPerPage, setSemantic, ranking } = useSearchkit()
|
|
62
|
+
|
|
63
|
+
const semanticEnabled = computed({ get: () => false, set: (v: boolean) => setSemantic(v) })
|
|
64
|
+
|
|
65
|
+
function onSearch() {
|
|
66
|
+
page.value = 1
|
|
67
|
+
return search()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function onInput(val: string) {
|
|
71
|
+
query.value = val
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onSelect(item: any) {
|
|
75
|
+
// if suggestion contains a query text or id, perform targeted search
|
|
76
|
+
query.value = item.query || item.title || item.name || query.value
|
|
77
|
+
search()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onPageChange(p: number) {
|
|
81
|
+
return setPage(p)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onPerPage(e: any) {
|
|
85
|
+
return setPerPage(parseInt(e.target.value || '12', 10))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toggleSemantic(e: Event) {
|
|
89
|
+
const target = e.target as HTMLInputElement
|
|
90
|
+
return setSemantic(target.checked)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onRankingChange(e: Event) {
|
|
94
|
+
const target = e.target as HTMLSelectElement
|
|
95
|
+
ranking.value = target.value as any
|
|
96
|
+
return search()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function bucketsFor(agg: any) {
|
|
100
|
+
return (agg && (agg.buckets || agg.terms || agg)) || []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// initialize
|
|
104
|
+
search()
|
|
105
|
+
|
|
106
|
+
watch(query, () => {
|
|
107
|
+
// do not auto-search on every keystroke by default; controlled via SearchInput
|
|
108
|
+
})
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<style scoped>
|
|
112
|
+
.controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap }
|
|
113
|
+
.filters { margin-top:12px; display:flex; gap:16px }
|
|
114
|
+
.facet { background:#fafafa; padding:8px; border:1px solid #eee }
|
|
115
|
+
</style>
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="search-input">
|
|
3
|
+
<input
|
|
4
|
+
:placeholder="placeholder"
|
|
5
|
+
:value="modelValue"
|
|
6
|
+
@input="onInput"
|
|
7
|
+
@keydown.enter.prevent="onEnter"
|
|
8
|
+
class="search-input-field"
|
|
9
|
+
/>
|
|
10
|
+
<button @click="onSearch" class="search-input-button">Search</button>
|
|
11
|
+
</div>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
const props = defineProps({
|
|
16
|
+
modelValue: { type: String, default: '' },
|
|
17
|
+
placeholder: { type: String, default: 'Search...' },
|
|
18
|
+
})
|
|
19
|
+
const emit = defineEmits(['update:modelValue', 'search'])
|
|
20
|
+
|
|
21
|
+
function onInput(e: Event) {
|
|
22
|
+
const v = (e.target as HTMLInputElement).value
|
|
23
|
+
emit('update:modelValue', v)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function onEnter() {
|
|
27
|
+
emit('search')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function onSearch() {
|
|
31
|
+
emit('search')
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<style scoped>
|
|
36
|
+
.search-input { display:flex; gap:8px; align-items:center }
|
|
37
|
+
.search-input-field { padding:8px; border:1px solid #ccc; }
|
|
38
|
+
.search-input-button { padding:8px 12px }
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pagination">
|
|
3
|
+
<button :disabled="page <= 1" @click="change(page - 1)">Prev</button>
|
|
4
|
+
<span class="page-info">Page {{ page }} / {{ totalPages }}</span>
|
|
5
|
+
<button :disabled="page >= totalPages" @click="change(page + 1)">Next</button>
|
|
6
|
+
</div>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
const props = defineProps({ page: { type: Number, default: 1 }, totalPages: { type: Number, default: 1 } })
|
|
11
|
+
const emit = defineEmits(['change'])
|
|
12
|
+
|
|
13
|
+
function change(p: number) {
|
|
14
|
+
emit('change', p)
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.pagination { display:flex; gap:12px; align-items:center }
|
|
20
|
+
.page-info { font-weight:600 }
|
|
21
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="result-list">
|
|
3
|
+
<div v-if="loading" class="loading">Loading…</div>
|
|
4
|
+
<div v-else>
|
|
5
|
+
<div v-if="!hits || hits.length === 0" class="empty">No results</div>
|
|
6
|
+
<div v-else>
|
|
7
|
+
<div v-for="(hit, idx) in hits" :key="idx" class="result-item">
|
|
8
|
+
<slot name="item" :hit="hit">
|
|
9
|
+
<pre>{{ hit }}</pre>
|
|
10
|
+
</slot>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
const props = defineProps({
|
|
19
|
+
hits: {
|
|
20
|
+
type: (Array as any) as () => Array<any>,
|
|
21
|
+
default: () => []
|
|
22
|
+
},
|
|
23
|
+
loading: {
|
|
24
|
+
type: Boolean,
|
|
25
|
+
default: false
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<style scoped>
|
|
31
|
+
.result-list {
|
|
32
|
+
display: block
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.result-item {
|
|
36
|
+
padding: 12px;
|
|
37
|
+
border-bottom: 1px solid #eee
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.loading {
|
|
41
|
+
padding: 12px
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.empty {
|
|
45
|
+
padding: 12px;
|
|
46
|
+
color: #666
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="searchField">
|
|
3
|
+
<div class="container">
|
|
4
|
+
<ais-instant-search :search-client="searchClient" :index-name="indexName">
|
|
5
|
+
<ais-configure :hits-per-page.camel="8" />
|
|
6
|
+
<div class="search-panel">
|
|
7
|
+
<div class="search-panel__filters">
|
|
8
|
+
<ais-panel>
|
|
9
|
+
<template v-slot:header>type</template>
|
|
10
|
+
<ais-refinement-list attribute="type" />
|
|
11
|
+
</ais-panel>
|
|
12
|
+
|
|
13
|
+
<ais-panel>
|
|
14
|
+
<template v-slot:header>actors</template>
|
|
15
|
+
<ais-refinement-list searchable attribute="actors" />
|
|
16
|
+
</ais-panel>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="search-panel__results">
|
|
20
|
+
<div class="searchbox">
|
|
21
|
+
<ais-search-box placeholder="" />
|
|
22
|
+
<v-text-field v-if="isDev" v-model="searchQuery" placeholder="Debug: search input" class="debug-search-input"></v-text-field>
|
|
23
|
+
</div>
|
|
24
|
+
<ais-hits>
|
|
25
|
+
<template v-slot:item="{ item, index }">
|
|
26
|
+
<article @click="openResult(item)" style="cursor:pointer">
|
|
27
|
+
<h1>
|
|
28
|
+
<ais-highlight attribute="title" :hit="item" />
|
|
29
|
+
</h1>
|
|
30
|
+
<p>
|
|
31
|
+
<ais-snippet :hit="item" attribute="plot" />
|
|
32
|
+
</p>
|
|
33
|
+
</article>
|
|
34
|
+
</template>
|
|
35
|
+
</ais-hits>
|
|
36
|
+
|
|
37
|
+
<div class="pagination">
|
|
38
|
+
<ais-pagination />
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</ais-instant-search>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<script setup lang="ts">
|
|
48
|
+
import {
|
|
49
|
+
useRouter
|
|
50
|
+
} from 'vue-router'
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
type Ref,
|
|
54
|
+
ref,
|
|
55
|
+
watch
|
|
56
|
+
} from 'vue';
|
|
57
|
+
import Client from '@searchkit/instantsearch-client'
|
|
58
|
+
|
|
59
|
+
const searchClient = Client({
|
|
60
|
+
url: '/api/search'
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const configDetails = useRuntimeConfig()
|
|
64
|
+
|
|
65
|
+
const router = useRouter()
|
|
66
|
+
const searchQuery = ref('');
|
|
67
|
+
const indexName = configDetails.public.indexName;
|
|
68
|
+
const isDev = process.env.NODE_ENV !== 'production'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.debug('[search] searchClient', searchClient, 'has search method:', typeof searchClient.search)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function openResult(item: any) {
|
|
77
|
+
const id = item._id ?? item.id ?? '';
|
|
78
|
+
const title = item.title ?? '';
|
|
79
|
+
router.push({
|
|
80
|
+
path: '/results',
|
|
81
|
+
query: {
|
|
82
|
+
id,
|
|
83
|
+
title
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
</script>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MeeoviSearchItem } from './types'
|
|
2
|
+
import type { BuiltSearchQuery } from '../core/QueryBuilder'
|
|
3
|
+
|
|
4
|
+
export function createMockSearchAdapter(items: MeeoviSearchItem[] = []) {
|
|
5
|
+
const cfg = { provider: 'mock' }
|
|
6
|
+
|
|
7
|
+
const adapter = {
|
|
8
|
+
id: 'search:mock',
|
|
9
|
+
type: 'search',
|
|
10
|
+
config: cfg,
|
|
11
|
+
|
|
12
|
+
async search(query: BuiltSearchQuery | any) {
|
|
13
|
+
const term = String((query && (query.term || query.params?.q)) || '')
|
|
14
|
+
const filtered = items.filter((item) => item.title?.toLowerCase().includes(term.toLowerCase()))
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
items: filtered,
|
|
18
|
+
total: filtered.length,
|
|
19
|
+
page: 1,
|
|
20
|
+
pageSize: filtered.length
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return adapter
|
|
26
|
+
}
|