@bobbykim/manguito-cms-admin 0.1.1 → 0.3.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 (90) hide show
  1. package/dist/assets/{ChangePasswordView-8UW4RWd8.js → ChangePasswordView-B0wg3O_I.js} +1 -1
  2. package/dist/assets/{EnumSelect-BOrBkA76.js → ComputedDisplay-C2FpwSB1.js} +2 -2
  3. package/dist/assets/ContentFormView-D92O0ICh.js +1 -0
  4. package/dist/assets/{ContentListView-BiPHUBHa.js → ContentListView-DM4xggDx.js} +1 -1
  5. package/dist/assets/{ContentSettingsView-DPKF5MRL.js → ContentSettingsView-B9UHNleT.js} +1 -1
  6. package/dist/assets/{LoginView-WELPH4Vh.js → LoginView-DPpdT3Ro.js} +1 -1
  7. package/dist/assets/{MediaDetailView-BT66fV9b.js → MediaDetailView-OUzzhO1B.js} +1 -1
  8. package/dist/assets/{MediaLibraryView-BcdiJ_7a.js → MediaLibraryView-Dx9t8r8T.js} +1 -1
  9. package/dist/assets/TaxonomyFormView-CZlOjxOY.js +1 -0
  10. package/dist/assets/{TaxonomyListView-BB7iTfjb.js → TaxonomyListView-Dkga11a3.js} +1 -1
  11. package/dist/assets/{UserFormView-C2p-FV0A.js → UserFormView-CaOcoKX5.js} +1 -1
  12. package/dist/assets/{UserListView-BnlU2Ovr.js → UserListView-B6_Hdemt.js} +1 -1
  13. package/dist/assets/{index-BkazhAun.js → index-s-2X8XCa.js} +2 -2
  14. package/dist/assets/{useNotification-DU6J5vms.js → useNotification-CEPVkHak.js} +1 -1
  15. package/dist/codegen/index.cjs +7 -2
  16. package/dist/codegen/index.js +7 -2
  17. package/dist/index.html +1 -1
  18. package/index.html +13 -0
  19. package/package.json +11 -7
  20. package/public/favicon.ico +0 -0
  21. package/src/App.vue +147 -0
  22. package/src/adapters/admin.ts +13 -0
  23. package/src/components/.gitkeep +0 -0
  24. package/src/components/fields/BooleanToggle.vue +63 -0
  25. package/src/components/fields/ComputedDisplay.vue +23 -0
  26. package/src/components/fields/DatePicker.vue +61 -0
  27. package/src/components/fields/EnumSelect.vue +69 -0
  28. package/src/components/fields/MediaUpload.vue +445 -0
  29. package/src/components/fields/NumberInput.vue +66 -0
  30. package/src/components/fields/ParagraphEmbed.vue +149 -0
  31. package/src/components/fields/ReferenceSelect.vue +275 -0
  32. package/src/components/fields/RichTextEditor.vue +293 -0
  33. package/src/components/fields/TextInput.vue +93 -0
  34. package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
  35. package/src/components/fields/__tests__/ComputedDisplay.test.ts +34 -0
  36. package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
  37. package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
  38. package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
  39. package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
  40. package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
  41. package/src/components/fields/__tests__/TextInput.test.ts +72 -0
  42. package/src/components/layout/AppShell.vue +33 -0
  43. package/src/components/layout/Footer.vue +11 -0
  44. package/src/components/layout/Sidebar.vue +254 -0
  45. package/src/components/layout/Topbar.vue +46 -0
  46. package/src/components/shared/ConfirmDialog.vue +38 -0
  47. package/src/components/shared/MediaSelectModal.vue +273 -0
  48. package/src/components/shared/Pagination.vue +93 -0
  49. package/src/components/shared/StatusBadge.vue +14 -0
  50. package/src/components/shared/ToastContainer.vue +44 -0
  51. package/src/composables/.gitkeep +0 -0
  52. package/src/composables/__tests__/useApiClient.test.ts +90 -0
  53. package/src/composables/__tests__/useFormValidation.test.ts +83 -0
  54. package/src/composables/__tests__/usePermission.test.ts +116 -0
  55. package/src/composables/useApiClient.ts +68 -0
  56. package/src/composables/useDirtyState.ts +28 -0
  57. package/src/composables/useFormValidation.ts +107 -0
  58. package/src/composables/useNotification.ts +26 -0
  59. package/src/composables/usePermission.ts +20 -0
  60. package/src/composables/useTabIndicator.ts +25 -0
  61. package/src/env.d.ts +2 -0
  62. package/src/main.ts +21 -0
  63. package/src/router/index.ts +174 -0
  64. package/src/stores/auth.ts +82 -0
  65. package/src/stores/content.ts +40 -0
  66. package/src/stores/media.ts +31 -0
  67. package/src/stores/schema.ts +130 -0
  68. package/src/stores/taxonomy.ts +40 -0
  69. package/src/stores/ui.ts +94 -0
  70. package/src/stores/users.ts +44 -0
  71. package/src/style.css +46 -0
  72. package/src/test-utils/server.ts +54 -0
  73. package/src/types/index.ts +0 -0
  74. package/src/views/.gitkeep +0 -0
  75. package/src/views/ChangePasswordView.vue +134 -0
  76. package/src/views/LoginView.vue +187 -0
  77. package/src/views/RolesView.vue +84 -0
  78. package/src/views/content/ContentFormView.vue +553 -0
  79. package/src/views/content/ContentListView.vue +236 -0
  80. package/src/views/content/ContentSettingsView.vue +122 -0
  81. package/src/views/media/MediaDetailView.vue +222 -0
  82. package/src/views/media/MediaLibraryView.vue +492 -0
  83. package/src/views/taxonomy/TaxonomyFormView.vue +286 -0
  84. package/src/views/taxonomy/TaxonomyListView.vue +198 -0
  85. package/src/views/users/UserFormView.vue +307 -0
  86. package/src/views/users/UserListView.vue +127 -0
  87. package/src/vite-env.d.ts +1 -0
  88. package/vite.config.ts +17 -0
  89. package/dist/assets/ContentFormView-vaIInaYs.js +0 -1
  90. package/dist/assets/TaxonomyFormView-BdRO9L-7.js +0 -1
@@ -0,0 +1,286 @@
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 ComputedDisplay from '../../components/fields/ComputedDisplay.vue'
19
+ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
20
+
21
+ const route = useRoute()
22
+ const router = useRouter()
23
+ const api = useApiClient()
24
+ const { can } = usePermission()
25
+ const { errors, validate, mergeServerErrors, clearErrors, setError } = useFormValidation()
26
+ const notify = useNotification()
27
+ const schemaStore = useSchemaStore()
28
+ const taxonomyStore = useTaxonomyStore()
29
+
30
+ // ── Route ─────────────────────────────────────────────────────────────────────
31
+
32
+ const type = computed(() => route.params.type as string)
33
+ const routeId = computed(() => route.params.id as string | undefined)
34
+ const isEdit = computed(() => !!routeId.value)
35
+
36
+ // ── Schema ────────────────────────────────────────────────────────────────────
37
+
38
+ const taxonomyType = computed(() => schemaStore.taxonomyTypes[type.value])
39
+ const allFields = computed(() => taxonomyType.value?.fields ?? [])
40
+
41
+ // ── Form state ────────────────────────────────────────────────────────────────
42
+
43
+ const item = ref<Record<string, unknown> | null>(null)
44
+ const form = ref<Record<string, unknown>>({})
45
+ const loading = ref(true)
46
+ const saving = ref(false)
47
+ const formError = ref('')
48
+ const showDeleteConfirm = ref(false)
49
+
50
+ // ── Field component mapping (flat — no paragraphs, media, or references in taxonomy) ──
51
+
52
+ const FIELD_COMP = {
53
+ 'text/plain': TextInput,
54
+ 'text/rich': RichTextEditor,
55
+ integer: NumberInput,
56
+ float: NumberInput,
57
+ boolean: BooleanToggle,
58
+ date: DatePicker,
59
+ enum: EnumSelect,
60
+ programmatic: ComputedDisplay,
61
+ } as const
62
+
63
+ function componentFor(field: ParsedField): Component {
64
+ const key = field.field_type as keyof typeof FIELD_COMP
65
+ return FIELD_COMP[key] ?? TextInput
66
+ }
67
+
68
+ // ── Form helpers ──────────────────────────────────────────────────────────────
69
+
70
+ function defaultForField(field: ParsedField): unknown {
71
+ switch (field.field_type) {
72
+ case 'text/plain':
73
+ case 'text/rich':
74
+ return ''
75
+ case 'integer':
76
+ case 'float':
77
+ return null
78
+ case 'boolean':
79
+ return false
80
+ case 'date':
81
+ return null
82
+ case 'enum':
83
+ return ''
84
+ default:
85
+ return null
86
+ }
87
+ }
88
+
89
+ function initForm(source?: Record<string, unknown>) {
90
+ const f: Record<string, unknown> = {}
91
+ for (const field of allFields.value) {
92
+ // Programmatic fields are computed at read time — never edited or submitted,
93
+ // so they stay out of the form state (and therefore the save payload).
94
+ if (field.field_type === 'programmatic') continue
95
+ f[field.name] = source?.[field.name] ?? defaultForField(field)
96
+ }
97
+ form.value = f
98
+ }
99
+
100
+ function updateField(name: string, value: unknown) {
101
+ form.value[name] = value
102
+ }
103
+
104
+ // ── Data loading ──────────────────────────────────────────────────────────────
105
+
106
+ async function loadItem(id: string) {
107
+ const res = await api.get<Record<string, unknown>>(`/taxonomy/${type.value}/${id}`)
108
+ if (res.ok) {
109
+ item.value = res.data
110
+ initForm(res.data)
111
+ } else {
112
+ formError.value = res.error.message
113
+ }
114
+ }
115
+
116
+ onMounted(async () => {
117
+ if (!taxonomyType.value) {
118
+ loading.value = false
119
+ return
120
+ }
121
+ if (isEdit.value && routeId.value) {
122
+ await loadItem(routeId.value)
123
+ } else {
124
+ initForm()
125
+ }
126
+ loading.value = false
127
+ })
128
+
129
+ watch(routeId, async (newId, oldId) => {
130
+ if (newId === oldId) return
131
+ loading.value = true
132
+ clearErrors()
133
+ if (newId) {
134
+ await loadItem(newId)
135
+ } else {
136
+ item.value = null
137
+ initForm()
138
+ }
139
+ loading.value = false
140
+ })
141
+
142
+ // ── Save / delete ─────────────────────────────────────────────────────────────
143
+
144
+ async function onSave() {
145
+ const valid = validate(allFields.value, form.value)
146
+ if (!valid) return
147
+
148
+ saving.value = true
149
+ formError.value = ''
150
+
151
+ const res = isEdit.value
152
+ ? await api.patch<Record<string, unknown>>(
153
+ `/taxonomy/${type.value}/${routeId.value!}`,
154
+ form.value
155
+ )
156
+ : await api.post<Record<string, unknown>>(`/taxonomy/${type.value}`, form.value)
157
+
158
+ saving.value = false
159
+
160
+ if (!res.ok) {
161
+ const raw = res.error as {
162
+ code: string
163
+ message: string
164
+ details?: Array<{ field: string; message: string }>
165
+ }
166
+ if (raw.code === 'PUBLISH_VALIDATION_ERROR' && raw.details) {
167
+ mergeServerErrors(raw.details)
168
+ } else if (raw.code === 'SLUG_CONFLICT') {
169
+ setError('slug', 'This slug is already taken.')
170
+ } else {
171
+ formError.value = raw.message
172
+ }
173
+ return
174
+ }
175
+
176
+ item.value = res.data
177
+ taxonomyStore.invalidate(type.value)
178
+ notify.success('Saved.')
179
+
180
+ if (!isEdit.value) {
181
+ void router.replace({
182
+ name: 'taxonomy-edit',
183
+ params: { type: type.value, id: String(res.data.id) },
184
+ })
185
+ }
186
+ }
187
+
188
+ async function doDelete() {
189
+ showDeleteConfirm.value = false
190
+ saving.value = true
191
+ const res = await api.del(`/taxonomy/${type.value}/${routeId.value!}`)
192
+ saving.value = false
193
+
194
+ if (!res.ok) {
195
+ formError.value = (res as { ok: false; error: { message: string } }).error.message
196
+ return
197
+ }
198
+
199
+ taxonomyStore.invalidate(type.value)
200
+ void router.push({ name: 'taxonomy-list', params: { type: type.value } })
201
+ }
202
+
203
+ // ── Page title ────────────────────────────────────────────────────────────────
204
+
205
+ const pageTitle = computed(() => {
206
+ if (!isEdit.value) return `New ${taxonomyType.value?.label ?? ''}`
207
+ const tf = allFields.value.find(f => f.field_type === 'text/plain')
208
+ return tf ? String(item.value?.[tf.name] ?? '') : (taxonomyType.value?.label ?? '')
209
+ })
210
+ </script>
211
+
212
+ <template>
213
+ <!-- Loading -->
214
+ <div v-if="loading" class="flex items-center justify-center py-20">
215
+ <span
216
+ class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent"
217
+ aria-hidden="true"
218
+ />
219
+ </div>
220
+
221
+ <div v-else-if="!taxonomyType" class="py-12 text-center text-sm text-gray-500">
222
+ Taxonomy type not found.
223
+ </div>
224
+
225
+ <div v-else>
226
+ <!-- Page header -->
227
+ <div class="mb-6 flex items-start justify-between gap-4">
228
+ <div>
229
+ <p class="text-xs font-medium uppercase tracking-wide text-gray-400">
230
+ {{ taxonomyType.label }}
231
+ </p>
232
+ <h1 class="text-[26px] font-bold tracking-tight text-ink">{{ pageTitle }}</h1>
233
+ </div>
234
+
235
+ <!-- Action bar -->
236
+ <div class="flex shrink-0 items-center gap-2">
237
+ <button
238
+ v-if="can('taxonomy:delete') && isEdit"
239
+ type="button"
240
+ :disabled="saving"
241
+ 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"
242
+ @click="showDeleteConfirm = true"
243
+ >
244
+ Delete
245
+ </button>
246
+
247
+ <button
248
+ v-if="isEdit ? can('taxonomy:edit') : can('taxonomy:create')"
249
+ type="button"
250
+ :disabled="saving"
251
+ 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"
252
+ @click="onSave"
253
+ >
254
+ {{ saving ? 'Saving…' : 'Save' }}
255
+ </button>
256
+ </div>
257
+ </div>
258
+
259
+ <!-- Flat field layout — no tabs, no slug, no publish toggle -->
260
+ <div class="space-y-5">
261
+ <component
262
+ :is="componentFor(field)"
263
+ v-for="field in allFields"
264
+ :key="field.name"
265
+ :field="field"
266
+ :model-value="form[field.name]"
267
+ :error="errors[field.name]"
268
+ :disabled="saving"
269
+ @update:model-value="updateField(field.name, $event)"
270
+ />
271
+ </div>
272
+
273
+ <!-- Form-level error -->
274
+ <p v-if="formError" class="mt-4 text-sm text-red-600" role="alert">
275
+ {{ formError }}
276
+ </p>
277
+
278
+ <!-- Delete confirmation -->
279
+ <ConfirmDialog
280
+ v-if="showDeleteConfirm"
281
+ message="Are you sure you want to delete this term? This action cannot be undone."
282
+ :on-confirm="doDelete"
283
+ :on-cancel="() => (showDeleteConfirm = false)"
284
+ />
285
+ </div>
286
+ </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>