@motor-cms/ui-admin 1.0.4-rc.2 → 1.1.0

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