@motor-cms/ui-admin 1.0.4-rc.2 → 1.1.0-alpha.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.
Files changed (2) hide show
  1. package/app/pages/search.vue +259 -67
  2. package/package.json +2 -2
@@ -1,91 +1,283 @@
1
1
  <!-- app/pages/search.vue -->
2
2
  <script setup lang="ts">
3
3
  import type { SearchGridRow } from '@motor-cms/ui-core/app/types/search'
4
- import type { ColumnDef } from '@motor-cms/ui-core/app/types/grid'
4
+ import type { PaginatedResponse, PaginationMeta } from '@motor-cms/ui-core/app/types/grid'
5
+ import { watchDebounced } from '@vueuse/core'
5
6
 
6
7
  const { t } = useI18n()
8
+ const route = useRoute()
9
+ const router = useRouter()
10
+ const client = useSanctumClient()
11
+ const toast = useToast()
7
12
 
8
13
  const ALL_MODULES = '_all'
9
14
  const moduleFilter = ref(ALL_MODULES)
15
+ const searchInput = ref((route.query.search as string) ?? '')
16
+ const page = ref(1)
17
+ const perPage = ref(25)
18
+ const loading = ref(false)
19
+ const results = ref<SearchGridRow[]>([])
20
+ const meta = ref<PaginationMeta | null>(null)
10
21
 
11
22
  const moduleFacets = useModuleFacets()
12
- const moduleOptions = computed(() => {
13
- const options = [{ label: t('motor-core.search.filter_all_modules'), value: ALL_MODULES }]
23
+
24
+ if (route.query.module && typeof route.query.module === 'string') {
25
+ moduleFilter.value = route.query.module
26
+ }
27
+
28
+ watch(() => route.query.search, (newSearch) => {
29
+ const val = (newSearch as string) ?? ''
30
+ if (val !== searchInput.value) {
31
+ searchInput.value = val
32
+ page.value = 1
33
+ doSearch(true)
34
+ }
35
+ })
36
+
37
+ watch(() => route.query.module, (newModule) => {
38
+ const val = (newModule as string) ?? ALL_MODULES
39
+ if (val !== moduleFilter.value) {
40
+ moduleFilter.value = val || ALL_MODULES
41
+ }
42
+ })
43
+
44
+ const facetTabs = computed(() => {
45
+ const tabs = [{ label: t('motor-core.search.filter_all_modules'), value: ALL_MODULES, count: 0 }]
46
+ let totalCount = 0
14
47
  for (const [key, count] of Object.entries(moduleFacets.value)) {
15
- options.push({ label: `${resolveModuleLabel(key, t)} (${count})`, value: key })
48
+ totalCount += count
49
+ tabs.push({ label: resolveModuleLabel(key, t), value: key, count })
16
50
  }
17
- return options
51
+ tabs[0]!.count = totalCount
52
+ return tabs
53
+ })
54
+
55
+ async function refreshFacets() {
56
+ if (!searchInput.value || searchInput.value.length < 2) return
57
+ const fetcher = fetchSearchGrid(t, undefined, client)
58
+ await fetcher({ page: 1, per_page: 1, search: searchInput.value })
59
+ }
60
+
61
+ async function doSearch(updateFacets = false) {
62
+ if (!searchInput.value || searchInput.value.length < 2) {
63
+ results.value = []
64
+ meta.value = null
65
+ return
66
+ }
67
+
68
+ loading.value = true
69
+ try {
70
+ const activeModule = moduleFilter.value === ALL_MODULES ? undefined : moduleFilter.value
71
+
72
+ if (updateFacets && activeModule) {
73
+ await refreshFacets()
74
+ }
75
+
76
+ const fetcher = fetchSearchGrid(t, activeModule, client)
77
+ const response: PaginatedResponse<SearchGridRow> = await fetcher({
78
+ page: page.value,
79
+ per_page: perPage.value,
80
+ search: searchInput.value
81
+ })
82
+ results.value = response.data
83
+ meta.value = response.meta
84
+ } catch {
85
+ results.value = []
86
+ meta.value = null
87
+ } finally {
88
+ loading.value = false
89
+ }
90
+ }
91
+
92
+ watchDebounced(searchInput, () => {
93
+ page.value = 1
94
+ doSearch(true)
95
+ router.replace({ query: { ...route.query, search: searchInput.value || undefined } })
96
+ }, { debounce: 300 })
97
+
98
+ watch(moduleFilter, () => {
99
+ page.value = 1
100
+ doSearch()
101
+ router.replace({
102
+ query: {
103
+ ...route.query,
104
+ module: moduleFilter.value === ALL_MODULES ? undefined : moduleFilter.value
105
+ }
106
+ })
107
+ })
108
+
109
+ watch(page, () => doSearch())
110
+ watch(perPage, () => {
111
+ page.value = 1
112
+ doSearch()
18
113
  })
19
114
 
20
- const activeModule = computed(() => moduleFilter.value === ALL_MODULES ? undefined : moduleFilter.value)
21
- const fetchResults = computed(() => fetchSearchGrid(t, activeModule.value))
22
-
23
- const columns: ColumnDef<SearchGridRow>[] = [
24
- {
25
- key: 'module',
26
- label: t('motor-core.search.column_module'),
27
- renderer: 'badge',
28
- width: 'w-[12%]'
29
- },
30
- {
31
- key: 'index_label',
32
- label: t('motor-core.search.column_type'),
33
- renderer: 'badge',
34
- rendererProps: { color: 'neutral' },
35
- width: 'w-[12%]'
36
- },
37
- {
38
- key: 'title',
39
- label: t('motor-core.search.column_title'),
40
- width: 'w-[30%]'
41
- },
42
- {
43
- key: 'excerpt',
44
- label: t('motor-core.search.column_excerpt')
115
+ // Builder page quicklinker
116
+ const quicklinkerOpen = ref(false)
117
+ const quicklinkerPageUuid = ref('')
118
+ const quicklinkerPageName = ref('')
119
+
120
+ async function handleCardAction(key: string, id: number | string | null, meta: Record<string, unknown> | undefined) {
121
+ if (key === 'link-navigation' && meta?.uuid) {
122
+ quicklinkerPageUuid.value = meta.uuid as string
123
+ quicklinkerPageName.value = (meta.name as string) ?? ''
124
+ quicklinkerOpen.value = true
125
+ } else if (key === 'publish' && id) {
126
+ try {
127
+ await client(`/api/v2/builder-pages/${id}/publication`, {
128
+ method: 'PUT',
129
+ body: { is_published: true }
130
+ })
131
+ toast.add({
132
+ title: t('motor-builder.builder_pages.toast_published'),
133
+ color: 'success',
134
+ icon: 'i-lucide-globe'
135
+ })
136
+ } catch {
137
+ toast.add({
138
+ title: t('motor-builder.builder_pages.toast_publish_error'),
139
+ color: 'error',
140
+ icon: 'i-lucide-alert-circle'
141
+ })
142
+ }
45
143
  }
46
- ]
144
+ }
145
+
146
+ onMounted(() => {
147
+ if (searchInput.value) doSearch(true)
148
+ })
47
149
  </script>
48
150
 
49
151
  <template>
50
- <GridPage
51
- :title="t('motor-core.search.title')"
52
- :subtitle="t('motor-core.search.subtitle')"
53
- >
54
- <GridBase
55
- id="search-grid"
56
- :key="moduleFilter"
57
- :fetch="fetchResults"
58
- :columns="columns"
59
- :searchable="true"
60
- :row-click-to="(row: any) => row.to"
61
- :disable-default-actions="true"
62
- >
63
- <template #toolbar-extra>
64
- <USelectMenu
65
- v-model="moduleFilter"
66
- :items="moduleOptions"
67
- value-key="value"
68
- label-key="label"
69
- class="w-48"
70
- :clear="moduleFilter !== ALL_MODULES"
71
- @clear="moduleFilter = ALL_MODULES"
152
+ <div class="flex flex-col h-full">
153
+ <!-- Sticky header: title + search + toolbar -->
154
+ <div class="sticky top-0 z-10 bg-[var(--ui-bg)] px-6 pt-6 pb-4 border-b border-[var(--ui-border)]">
155
+ <div class="flex items-center gap-2 mb-3">
156
+ <UDashboardSidebarToggle class="lg:hidden shrink-0 -ml-2" />
157
+ </div>
158
+
159
+ <div class="flex items-baseline gap-3 mb-4">
160
+ <h1 class="text-xl font-semibold">
161
+ {{ t('motor-core.search.title') }}
162
+ </h1>
163
+ <span class="text-sm text-dimmed hidden sm:inline">
164
+ {{ t('motor-core.search.subtitle') }}
165
+ </span>
166
+ </div>
167
+
168
+ <div class="mb-4">
169
+ <UInput
170
+ v-model="searchInput"
171
+ :placeholder="t('motor-core.search.placeholder')"
172
+ icon="i-lucide-search"
173
+ size="lg"
174
+ autofocus
175
+ class="w-full"
176
+ :ui="{ base: 'w-full' }"
72
177
  />
73
- </template>
178
+ </div>
74
179
 
75
- <template #empty>
76
- <div class="flex flex-col items-center justify-center py-12 gap-3">
77
- <UIcon
78
- name="i-lucide-search"
79
- class="size-8 text-muted"
180
+ <div class="flex items-start gap-3">
181
+ <!-- Left: facet tabs -->
182
+ <div
183
+ v-if="facetTabs.length > 2"
184
+ class="flex flex-wrap gap-1.5 flex-1 min-w-0"
185
+ >
186
+ <UButton
187
+ v-for="tab in facetTabs"
188
+ :key="tab.value"
189
+ :label="tab.count > 0 ? `${tab.label} (${tab.count})` : tab.label"
190
+ size="sm"
191
+ :variant="moduleFilter === tab.value ? 'solid' : 'ghost'"
192
+ :color="moduleFilter === tab.value ? 'primary' : 'neutral'"
193
+ @click="moduleFilter = tab.value"
194
+ />
195
+ </div>
196
+
197
+ <!-- Right: pagination -->
198
+ <GridPagination
199
+ compact
200
+ :meta="meta"
201
+ :per-page-options="[25, 50, 100]"
202
+ @update:page="page = $event"
203
+ @update:per-page="perPage = $event"
204
+ />
205
+ </div>
206
+ </div>
207
+
208
+ <!-- Results -->
209
+ <div class="flex flex-col gap-4 px-6 pt-4 pb-8">
210
+ <!-- Loading skeleton -->
211
+ <div
212
+ v-if="loading"
213
+ class="flex flex-col gap-2"
214
+ >
215
+ <USkeleton
216
+ v-for="i in 5"
217
+ :key="i"
218
+ class="h-[76px] w-full rounded-[var(--ui-radius)]"
219
+ />
220
+ </div>
221
+
222
+ <!-- Empty: no search term -->
223
+ <div
224
+ v-else-if="!searchInput || searchInput.length < 2"
225
+ class="flex flex-col items-center justify-center py-12 gap-3"
226
+ >
227
+ <UIcon
228
+ name="i-lucide-search"
229
+ class="size-8 text-[var(--ui-text-muted)]"
230
+ />
231
+ <p class="text-sm font-medium text-[var(--ui-text-muted)]">
232
+ {{ t('motor-core.search.min_chars') }}
233
+ </p>
234
+ </div>
235
+
236
+ <!-- Empty: no results -->
237
+ <div
238
+ v-else-if="results.length === 0"
239
+ class="flex flex-col items-center justify-center py-12 gap-3"
240
+ >
241
+ <UIcon
242
+ name="i-lucide-search-x"
243
+ class="size-8 text-[var(--ui-text-muted)]"
244
+ />
245
+ <p class="text-sm font-medium text-[var(--ui-text-muted)]">
246
+ {{ t('motor-core.search.no_results') }}
247
+ </p>
248
+ <p class="text-xs text-[var(--ui-text-dimmed)]">
249
+ {{ t('motor-core.search.no_results_hint') }}
250
+ </p>
251
+ </div>
252
+
253
+ <!-- Result cards -->
254
+ <template v-else>
255
+ <div class="flex flex-col gap-2 mb-6">
256
+ <SearchResultCard
257
+ v-for="row in results"
258
+ :key="`${row.raw_module}-${row.raw_index}-${row.id}`"
259
+ :title="row.title"
260
+ :excerpt="row.excerpt"
261
+ :icon="row.icon"
262
+ :thumbnail-url="row.thumbnail_url"
263
+ :module="row.module"
264
+ :index-label="row.index_label"
265
+ :to="row.to"
266
+ :actions="row.actions"
267
+ :file-id="row.raw_index === 'files' ? row.id : undefined"
268
+ :entity-id="row.id"
269
+ :entity-meta="row.entity_meta"
270
+ @action="handleCardAction"
80
271
  />
81
- <p class="text-sm font-medium text-muted">
82
- {{ t('motor-core.search.min_chars') }}
83
- </p>
84
- <p class="text-xs text-dimmed">
85
- {{ t('motor-core.search.no_results_hint') }}
86
- </p>
87
272
  </div>
88
273
  </template>
89
- </GridBase>
90
- </GridPage>
274
+ </div>
275
+
276
+ <LazyBuilderNavigationQuickLinker
277
+ v-if="quicklinkerOpen"
278
+ v-model:open="quicklinkerOpen"
279
+ :page-uuid="quicklinkerPageUuid"
280
+ :page-name="quicklinkerPageName"
281
+ />
282
+ </div>
91
283
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motor-cms/ui-admin",
3
- "version": "1.0.4-rc.2",
3
+ "version": "1.1.0-alpha.3",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -17,7 +17,7 @@
17
17
  "@vueuse/core": "^14.0.0",
18
18
  "sortablejs": "^1.15.0",
19
19
  "zod": "^4.0.0",
20
- "@motor-cms/ui-core": "1.0.4-rc.2"
20
+ "@motor-cms/ui-core": "1.1.0-alpha.3"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "nuxt": "^4.0.0",