@open-mercato/search 0.4.2-canary-c02407ff85

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.
Files changed (237) hide show
  1. package/AGENTS.md +678 -0
  2. package/build.mjs +92 -0
  3. package/dist/di.js +157 -0
  4. package/dist/di.js.map +7 -0
  5. package/dist/fulltext/drivers/index.js +21 -0
  6. package/dist/fulltext/drivers/index.js.map +7 -0
  7. package/dist/fulltext/drivers/meilisearch/index.js +320 -0
  8. package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
  9. package/dist/fulltext/index.js +7 -0
  10. package/dist/fulltext/index.js.map +7 -0
  11. package/dist/fulltext/types.js +1 -0
  12. package/dist/fulltext/types.js.map +7 -0
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/indexer/index.js +8 -0
  16. package/dist/indexer/index.js.map +7 -0
  17. package/dist/indexer/search-indexer.js +848 -0
  18. package/dist/indexer/search-indexer.js.map +7 -0
  19. package/dist/indexer/subscribers/delete.js +41 -0
  20. package/dist/indexer/subscribers/delete.js.map +7 -0
  21. package/dist/lib/debug.js +34 -0
  22. package/dist/lib/debug.js.map +7 -0
  23. package/dist/lib/fallback-presenter.js +107 -0
  24. package/dist/lib/fallback-presenter.js.map +7 -0
  25. package/dist/lib/field-policy.js +75 -0
  26. package/dist/lib/field-policy.js.map +7 -0
  27. package/dist/lib/index.js +19 -0
  28. package/dist/lib/index.js.map +7 -0
  29. package/dist/lib/merger.js +93 -0
  30. package/dist/lib/merger.js.map +7 -0
  31. package/dist/lib/presenter-enricher.js +192 -0
  32. package/dist/lib/presenter-enricher.js.map +7 -0
  33. package/dist/modules/search/acl.js +14 -0
  34. package/dist/modules/search/acl.js.map +7 -0
  35. package/dist/modules/search/ai-tools.js +284 -0
  36. package/dist/modules/search/ai-tools.js.map +7 -0
  37. package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
  38. package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
  39. package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
  40. package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
  41. package/dist/modules/search/api/embeddings/route.js +246 -0
  42. package/dist/modules/search/api/embeddings/route.js.map +7 -0
  43. package/dist/modules/search/api/index/route.js +245 -0
  44. package/dist/modules/search/api/index/route.js.map +7 -0
  45. package/dist/modules/search/api/reindex/cancel/route.js +65 -0
  46. package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
  47. package/dist/modules/search/api/reindex/route.js +332 -0
  48. package/dist/modules/search/api/reindex/route.js.map +7 -0
  49. package/dist/modules/search/api/search/global/route.js +100 -0
  50. package/dist/modules/search/api/search/global/route.js.map +7 -0
  51. package/dist/modules/search/api/search/route.js +101 -0
  52. package/dist/modules/search/api/search/route.js.map +7 -0
  53. package/dist/modules/search/api/settings/fulltext/route.js +55 -0
  54. package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
  55. package/dist/modules/search/api/settings/global-search/route.js +80 -0
  56. package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
  57. package/dist/modules/search/api/settings/route.js +118 -0
  58. package/dist/modules/search/api/settings/route.js.map +7 -0
  59. package/dist/modules/search/api/settings/vector-store/route.js +77 -0
  60. package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
  61. package/dist/modules/search/backend/config/search/page.js +10 -0
  62. package/dist/modules/search/backend/config/search/page.js.map +7 -0
  63. package/dist/modules/search/backend/config/search/page.meta.js +24 -0
  64. package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
  65. package/dist/modules/search/cli.js +698 -0
  66. package/dist/modules/search/cli.js.map +7 -0
  67. package/dist/modules/search/di.js +32 -0
  68. package/dist/modules/search/di.js.map +7 -0
  69. package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
  70. package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
  71. package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
  72. package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
  73. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
  74. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
  75. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
  76. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
  77. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
  78. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
  79. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
  80. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
  81. package/dist/modules/search/frontend/index.js +9 -0
  82. package/dist/modules/search/frontend/index.js.map +7 -0
  83. package/dist/modules/search/frontend/utils.js +41 -0
  84. package/dist/modules/search/frontend/utils.js.map +7 -0
  85. package/dist/modules/search/i18n/de.json +61 -0
  86. package/dist/modules/search/i18n/en.json +72 -0
  87. package/dist/modules/search/i18n/es.json +61 -0
  88. package/dist/modules/search/i18n/pl.json +61 -0
  89. package/dist/modules/search/index.js +11 -0
  90. package/dist/modules/search/index.js.map +7 -0
  91. package/dist/modules/search/lib/auto-indexing.js +29 -0
  92. package/dist/modules/search/lib/auto-indexing.js.map +7 -0
  93. package/dist/modules/search/lib/embedding-config.js +131 -0
  94. package/dist/modules/search/lib/embedding-config.js.map +7 -0
  95. package/dist/modules/search/lib/global-search-config.js +45 -0
  96. package/dist/modules/search/lib/global-search-config.js.map +7 -0
  97. package/dist/modules/search/lib/reindex-lock.js +99 -0
  98. package/dist/modules/search/lib/reindex-lock.js.map +7 -0
  99. package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
  100. package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
  101. package/dist/modules/search/subscribers/vector_delete.js +58 -0
  102. package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
  103. package/dist/modules/search/subscribers/vector_purge.js +142 -0
  104. package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
  105. package/dist/modules/search/subscribers/vector_upsert.js +58 -0
  106. package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
  107. package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
  108. package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
  109. package/dist/modules/search/workers/vector-index.worker.js +234 -0
  110. package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
  111. package/dist/queue/fulltext-indexing.js +15 -0
  112. package/dist/queue/fulltext-indexing.js.map +7 -0
  113. package/dist/queue/index.js +3 -0
  114. package/dist/queue/index.js.map +7 -0
  115. package/dist/queue/vector-indexing.js +15 -0
  116. package/dist/queue/vector-indexing.js.map +7 -0
  117. package/dist/service.js +286 -0
  118. package/dist/service.js.map +7 -0
  119. package/dist/strategies/fulltext.strategy.js +116 -0
  120. package/dist/strategies/fulltext.strategy.js.map +7 -0
  121. package/dist/strategies/index.js +12 -0
  122. package/dist/strategies/index.js.map +7 -0
  123. package/dist/strategies/token.strategy.js +80 -0
  124. package/dist/strategies/token.strategy.js.map +7 -0
  125. package/dist/strategies/vector.strategy.js +137 -0
  126. package/dist/strategies/vector.strategy.js.map +7 -0
  127. package/dist/types.js +1 -0
  128. package/dist/types.js.map +7 -0
  129. package/dist/vector/drivers/chromadb/index.js +44 -0
  130. package/dist/vector/drivers/chromadb/index.js.map +7 -0
  131. package/dist/vector/drivers/index.js +9 -0
  132. package/dist/vector/drivers/index.js.map +7 -0
  133. package/dist/vector/drivers/pgvector/index.js +509 -0
  134. package/dist/vector/drivers/pgvector/index.js.map +7 -0
  135. package/dist/vector/drivers/qdrant/index.js +44 -0
  136. package/dist/vector/drivers/qdrant/index.js.map +7 -0
  137. package/dist/vector/index.js +4 -0
  138. package/dist/vector/index.js.map +7 -0
  139. package/dist/vector/lib/vector-logs.js +33 -0
  140. package/dist/vector/lib/vector-logs.js.map +7 -0
  141. package/dist/vector/services/checksum.js +20 -0
  142. package/dist/vector/services/checksum.js.map +7 -0
  143. package/dist/vector/services/embedding.js +222 -0
  144. package/dist/vector/services/embedding.js.map +7 -0
  145. package/dist/vector/services/index.js +4 -0
  146. package/dist/vector/services/index.js.map +7 -0
  147. package/dist/vector/services/vector-index.service.js +960 -0
  148. package/dist/vector/services/vector-index.service.js.map +7 -0
  149. package/dist/vector/types/pg.d.js +1 -0
  150. package/dist/vector/types/pg.d.js.map +7 -0
  151. package/dist/vector/types.js +75 -0
  152. package/dist/vector/types.js.map +7 -0
  153. package/jest.config.cjs +19 -0
  154. package/package.json +142 -0
  155. package/src/__tests__/queue.test.ts +148 -0
  156. package/src/__tests__/service.test.ts +345 -0
  157. package/src/__tests__/workers.test.ts +319 -0
  158. package/src/di.ts +291 -0
  159. package/src/fulltext/drivers/index.ts +41 -0
  160. package/src/fulltext/drivers/meilisearch/index.ts +410 -0
  161. package/src/fulltext/index.ts +13 -0
  162. package/src/fulltext/types.ts +115 -0
  163. package/src/index.ts +36 -0
  164. package/src/indexer/index.ts +13 -0
  165. package/src/indexer/search-indexer.ts +1141 -0
  166. package/src/indexer/subscribers/delete.ts +49 -0
  167. package/src/lib/debug.ts +46 -0
  168. package/src/lib/fallback-presenter.ts +106 -0
  169. package/src/lib/field-policy.ts +169 -0
  170. package/src/lib/index.ts +13 -0
  171. package/src/lib/merger.ts +159 -0
  172. package/src/lib/presenter-enricher.ts +323 -0
  173. package/src/modules/search/README.md +694 -0
  174. package/src/modules/search/acl.ts +10 -0
  175. package/src/modules/search/ai-tools.ts +467 -0
  176. package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
  177. package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
  178. package/src/modules/search/api/embeddings/route.ts +304 -0
  179. package/src/modules/search/api/index/route.ts +297 -0
  180. package/src/modules/search/api/reindex/cancel/route.ts +77 -0
  181. package/src/modules/search/api/reindex/route.ts +419 -0
  182. package/src/modules/search/api/search/global/route.ts +120 -0
  183. package/src/modules/search/api/search/route.ts +121 -0
  184. package/src/modules/search/api/settings/fulltext/route.ts +82 -0
  185. package/src/modules/search/api/settings/global-search/route.ts +91 -0
  186. package/src/modules/search/api/settings/route.ts +187 -0
  187. package/src/modules/search/api/settings/vector-store/route.ts +105 -0
  188. package/src/modules/search/backend/config/search/page.meta.ts +22 -0
  189. package/src/modules/search/backend/config/search/page.tsx +12 -0
  190. package/src/modules/search/cli.ts +818 -0
  191. package/src/modules/search/di.ts +50 -0
  192. package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
  193. package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
  194. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
  195. package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
  196. package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
  197. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
  198. package/src/modules/search/frontend/index.ts +3 -0
  199. package/src/modules/search/frontend/utils.ts +82 -0
  200. package/src/modules/search/i18n/de.json +61 -0
  201. package/src/modules/search/i18n/en.json +72 -0
  202. package/src/modules/search/i18n/es.json +61 -0
  203. package/src/modules/search/i18n/pl.json +61 -0
  204. package/src/modules/search/index.ts +9 -0
  205. package/src/modules/search/lib/auto-indexing.ts +35 -0
  206. package/src/modules/search/lib/embedding-config.ts +161 -0
  207. package/src/modules/search/lib/global-search-config.ts +69 -0
  208. package/src/modules/search/lib/reindex-lock.ts +201 -0
  209. package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
  210. package/src/modules/search/subscribers/vector_delete.ts +75 -0
  211. package/src/modules/search/subscribers/vector_purge.ts +161 -0
  212. package/src/modules/search/subscribers/vector_upsert.ts +75 -0
  213. package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
  214. package/src/modules/search/workers/vector-index.worker.ts +292 -0
  215. package/src/queue/fulltext-indexing.ts +87 -0
  216. package/src/queue/index.ts +2 -0
  217. package/src/queue/vector-indexing.ts +66 -0
  218. package/src/service.ts +397 -0
  219. package/src/strategies/fulltext.strategy.ts +155 -0
  220. package/src/strategies/index.ts +17 -0
  221. package/src/strategies/token.strategy.ts +153 -0
  222. package/src/strategies/vector.strategy.ts +234 -0
  223. package/src/types.ts +38 -0
  224. package/src/vector/drivers/chromadb/index.ts +49 -0
  225. package/src/vector/drivers/index.ts +4 -0
  226. package/src/vector/drivers/pgvector/index.ts +627 -0
  227. package/src/vector/drivers/qdrant/index.ts +49 -0
  228. package/src/vector/index.ts +3 -0
  229. package/src/vector/lib/vector-logs.ts +46 -0
  230. package/src/vector/services/checksum.ts +18 -0
  231. package/src/vector/services/embedding.ts +275 -0
  232. package/src/vector/services/index.ts +3 -0
  233. package/src/vector/services/vector-index.service.ts +1234 -0
  234. package/src/vector/types/pg.d.ts +1 -0
  235. package/src/vector/types.ts +220 -0
  236. package/tsconfig.json +9 -0
  237. package/watch.mjs +6 -0
@@ -0,0 +1,624 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
9
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@open-mercato/ui/primitives/tabs'
10
+
11
+ // Types
12
+ type FulltextStats = {
13
+ numberOfDocuments: number
14
+ isIndexing: boolean
15
+ fieldDistribution: Record<string, number>
16
+ }
17
+
18
+ type ReindexLock = {
19
+ type: 'fulltext' | 'vector'
20
+ action: string
21
+ startedAt: string
22
+ elapsedMinutes: number
23
+ }
24
+
25
+ type FulltextEnvVarStatus = {
26
+ set: boolean
27
+ hint: string
28
+ }
29
+
30
+ type FulltextOptionalEnvVarStatus = {
31
+ set: boolean
32
+ value?: string | boolean
33
+ default?: string | boolean
34
+ hint: string
35
+ }
36
+
37
+ type FulltextConfigResponse = {
38
+ driver: 'meilisearch' | null
39
+ configured: boolean
40
+ envVars: {
41
+ MEILISEARCH_HOST: FulltextEnvVarStatus
42
+ MEILISEARCH_API_KEY: FulltextEnvVarStatus
43
+ }
44
+ optionalEnvVars: {
45
+ MEILISEARCH_INDEX_PREFIX: FulltextOptionalEnvVarStatus
46
+ SEARCH_EXCLUDE_ENCRYPTED_FIELDS: FulltextOptionalEnvVarStatus
47
+ }
48
+ }
49
+
50
+ type ReindexResponse = {
51
+ ok: boolean
52
+ action: string
53
+ entityId?: string | null
54
+ result?: {
55
+ entitiesProcessed: number
56
+ recordsIndexed: number
57
+ errors?: Array<{ entityId: string; error: string }>
58
+ }
59
+ stats?: FulltextStats | null
60
+ error?: string
61
+ }
62
+
63
+ type ReindexAction = 'clear' | 'recreate' | 'reindex'
64
+
65
+ type ActivityLog = {
66
+ id: string
67
+ source: string
68
+ handler: string
69
+ level: 'info' | 'error' | 'warn'
70
+ entityType: string | null
71
+ recordId: string | null
72
+ message: string
73
+ details: unknown
74
+ occurredAt: string
75
+ }
76
+
77
+ export type FulltextSearchSectionProps = {
78
+ fulltextConfig: FulltextConfigResponse | null
79
+ fulltextConfigLoading: boolean
80
+ fulltextStats: FulltextStats | null
81
+ fulltextReindexLock: ReindexLock | null
82
+ loading: boolean
83
+ onStatsUpdate: (stats: FulltextStats | null) => void
84
+ onRefresh: () => Promise<void>
85
+ }
86
+
87
+ const normalizeErrorMessage = (error: unknown, fallback: string): string => {
88
+ if (typeof error === 'string' && error.trim().length) return error.trim()
89
+ if (error instanceof Error && error.message.trim().length) return error.message.trim()
90
+ return fallback
91
+ }
92
+
93
+ export function FulltextSearchSection({
94
+ fulltextConfig,
95
+ fulltextConfigLoading,
96
+ fulltextStats,
97
+ fulltextReindexLock,
98
+ loading,
99
+ onStatsUpdate,
100
+ onRefresh,
101
+ }: FulltextSearchSectionProps) {
102
+ const t = useT()
103
+ const [reindexing, setReindexing] = React.useState<ReindexAction | null>(null)
104
+ const [showReindexDialog, setShowReindexDialog] = React.useState<ReindexAction | null>(null)
105
+ const [activityLogs, setActivityLogs] = React.useState<ActivityLog[]>([])
106
+ const [activityLoading, setActivityLoading] = React.useState(true)
107
+
108
+ // Fetch activity logs
109
+ const fetchActivityLogs = React.useCallback(async () => {
110
+ setActivityLoading(true)
111
+ try {
112
+ const response = await fetch('/api/query_index/status')
113
+ if (response.ok) {
114
+ const body = await response.json() as { logs?: ActivityLog[]; errors?: ActivityLog[] }
115
+ // Combine logs and errors
116
+ const allLogs: ActivityLog[] = []
117
+ if (body.logs) {
118
+ allLogs.push(...body.logs)
119
+ }
120
+ if (body.errors) {
121
+ allLogs.push(...body.errors.map(err => ({ ...err, level: 'error' as const })))
122
+ }
123
+ // Filter for fulltext-related logs (exclude vector/embedding related)
124
+ const fulltextLogs = allLogs.filter(log => {
125
+ const lowerSource = log.source?.toLowerCase() ?? ''
126
+ const lowerMessage = log.message?.toLowerCase() ?? ''
127
+ const lowerHandler = log.handler?.toLowerCase() ?? ''
128
+ const isVector = lowerSource.includes('vector') || lowerMessage.includes('vector') ||
129
+ lowerMessage.includes('embedding') || lowerHandler.includes('vector')
130
+ return !isVector
131
+ })
132
+ // Sort by occurredAt descending
133
+ fulltextLogs.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())
134
+ setActivityLogs(fulltextLogs.slice(0, 50))
135
+ }
136
+ } catch {
137
+ // Silently fail
138
+ } finally {
139
+ setActivityLoading(false)
140
+ }
141
+ }, [])
142
+
143
+ React.useEffect(() => {
144
+ fetchActivityLogs()
145
+ }, [fetchActivityLogs])
146
+
147
+ // Poll for activity when reindexing
148
+ React.useEffect(() => {
149
+ if (fulltextReindexLock || reindexing) {
150
+ const interval = setInterval(fetchActivityLogs, 5000)
151
+ return () => clearInterval(interval)
152
+ }
153
+ }, [fulltextReindexLock, reindexing, fetchActivityLogs])
154
+
155
+ const handleReindexClick = (action: ReindexAction) => {
156
+ setShowReindexDialog(action)
157
+ }
158
+
159
+ const handleReindexCancel = () => {
160
+ setShowReindexDialog(null)
161
+ }
162
+
163
+ const handleReindexConfirm = React.useCallback(async () => {
164
+ const action = showReindexDialog
165
+ if (!action) return
166
+
167
+ setShowReindexDialog(null)
168
+ setReindexing(action)
169
+
170
+ try {
171
+ const response = await fetch('/api/search/reindex', {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ action, useQueue: action === 'reindex' }),
175
+ })
176
+
177
+ const body = await response.json() as ReindexResponse
178
+
179
+ if (!response.ok || body.error) {
180
+ throw new Error(body.error || t('search.settings.reindexErrorLabel', 'Failed to reindex'))
181
+ }
182
+
183
+ if (body.stats) {
184
+ onStatsUpdate(body.stats)
185
+ }
186
+
187
+ const successLabel = t('search.settings.reindexSuccessLabel', 'Operation completed successfully')
188
+ const successMessage = action === 'reindex' && body.result
189
+ ? `${successLabel}: ${body.result.recordsIndexed} documents indexed`
190
+ : successLabel
191
+
192
+ flash(successMessage, 'success')
193
+ await onRefresh()
194
+ await fetchActivityLogs()
195
+ } catch (err) {
196
+ const message = normalizeErrorMessage(err, t('search.settings.reindexErrorLabel', 'Failed to reindex'))
197
+ flash(message, 'error')
198
+ } finally {
199
+ setReindexing(null)
200
+ }
201
+ }, [showReindexDialog, t, onStatsUpdate, onRefresh, fetchActivityLogs])
202
+
203
+ const getDialogContent = (action: ReindexAction) => {
204
+ switch (action) {
205
+ case 'clear':
206
+ return {
207
+ title: t('search.settings.clearIndexDialogTitle', 'Clear Index'),
208
+ description: t('search.settings.clearIndexDialogDescription', 'This will remove all documents from the Meilisearch index but keep the index settings.'),
209
+ warning: t('search.settings.clearIndexDialogWarning', 'Search will not work until documents are re-indexed.'),
210
+ confirmLabel: t('search.settings.clearIndexLabel', 'Clear Index'),
211
+ }
212
+ case 'recreate':
213
+ return {
214
+ title: t('search.settings.recreateIndexDialogTitle', 'Recreate Index'),
215
+ description: t('search.settings.recreateIndexDialogDescription', 'This will delete the index completely and recreate it with fresh settings.'),
216
+ warning: t('search.settings.recreateIndexDialogWarning', 'All indexed documents will be permanently removed.'),
217
+ confirmLabel: t('search.settings.recreateIndexLabel', 'Recreate Index'),
218
+ }
219
+ case 'reindex':
220
+ return {
221
+ title: t('search.settings.fullReindexDialogTitle', 'Full Reindex'),
222
+ description: t('search.settings.fullReindexDialogDescription', 'This will recreate the index and re-index all data from the database.'),
223
+ warning: t('search.settings.fullReindexDialogWarning', 'This operation may take a while depending on the amount of data.'),
224
+ confirmLabel: t('search.settings.fullReindexLabel', 'Full Reindex'),
225
+ }
226
+ }
227
+ }
228
+
229
+ const getStrategyIcon = () => (
230
+ <svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
231
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
232
+ </svg>
233
+ )
234
+
235
+ return (
236
+ <div className="rounded-lg border border-border bg-card p-5 shadow-sm">
237
+ <h2 className="text-lg font-semibold mb-2">
238
+ {t('search.settings.fulltext.sectionTitle', 'Full-Text Search')}
239
+ </h2>
240
+ <p className="text-sm text-muted-foreground mb-4">
241
+ {t('search.settings.fulltext.sectionDescription', 'Fast, typo-tolerant search using Meilisearch.')}
242
+ </p>
243
+
244
+ <Tabs defaultValue="configuration">
245
+ <TabsList className="mb-4">
246
+ <TabsTrigger value="configuration">
247
+ {t('search.settings.tabs.configuration', 'Configuration')}
248
+ </TabsTrigger>
249
+ <TabsTrigger value="index">
250
+ {t('search.settings.tabs.indexManagement', 'Index Management')}
251
+ </TabsTrigger>
252
+ <TabsTrigger value="activity">
253
+ {t('search.settings.tabs.activity', 'Activity')}
254
+ </TabsTrigger>
255
+ </TabsList>
256
+
257
+ {/* Configuration Tab */}
258
+ <TabsContent value="configuration">
259
+ {fulltextConfigLoading ? (
260
+ <div className="flex items-center gap-2 text-muted-foreground">
261
+ <Spinner size="sm" />
262
+ <span>{t('search.settings.loadingLabel', 'Loading settings...')}</span>
263
+ </div>
264
+ ) : (
265
+ <div className="space-y-4">
266
+ {/* Driver Status */}
267
+ <div className="flex items-center gap-3 p-3 rounded-md border border-border bg-muted/30">
268
+ <div className={`flex h-10 w-10 items-center justify-center rounded-full ${
269
+ fulltextConfig?.configured
270
+ ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400'
271
+ : 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-400'
272
+ }`}>
273
+ {getStrategyIcon()}
274
+ </div>
275
+ <div>
276
+ <p className="font-medium">
277
+ {t('search.settings.fulltext.driver', 'Current Driver')}: {fulltextConfig?.driver ? 'Meilisearch' : t('search.settings.fulltext.noDriver', 'None')}
278
+ </p>
279
+ <p className={`text-sm ${
280
+ fulltextConfig?.configured
281
+ ? 'text-emerald-600 dark:text-emerald-400'
282
+ : 'text-amber-600 dark:text-amber-400'
283
+ }`}>
284
+ {fulltextConfig?.configured
285
+ ? t('search.settings.fulltext.ready', 'Ready to use')
286
+ : t('search.settings.fulltext.notReady', 'Not configured - set environment variables below')}
287
+ </p>
288
+ </div>
289
+ </div>
290
+
291
+ {/* Required Environment Variables */}
292
+ <div>
293
+ <h3 className="text-sm font-semibold mb-2">
294
+ {t('search.settings.fulltext.envVars', 'Required Environment Variables')}
295
+ </h3>
296
+ <div className="space-y-2">
297
+ {fulltextConfig?.envVars && Object.entries(fulltextConfig.envVars).map(([key, status]) => (
298
+ <div key={key} className="flex items-start gap-3 p-3 rounded-md border border-border">
299
+ <div className={`flex h-5 w-5 items-center justify-center rounded-full flex-shrink-0 mt-0.5 ${
300
+ status.set
301
+ ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400'
302
+ : 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400'
303
+ }`}>
304
+ {status.set ? (
305
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
306
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
307
+ </svg>
308
+ ) : (
309
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
310
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
311
+ </svg>
312
+ )}
313
+ </div>
314
+ <div className="flex-1">
315
+ <div className="flex items-center gap-2">
316
+ <code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">{key}</code>
317
+ <span className={`text-xs ${status.set ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
318
+ {status.set ? t('search.settings.fulltext.envSet', 'Set') : t('search.settings.fulltext.envMissing', 'Missing')}
319
+ </span>
320
+ </div>
321
+ <p className="text-xs text-muted-foreground mt-1">{status.hint}</p>
322
+ </div>
323
+ </div>
324
+ ))}
325
+ </div>
326
+ </div>
327
+
328
+ {/* Optional Settings */}
329
+ <div>
330
+ <h3 className="text-sm font-semibold mb-2">
331
+ {t('search.settings.fulltext.optional', 'Optional Settings')}
332
+ </h3>
333
+ <div className="space-y-2">
334
+ {fulltextConfig?.optionalEnvVars && Object.entries(fulltextConfig.optionalEnvVars).map(([key, status]) => (
335
+ <div key={key} className="flex items-start gap-3 p-2 rounded-md bg-muted/30">
336
+ <code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">{key}</code>
337
+ <div className="flex-1 text-xs text-muted-foreground">
338
+ <span>{status.hint}</span>
339
+ {status.set ? (
340
+ <span className="ml-2 text-emerald-600 dark:text-emerald-400">
341
+ ({t('search.settings.fulltext.currentValue', 'Current')}: {String(status.value)})
342
+ </span>
343
+ ) : (
344
+ <span className="ml-2">
345
+ ({t('search.settings.fulltext.defaultValue', 'Default')}: {String(status.default)})
346
+ </span>
347
+ )}
348
+ </div>
349
+ </div>
350
+ ))}
351
+ </div>
352
+ </div>
353
+
354
+ {/* Setup Instructions */}
355
+ <div className="p-3 rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
356
+ <div className="flex items-start gap-2">
357
+ <svg className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
358
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
359
+ </svg>
360
+ <div className="text-sm text-blue-800 dark:text-blue-200">
361
+ <p className="font-medium mb-1">{t('search.settings.fulltext.howTo', 'How to set up')}</p>
362
+ <p className="text-xs">{t('search.settings.fulltext.howToDescription', 'Add these variables to your .env file or deployment environment. You can use a hosted Meilisearch instance or run it locally with Docker.')}</p>
363
+ <a
364
+ href="https://www.meilisearch.com/docs/learn/getting_started/quick_start"
365
+ target="_blank"
366
+ rel="noopener noreferrer"
367
+ className="text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1 inline-block"
368
+ >
369
+ {t('search.settings.fulltext.learnMore', 'Learn more: Meilisearch Quick Start')} →
370
+ </a>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ )}
376
+ </TabsContent>
377
+
378
+ {/* Index Management Tab */}
379
+ <TabsContent value="index">
380
+ {(loading || fulltextConfigLoading) ? (
381
+ <div className="flex items-center gap-2 text-muted-foreground">
382
+ <Spinner size="sm" />
383
+ <span>{t('search.settings.loadingLabel', 'Loading settings...')}</span>
384
+ </div>
385
+ ) : !fulltextConfig?.configured ? (
386
+ <div className="p-4 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
387
+ <div className="flex items-start gap-3">
388
+ <svg className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
389
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
390
+ </svg>
391
+ <div>
392
+ <p className="text-sm font-medium text-amber-800 dark:text-amber-200">
393
+ {t('search.settings.fulltextNotConfigured', 'Full-text search driver not configured')}
394
+ </p>
395
+ <p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
396
+ {t('search.settings.fulltextNotConfiguredHint', 'Configure the required environment variables in the Configuration tab to enable indexing.')}
397
+ </p>
398
+ </div>
399
+ </div>
400
+ </div>
401
+ ) : (
402
+ <div className="space-y-4">
403
+ {/* Stats */}
404
+ {fulltextStats ? (
405
+ <div className="rounded-md border border-border p-4 max-w-xs">
406
+ <p className="text-sm text-muted-foreground">{t('search.settings.documentsLabel', 'Documents')}</p>
407
+ <p className="text-2xl font-bold">{fulltextStats.numberOfDocuments.toLocaleString()}</p>
408
+ </div>
409
+ ) : (
410
+ <div className="p-3 rounded-md bg-muted/50">
411
+ <p className="text-sm text-muted-foreground">
412
+ {t('search.settings.noIndexMessage', "No index found for this tenant. Click 'Full Reindex' to create one.")}
413
+ </p>
414
+ </div>
415
+ )}
416
+
417
+ {/* Active reindex lock banner */}
418
+ {fulltextReindexLock && (
419
+ <div className="p-4 rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
420
+ <div className="flex items-start gap-3">
421
+ <Spinner size="sm" className="flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
422
+ <div className="flex-1">
423
+ <p className="text-sm font-medium text-blue-800 dark:text-blue-200">
424
+ {t('search.settings.reindexInProgress', 'Reindex operation in progress')}
425
+ </p>
426
+ <p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
427
+ {t('search.settings.reindexInProgressDetails', 'Action: {{action}} | Started {{minutes}} minutes ago', {
428
+ action: fulltextReindexLock.action,
429
+ minutes: fulltextReindexLock.elapsedMinutes,
430
+ })}
431
+ </p>
432
+ </div>
433
+ </div>
434
+ </div>
435
+ )}
436
+
437
+ {/* Actions */}
438
+ <div className="flex flex-wrap gap-3 pt-2">
439
+ {fulltextStats && (
440
+ <>
441
+ <div className="flex flex-col">
442
+ <Button
443
+ type="button"
444
+ variant="outline"
445
+ size="sm"
446
+ onClick={() => handleReindexClick('clear')}
447
+ disabled={reindexing !== null || fulltextReindexLock !== null}
448
+ >
449
+ {reindexing === 'clear' ? (
450
+ <>
451
+ <Spinner size="sm" className="mr-2" />
452
+ {t('search.settings.processingLabel', 'Processing...')}
453
+ </>
454
+ ) : (
455
+ t('search.settings.clearIndexLabel', 'Clear Index')
456
+ )}
457
+ </Button>
458
+ <span className="text-xs text-muted-foreground mt-1">
459
+ {t('search.settings.clearIndexDescription', 'Remove all documents but keep index settings')}
460
+ </span>
461
+ </div>
462
+ <div className="flex flex-col">
463
+ <Button
464
+ type="button"
465
+ variant="outline"
466
+ size="sm"
467
+ onClick={() => handleReindexClick('recreate')}
468
+ disabled={reindexing !== null || fulltextReindexLock !== null}
469
+ >
470
+ {reindexing === 'recreate' ? (
471
+ <>
472
+ <Spinner size="sm" className="mr-2" />
473
+ {t('search.settings.processingLabel', 'Processing...')}
474
+ </>
475
+ ) : (
476
+ t('search.settings.recreateIndexLabel', 'Recreate Index')
477
+ )}
478
+ </Button>
479
+ <span className="text-xs text-muted-foreground mt-1">
480
+ {t('search.settings.recreateIndexDescription', 'Delete and recreate the index with fresh settings')}
481
+ </span>
482
+ </div>
483
+ </>
484
+ )}
485
+ <div className="flex flex-col">
486
+ <Button
487
+ type="button"
488
+ variant="default"
489
+ size="sm"
490
+ onClick={() => handleReindexClick('reindex')}
491
+ disabled={reindexing !== null || fulltextReindexLock !== null}
492
+ >
493
+ {reindexing === 'reindex' || fulltextReindexLock !== null ? (
494
+ <>
495
+ <Spinner size="sm" className="mr-2" />
496
+ {t('search.settings.processingLabel', 'Processing...')}
497
+ </>
498
+ ) : (
499
+ t('search.settings.fullReindexLabel', 'Full Reindex')
500
+ )}
501
+ </Button>
502
+ <span className="text-xs text-muted-foreground mt-1">
503
+ {t('search.settings.fullReindexDescription', 'Recreate index and re-index all data from database')}
504
+ </span>
505
+ </div>
506
+ </div>
507
+ </div>
508
+ )}
509
+ </TabsContent>
510
+
511
+ {/* Activity Tab */}
512
+ <TabsContent value="activity">
513
+ {activityLoading ? (
514
+ <div className="flex items-center gap-2 text-muted-foreground">
515
+ <Spinner size="sm" />
516
+ <span>{t('search.settings.loadingLabel', 'Loading...')}</span>
517
+ </div>
518
+ ) : activityLogs.length === 0 ? (
519
+ <div className="p-4 rounded-md bg-muted/50 text-center">
520
+ <p className="text-sm text-muted-foreground">
521
+ {t('search.settings.activity.noLogs', 'No recent indexing activity')}
522
+ </p>
523
+ </div>
524
+ ) : (
525
+ <div className="space-y-2 max-h-80 overflow-y-auto">
526
+ {activityLogs.map((log) => (
527
+ <div
528
+ key={log.id}
529
+ className={`p-2 rounded-md text-sm ${
530
+ log.level === 'error'
531
+ ? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
532
+ : 'bg-muted/50'
533
+ }`}
534
+ >
535
+ <div className="flex items-start gap-2">
536
+ {log.level === 'error' && (
537
+ <svg className="h-4 w-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
538
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
539
+ </svg>
540
+ )}
541
+ <div className="flex-1 min-w-0">
542
+ <p className={`text-xs ${log.level === 'error' ? 'text-red-800 dark:text-red-200' : 'text-foreground'}`}>
543
+ {log.message}
544
+ </p>
545
+ <p className="text-xs text-muted-foreground mt-0.5">
546
+ {(() => {
547
+ const d = new Date(log.occurredAt)
548
+ const pad = (n: number) => n.toString().padStart(2, '0')
549
+ return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
550
+ })()}
551
+ {log.entityType && ` · ${log.entityType}`}
552
+ </p>
553
+ </div>
554
+ </div>
555
+ </div>
556
+ ))}
557
+ </div>
558
+ )}
559
+ <div className="mt-3">
560
+ <Button
561
+ type="button"
562
+ variant="outline"
563
+ size="sm"
564
+ onClick={fetchActivityLogs}
565
+ disabled={activityLoading}
566
+ >
567
+ {activityLoading ? (
568
+ <>
569
+ <Spinner size="sm" className="mr-2" />
570
+ {t('search.settings.loadingLabel', 'Loading...')}
571
+ </>
572
+ ) : (
573
+ t('search.settings.refreshLabel', 'Refresh')
574
+ )}
575
+ </Button>
576
+ </div>
577
+ </TabsContent>
578
+ </Tabs>
579
+
580
+ {/* Reindex Confirmation Dialog */}
581
+ {showReindexDialog && (
582
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
583
+ <div className="mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
584
+ <div className="flex items-start gap-3 mb-4">
585
+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
586
+ <svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
587
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
588
+ </svg>
589
+ </div>
590
+ <div>
591
+ <h3 className="text-lg font-semibold">{getDialogContent(showReindexDialog).title}</h3>
592
+ <p className="text-sm text-muted-foreground mt-1">{getDialogContent(showReindexDialog).description}</p>
593
+ </div>
594
+ </div>
595
+
596
+ <div className="mb-4 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20">
597
+ <div className="flex items-start gap-2">
598
+ <svg className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
599
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
600
+ </svg>
601
+ <p className="text-sm text-amber-800 dark:text-amber-200">{getDialogContent(showReindexDialog).warning}</p>
602
+ </div>
603
+ </div>
604
+
605
+ <div className="flex justify-end gap-3">
606
+ <Button type="button" variant="outline" onClick={handleReindexCancel}>
607
+ {t('search.settings.cancelLabel', 'Cancel')}
608
+ </Button>
609
+ <Button
610
+ type="button"
611
+ variant={showReindexDialog === 'reindex' ? 'default' : 'destructive'}
612
+ onClick={handleReindexConfirm}
613
+ >
614
+ {getDialogContent(showReindexDialog).confirmLabel}
615
+ </Button>
616
+ </div>
617
+ </div>
618
+ </div>
619
+ )}
620
+ </div>
621
+ )
622
+ }
623
+
624
+ export default FulltextSearchSection