@bobbykim/manguito-cms-admin 0.1.0 → 0.2.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.
- package/LICENSE.md +21 -0
- package/index.html +13 -0
- package/package.json +19 -7
- package/public/favicon.ico +0 -0
- package/src/App.vue +147 -0
- package/src/adapters/admin.ts +13 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/fields/BooleanToggle.vue +63 -0
- package/src/components/fields/DatePicker.vue +61 -0
- package/src/components/fields/EnumSelect.vue +69 -0
- package/src/components/fields/MediaUpload.vue +445 -0
- package/src/components/fields/NumberInput.vue +66 -0
- package/src/components/fields/ParagraphEmbed.vue +149 -0
- package/src/components/fields/ReferenceSelect.vue +275 -0
- package/src/components/fields/RichTextEditor.vue +293 -0
- package/src/components/fields/TextInput.vue +93 -0
- package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
- package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
- package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
- package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
- package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
- package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
- package/src/components/fields/__tests__/TextInput.test.ts +72 -0
- package/src/components/layout/AppShell.vue +33 -0
- package/src/components/layout/Footer.vue +11 -0
- package/src/components/layout/Sidebar.vue +254 -0
- package/src/components/layout/Topbar.vue +46 -0
- package/src/components/shared/ConfirmDialog.vue +38 -0
- package/src/components/shared/MediaSelectModal.vue +273 -0
- package/src/components/shared/Pagination.vue +93 -0
- package/src/components/shared/StatusBadge.vue +14 -0
- package/src/components/shared/ToastContainer.vue +44 -0
- package/src/composables/.gitkeep +0 -0
- package/src/composables/__tests__/useApiClient.test.ts +90 -0
- package/src/composables/__tests__/useFormValidation.test.ts +83 -0
- package/src/composables/__tests__/usePermission.test.ts +116 -0
- package/src/composables/useApiClient.ts +68 -0
- package/src/composables/useDirtyState.ts +28 -0
- package/src/composables/useFormValidation.ts +107 -0
- package/src/composables/useNotification.ts +26 -0
- package/src/composables/usePermission.ts +20 -0
- package/src/composables/useTabIndicator.ts +25 -0
- package/src/env.d.ts +2 -0
- package/src/main.ts +21 -0
- package/src/router/index.ts +174 -0
- package/src/stores/auth.ts +82 -0
- package/src/stores/content.ts +40 -0
- package/src/stores/media.ts +31 -0
- package/src/stores/schema.ts +130 -0
- package/src/stores/taxonomy.ts +40 -0
- package/src/stores/ui.ts +94 -0
- package/src/stores/users.ts +44 -0
- package/src/style.css +46 -0
- package/src/test-utils/server.ts +54 -0
- package/src/types/index.ts +0 -0
- package/src/views/.gitkeep +0 -0
- package/src/views/ChangePasswordView.vue +134 -0
- package/src/views/LoginView.vue +187 -0
- package/src/views/RolesView.vue +84 -0
- package/src/views/content/ContentFormView.vue +548 -0
- package/src/views/content/ContentListView.vue +236 -0
- package/src/views/content/ContentSettingsView.vue +122 -0
- package/src/views/media/MediaDetailView.vue +222 -0
- package/src/views/media/MediaLibraryView.vue +492 -0
- package/src/views/taxonomy/TaxonomyFormView.vue +281 -0
- package/src/views/taxonomy/TaxonomyListView.vue +198 -0
- package/src/views/users/UserFormView.vue +307 -0
- package/src/views/users/UserListView.vue +127 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +17 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, watch } from 'vue'
|
|
3
|
+
import type { Component } from 'vue'
|
|
4
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
5
|
+
import type { ParsedField } from '@bobbykim/manguito-cms-core'
|
|
6
|
+
import { useApiClient } from '../../composables/useApiClient'
|
|
7
|
+
import { usePermission } from '../../composables/usePermission'
|
|
8
|
+
import { useFormValidation } from '../../composables/useFormValidation'
|
|
9
|
+
import { useNotification } from '../../composables/useNotification'
|
|
10
|
+
import { useSchemaStore } from '../../stores/schema'
|
|
11
|
+
import { useTaxonomyStore } from '../../stores/taxonomy'
|
|
12
|
+
import TextInput from '../../components/fields/TextInput.vue'
|
|
13
|
+
import RichTextEditor from '../../components/fields/RichTextEditor.vue'
|
|
14
|
+
import NumberInput from '../../components/fields/NumberInput.vue'
|
|
15
|
+
import BooleanToggle from '../../components/fields/BooleanToggle.vue'
|
|
16
|
+
import DatePicker from '../../components/fields/DatePicker.vue'
|
|
17
|
+
import EnumSelect from '../../components/fields/EnumSelect.vue'
|
|
18
|
+
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
|
19
|
+
|
|
20
|
+
const route = useRoute()
|
|
21
|
+
const router = useRouter()
|
|
22
|
+
const api = useApiClient()
|
|
23
|
+
const { can } = usePermission()
|
|
24
|
+
const { errors, validate, mergeServerErrors, clearErrors, setError } = useFormValidation()
|
|
25
|
+
const notify = useNotification()
|
|
26
|
+
const schemaStore = useSchemaStore()
|
|
27
|
+
const taxonomyStore = useTaxonomyStore()
|
|
28
|
+
|
|
29
|
+
// ── Route ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const type = computed(() => route.params.type as string)
|
|
32
|
+
const routeId = computed(() => route.params.id as string | undefined)
|
|
33
|
+
const isEdit = computed(() => !!routeId.value)
|
|
34
|
+
|
|
35
|
+
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const taxonomyType = computed(() => schemaStore.taxonomyTypes[type.value])
|
|
38
|
+
const allFields = computed(() => taxonomyType.value?.fields ?? [])
|
|
39
|
+
|
|
40
|
+
// ── Form state ────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const item = ref<Record<string, unknown> | null>(null)
|
|
43
|
+
const form = ref<Record<string, unknown>>({})
|
|
44
|
+
const loading = ref(true)
|
|
45
|
+
const saving = ref(false)
|
|
46
|
+
const formError = ref('')
|
|
47
|
+
const showDeleteConfirm = ref(false)
|
|
48
|
+
|
|
49
|
+
// ── Field component mapping (flat — no paragraphs, media, or references in taxonomy) ──
|
|
50
|
+
|
|
51
|
+
const FIELD_COMP = {
|
|
52
|
+
'text/plain': TextInput,
|
|
53
|
+
'text/rich': RichTextEditor,
|
|
54
|
+
integer: NumberInput,
|
|
55
|
+
float: NumberInput,
|
|
56
|
+
boolean: BooleanToggle,
|
|
57
|
+
date: DatePicker,
|
|
58
|
+
enum: EnumSelect,
|
|
59
|
+
} as const
|
|
60
|
+
|
|
61
|
+
function componentFor(field: ParsedField): Component {
|
|
62
|
+
const key = field.field_type as keyof typeof FIELD_COMP
|
|
63
|
+
return FIELD_COMP[key] ?? TextInput
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Form helpers ──────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function defaultForField(field: ParsedField): unknown {
|
|
69
|
+
switch (field.field_type) {
|
|
70
|
+
case 'text/plain':
|
|
71
|
+
case 'text/rich':
|
|
72
|
+
return ''
|
|
73
|
+
case 'integer':
|
|
74
|
+
case 'float':
|
|
75
|
+
return null
|
|
76
|
+
case 'boolean':
|
|
77
|
+
return false
|
|
78
|
+
case 'date':
|
|
79
|
+
return null
|
|
80
|
+
case 'enum':
|
|
81
|
+
return ''
|
|
82
|
+
default:
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function initForm(source?: Record<string, unknown>) {
|
|
88
|
+
const f: Record<string, unknown> = {}
|
|
89
|
+
for (const field of allFields.value) {
|
|
90
|
+
f[field.name] = source?.[field.name] ?? defaultForField(field)
|
|
91
|
+
}
|
|
92
|
+
form.value = f
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function updateField(name: string, value: unknown) {
|
|
96
|
+
form.value[name] = value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Data loading ──────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
async function loadItem(id: string) {
|
|
102
|
+
const res = await api.get<Record<string, unknown>>(`/taxonomy/${type.value}/${id}`)
|
|
103
|
+
if (res.ok) {
|
|
104
|
+
item.value = res.data
|
|
105
|
+
initForm(res.data)
|
|
106
|
+
} else {
|
|
107
|
+
formError.value = res.error.message
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
onMounted(async () => {
|
|
112
|
+
if (!taxonomyType.value) {
|
|
113
|
+
loading.value = false
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (isEdit.value && routeId.value) {
|
|
117
|
+
await loadItem(routeId.value)
|
|
118
|
+
} else {
|
|
119
|
+
initForm()
|
|
120
|
+
}
|
|
121
|
+
loading.value = false
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
watch(routeId, async (newId, oldId) => {
|
|
125
|
+
if (newId === oldId) return
|
|
126
|
+
loading.value = true
|
|
127
|
+
clearErrors()
|
|
128
|
+
if (newId) {
|
|
129
|
+
await loadItem(newId)
|
|
130
|
+
} else {
|
|
131
|
+
item.value = null
|
|
132
|
+
initForm()
|
|
133
|
+
}
|
|
134
|
+
loading.value = false
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ── Save / delete ─────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async function onSave() {
|
|
140
|
+
const valid = validate(allFields.value, form.value)
|
|
141
|
+
if (!valid) return
|
|
142
|
+
|
|
143
|
+
saving.value = true
|
|
144
|
+
formError.value = ''
|
|
145
|
+
|
|
146
|
+
const res = isEdit.value
|
|
147
|
+
? await api.patch<Record<string, unknown>>(
|
|
148
|
+
`/taxonomy/${type.value}/${routeId.value!}`,
|
|
149
|
+
form.value
|
|
150
|
+
)
|
|
151
|
+
: await api.post<Record<string, unknown>>(`/taxonomy/${type.value}`, form.value)
|
|
152
|
+
|
|
153
|
+
saving.value = false
|
|
154
|
+
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
const raw = res.error as {
|
|
157
|
+
code: string
|
|
158
|
+
message: string
|
|
159
|
+
details?: Array<{ field: string; message: string }>
|
|
160
|
+
}
|
|
161
|
+
if (raw.code === 'PUBLISH_VALIDATION_ERROR' && raw.details) {
|
|
162
|
+
mergeServerErrors(raw.details)
|
|
163
|
+
} else if (raw.code === 'SLUG_CONFLICT') {
|
|
164
|
+
setError('slug', 'This slug is already taken.')
|
|
165
|
+
} else {
|
|
166
|
+
formError.value = raw.message
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
item.value = res.data
|
|
172
|
+
taxonomyStore.invalidate(type.value)
|
|
173
|
+
notify.success('Saved.')
|
|
174
|
+
|
|
175
|
+
if (!isEdit.value) {
|
|
176
|
+
void router.replace({
|
|
177
|
+
name: 'taxonomy-edit',
|
|
178
|
+
params: { type: type.value, id: String(res.data.id) },
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function doDelete() {
|
|
184
|
+
showDeleteConfirm.value = false
|
|
185
|
+
saving.value = true
|
|
186
|
+
const res = await api.del(`/taxonomy/${type.value}/${routeId.value!}`)
|
|
187
|
+
saving.value = false
|
|
188
|
+
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
formError.value = (res as { ok: false; error: { message: string } }).error.message
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
taxonomyStore.invalidate(type.value)
|
|
195
|
+
void router.push({ name: 'taxonomy-list', params: { type: type.value } })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Page title ────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
const pageTitle = computed(() => {
|
|
201
|
+
if (!isEdit.value) return `New ${taxonomyType.value?.label ?? ''}`
|
|
202
|
+
const tf = allFields.value.find(f => f.field_type === 'text/plain')
|
|
203
|
+
return tf ? String(item.value?.[tf.name] ?? '') : (taxonomyType.value?.label ?? '')
|
|
204
|
+
})
|
|
205
|
+
</script>
|
|
206
|
+
|
|
207
|
+
<template>
|
|
208
|
+
<!-- Loading -->
|
|
209
|
+
<div v-if="loading" class="flex items-center justify-center py-20">
|
|
210
|
+
<span
|
|
211
|
+
class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent"
|
|
212
|
+
aria-hidden="true"
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div v-else-if="!taxonomyType" class="py-12 text-center text-sm text-gray-500">
|
|
217
|
+
Taxonomy type not found.
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div v-else>
|
|
221
|
+
<!-- Page header -->
|
|
222
|
+
<div class="mb-6 flex items-start justify-between gap-4">
|
|
223
|
+
<div>
|
|
224
|
+
<p class="text-xs font-medium uppercase tracking-wide text-gray-400">
|
|
225
|
+
{{ taxonomyType.label }}
|
|
226
|
+
</p>
|
|
227
|
+
<h1 class="text-[26px] font-bold tracking-tight text-ink">{{ pageTitle }}</h1>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<!-- Action bar -->
|
|
231
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
232
|
+
<button
|
|
233
|
+
v-if="can('taxonomy:delete') && isEdit"
|
|
234
|
+
type="button"
|
|
235
|
+
:disabled="saving"
|
|
236
|
+
class="rounded-md px-3 py-2 text-sm font-medium text-red-600 ring-1 ring-inset ring-red-300 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
237
|
+
@click="showDeleteConfirm = true"
|
|
238
|
+
>
|
|
239
|
+
Delete
|
|
240
|
+
</button>
|
|
241
|
+
|
|
242
|
+
<button
|
|
243
|
+
v-if="isEdit ? can('taxonomy:edit') : can('taxonomy:create')"
|
|
244
|
+
type="button"
|
|
245
|
+
:disabled="saving"
|
|
246
|
+
class="rounded-[11px] bg-indigo-600 px-4 py-2.5 text-[13.5px] font-semibold text-white shadow-[0_3px_10px_rgba(91,87,232,0.3)] transition-all hover:bg-indigo-700 hover:shadow-[0_6px_18px_rgba(91,87,232,0.4)] disabled:cursor-not-allowed disabled:opacity-50"
|
|
247
|
+
@click="onSave"
|
|
248
|
+
>
|
|
249
|
+
{{ saving ? 'Saving…' : 'Save' }}
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<!-- Flat field layout — no tabs, no slug, no publish toggle -->
|
|
255
|
+
<div class="space-y-5">
|
|
256
|
+
<component
|
|
257
|
+
:is="componentFor(field)"
|
|
258
|
+
v-for="field in allFields"
|
|
259
|
+
:key="field.name"
|
|
260
|
+
:field="field"
|
|
261
|
+
:model-value="form[field.name]"
|
|
262
|
+
:error="errors[field.name]"
|
|
263
|
+
:disabled="saving"
|
|
264
|
+
@update:model-value="updateField(field.name, $event)"
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<!-- Form-level error -->
|
|
269
|
+
<p v-if="formError" class="mt-4 text-sm text-red-600" role="alert">
|
|
270
|
+
{{ formError }}
|
|
271
|
+
</p>
|
|
272
|
+
|
|
273
|
+
<!-- Delete confirmation -->
|
|
274
|
+
<ConfirmDialog
|
|
275
|
+
v-if="showDeleteConfirm"
|
|
276
|
+
message="Are you sure you want to delete this term? This action cannot be undone."
|
|
277
|
+
:on-confirm="doDelete"
|
|
278
|
+
:on-cancel="() => (showDeleteConfirm = false)"
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
</template>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, watch } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
4
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
5
|
+
import { useApiClient } from '../../composables/useApiClient'
|
|
6
|
+
import { usePermission } from '../../composables/usePermission'
|
|
7
|
+
import { useSchemaStore } from '../../stores/schema'
|
|
8
|
+
import { useTaxonomyStore, DEFAULT_PAGE_SIZE } from '../../stores/taxonomy'
|
|
9
|
+
import Pagination from '../../components/shared/Pagination.vue'
|
|
10
|
+
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
const api = useApiClient()
|
|
14
|
+
const { can } = usePermission()
|
|
15
|
+
const schemaStore = useSchemaStore()
|
|
16
|
+
const taxonomyStore = useTaxonomyStore()
|
|
17
|
+
|
|
18
|
+
const type = computed(() => route.params.type as string)
|
|
19
|
+
const taxonomyType = computed(() => schemaStore.taxonomyTypes[type.value])
|
|
20
|
+
|
|
21
|
+
// First text/plain field — used as the title column.
|
|
22
|
+
const titleField = computed(
|
|
23
|
+
() => taxonomyType.value?.fields.find(f => f.field_type === 'text/plain')?.name ?? 'id'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const items = ref<Record<string, unknown>[]>([])
|
|
27
|
+
const total = ref(0)
|
|
28
|
+
const page = ref(1)
|
|
29
|
+
const loading = ref(false)
|
|
30
|
+
const search = ref('')
|
|
31
|
+
|
|
32
|
+
function formatDate(val: unknown): string {
|
|
33
|
+
if (typeof val !== 'string' || !val) return '—'
|
|
34
|
+
return new Date(val).toLocaleDateString(undefined, {
|
|
35
|
+
year: 'numeric',
|
|
36
|
+
month: 'short',
|
|
37
|
+
day: 'numeric',
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function loadPage(p: number) {
|
|
42
|
+
loading.value = true
|
|
43
|
+
|
|
44
|
+
const term = search.value.trim()
|
|
45
|
+
if (term === '') {
|
|
46
|
+
const cached = taxonomyStore.getPage(type.value, p, DEFAULT_PAGE_SIZE)
|
|
47
|
+
if (cached) {
|
|
48
|
+
items.value = cached as Record<string, unknown>[]
|
|
49
|
+
total.value = taxonomyStore.total
|
|
50
|
+
loading.value = false
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const searchParam = term !== '' ? `&search=${encodeURIComponent(term)}` : ''
|
|
56
|
+
const res = await api.get<Record<string, unknown>[]>(
|
|
57
|
+
`/taxonomy/${type.value}?page=${p}&per_page=${DEFAULT_PAGE_SIZE}${searchParam}`
|
|
58
|
+
)
|
|
59
|
+
loading.value = false
|
|
60
|
+
|
|
61
|
+
if (res.ok) {
|
|
62
|
+
items.value = res.data
|
|
63
|
+
const raw = res as unknown as { meta?: { total: number } }
|
|
64
|
+
total.value = raw.meta?.total ?? res.data.length
|
|
65
|
+
if (term === '') {
|
|
66
|
+
taxonomyStore.total = total.value
|
|
67
|
+
taxonomyStore.setPage(type.value, p, DEFAULT_PAGE_SIZE, res.data)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const onSearchInput = useDebounceFn(() => {
|
|
73
|
+
page.value = 1
|
|
74
|
+
void loadPage(1)
|
|
75
|
+
}, 300)
|
|
76
|
+
|
|
77
|
+
onMounted(() => { void loadPage(page.value) })
|
|
78
|
+
|
|
79
|
+
watch(type, () => {
|
|
80
|
+
page.value = 1
|
|
81
|
+
items.value = []
|
|
82
|
+
void loadPage(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function onPageChange(p: number) {
|
|
86
|
+
page.value = p
|
|
87
|
+
void loadPage(p)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function goToEdit(item: Record<string, unknown>) {
|
|
91
|
+
void router.push({
|
|
92
|
+
name: 'taxonomy-edit',
|
|
93
|
+
params: { type: type.value, id: String(item.id) },
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function goToCreate() {
|
|
98
|
+
void router.push({ name: 'taxonomy-new', params: { type: type.value } })
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<div>
|
|
104
|
+
<!-- Page header -->
|
|
105
|
+
<div class="mb-5.5 flex flex-wrap items-center justify-between gap-4">
|
|
106
|
+
<h1 class="text-[26px] font-bold tracking-tight text-ink">
|
|
107
|
+
{{ taxonomyType?.label ?? type }}
|
|
108
|
+
</h1>
|
|
109
|
+
<button
|
|
110
|
+
v-if="can('taxonomy:create')"
|
|
111
|
+
type="button"
|
|
112
|
+
class="inline-flex items-center gap-1.75 rounded-[11px] bg-indigo-600 px-4 py-2.5 text-[13.5px] font-semibold text-white shadow-[0_3px_10px_rgba(91,87,232,0.3)] transition-all hover:bg-indigo-700 hover:shadow-[0_6px_18px_rgba(91,87,232,0.4)]"
|
|
113
|
+
@click="goToCreate"
|
|
114
|
+
>
|
|
115
|
+
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14" /><path d="M5 12h14" /></svg>
|
|
116
|
+
New {{ taxonomyType?.label ?? 'Term' }}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<!-- Search -->
|
|
121
|
+
<div class="mb-4 flex h-9.5 items-center gap-2 rounded-[10px] border border-card-border bg-[#FBFBFD] px-2.75 text-[13px] transition-colors focus-within:border-indigo-400">
|
|
122
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-faint">
|
|
123
|
+
<path d="M21 21l-4.3-4.3" /><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z" />
|
|
124
|
+
</svg>
|
|
125
|
+
<input
|
|
126
|
+
v-model="search"
|
|
127
|
+
type="text"
|
|
128
|
+
placeholder="Search terms…"
|
|
129
|
+
class="w-full bg-transparent text-ink outline-none placeholder:text-faint"
|
|
130
|
+
@input="onSearchInput"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<!-- Loading skeleton -->
|
|
135
|
+
<div v-if="loading" class="space-y-2">
|
|
136
|
+
<div
|
|
137
|
+
v-for="n in 6"
|
|
138
|
+
:key="n"
|
|
139
|
+
class="h-12 animate-pulse rounded-[10px] bg-gray-100"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- Empty state -->
|
|
144
|
+
<div
|
|
145
|
+
v-else-if="items.length === 0"
|
|
146
|
+
class="rounded-2xl border border-dashed border-gray-300 p-12 text-center"
|
|
147
|
+
>
|
|
148
|
+
<p class="text-sm text-muted">
|
|
149
|
+
{{ search.trim() !== '' ? `No results for “${search.trim()}”.` : 'No terms yet.' }}
|
|
150
|
+
</p>
|
|
151
|
+
<button
|
|
152
|
+
v-if="can('taxonomy:create') && search.trim() === ''"
|
|
153
|
+
type="button"
|
|
154
|
+
class="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
|
155
|
+
@click="goToCreate"
|
|
156
|
+
>
|
|
157
|
+
Create the first one →
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<!-- Table -->
|
|
162
|
+
<div v-else class="overflow-hidden rounded-2xl border border-card-border bg-white shadow-[0_1px_2px_rgba(24,24,48,0.04),0_10px_28px_rgba(24,24,48,0.04)]">
|
|
163
|
+
<table class="w-full text-sm">
|
|
164
|
+
<thead class="text-[11.5px] font-bold uppercase tracking-[.06em] text-faint">
|
|
165
|
+
<tr class="border-b border-divider">
|
|
166
|
+
<th class="px-5.5 py-3.5 text-left">Name</th>
|
|
167
|
+
<th class="px-5.5 py-3.5 text-left">Updated</th>
|
|
168
|
+
</tr>
|
|
169
|
+
</thead>
|
|
170
|
+
<tbody class="divide-y divide-[#F4F3F9]">
|
|
171
|
+
<tr
|
|
172
|
+
v-for="item in items"
|
|
173
|
+
:key="String(item.id)"
|
|
174
|
+
class="cursor-pointer transition-colors hover:bg-[#FAFAFE]"
|
|
175
|
+
@click="goToEdit(item)"
|
|
176
|
+
>
|
|
177
|
+
<td class="px-5.5 py-4 text-[14.5px] font-semibold text-ink">
|
|
178
|
+
{{ item[titleField] ?? '—' }}
|
|
179
|
+
</td>
|
|
180
|
+
<td class="px-5.5 py-4 text-[13px] text-faint">
|
|
181
|
+
{{ formatDate(item.updated_at) }}
|
|
182
|
+
</td>
|
|
183
|
+
</tr>
|
|
184
|
+
</tbody>
|
|
185
|
+
</table>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<!-- Pagination -->
|
|
189
|
+
<div v-if="!loading && total > DEFAULT_PAGE_SIZE" class="mt-4 flex justify-center">
|
|
190
|
+
<Pagination
|
|
191
|
+
:total="total"
|
|
192
|
+
:page="page"
|
|
193
|
+
:per-page="DEFAULT_PAGE_SIZE"
|
|
194
|
+
@update:page="onPageChange"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</template>
|