@bobbykim/manguito-cms-admin 0.1.1 → 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.
Files changed (69) hide show
  1. package/index.html +13 -0
  2. package/package.json +10 -6
  3. package/public/favicon.ico +0 -0
  4. package/src/App.vue +147 -0
  5. package/src/adapters/admin.ts +13 -0
  6. package/src/components/.gitkeep +0 -0
  7. package/src/components/fields/BooleanToggle.vue +63 -0
  8. package/src/components/fields/DatePicker.vue +61 -0
  9. package/src/components/fields/EnumSelect.vue +69 -0
  10. package/src/components/fields/MediaUpload.vue +445 -0
  11. package/src/components/fields/NumberInput.vue +66 -0
  12. package/src/components/fields/ParagraphEmbed.vue +149 -0
  13. package/src/components/fields/ReferenceSelect.vue +275 -0
  14. package/src/components/fields/RichTextEditor.vue +293 -0
  15. package/src/components/fields/TextInput.vue +93 -0
  16. package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
  17. package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
  18. package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
  19. package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
  20. package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
  21. package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
  22. package/src/components/fields/__tests__/TextInput.test.ts +72 -0
  23. package/src/components/layout/AppShell.vue +33 -0
  24. package/src/components/layout/Footer.vue +11 -0
  25. package/src/components/layout/Sidebar.vue +254 -0
  26. package/src/components/layout/Topbar.vue +46 -0
  27. package/src/components/shared/ConfirmDialog.vue +38 -0
  28. package/src/components/shared/MediaSelectModal.vue +273 -0
  29. package/src/components/shared/Pagination.vue +93 -0
  30. package/src/components/shared/StatusBadge.vue +14 -0
  31. package/src/components/shared/ToastContainer.vue +44 -0
  32. package/src/composables/.gitkeep +0 -0
  33. package/src/composables/__tests__/useApiClient.test.ts +90 -0
  34. package/src/composables/__tests__/useFormValidation.test.ts +83 -0
  35. package/src/composables/__tests__/usePermission.test.ts +116 -0
  36. package/src/composables/useApiClient.ts +68 -0
  37. package/src/composables/useDirtyState.ts +28 -0
  38. package/src/composables/useFormValidation.ts +107 -0
  39. package/src/composables/useNotification.ts +26 -0
  40. package/src/composables/usePermission.ts +20 -0
  41. package/src/composables/useTabIndicator.ts +25 -0
  42. package/src/env.d.ts +2 -0
  43. package/src/main.ts +21 -0
  44. package/src/router/index.ts +174 -0
  45. package/src/stores/auth.ts +82 -0
  46. package/src/stores/content.ts +40 -0
  47. package/src/stores/media.ts +31 -0
  48. package/src/stores/schema.ts +130 -0
  49. package/src/stores/taxonomy.ts +40 -0
  50. package/src/stores/ui.ts +94 -0
  51. package/src/stores/users.ts +44 -0
  52. package/src/style.css +46 -0
  53. package/src/test-utils/server.ts +54 -0
  54. package/src/types/index.ts +0 -0
  55. package/src/views/.gitkeep +0 -0
  56. package/src/views/ChangePasswordView.vue +134 -0
  57. package/src/views/LoginView.vue +187 -0
  58. package/src/views/RolesView.vue +84 -0
  59. package/src/views/content/ContentFormView.vue +548 -0
  60. package/src/views/content/ContentListView.vue +236 -0
  61. package/src/views/content/ContentSettingsView.vue +122 -0
  62. package/src/views/media/MediaDetailView.vue +222 -0
  63. package/src/views/media/MediaLibraryView.vue +492 -0
  64. package/src/views/taxonomy/TaxonomyFormView.vue +281 -0
  65. package/src/views/taxonomy/TaxonomyListView.vue +198 -0
  66. package/src/views/users/UserFormView.vue +307 -0
  67. package/src/views/users/UserListView.vue +127 -0
  68. package/src/vite-env.d.ts +1 -0
  69. package/vite.config.ts +17 -0
@@ -0,0 +1,548 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, watch, defineComponent, h, markRaw } from 'vue'
3
+ import type { Component, PropType } 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 { useContentStore } from '../../stores/content'
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 MediaUpload from '../../components/fields/MediaUpload.vue'
18
+ import EnumSelect from '../../components/fields/EnumSelect.vue'
19
+ import ReferenceSelect from '../../components/fields/ReferenceSelect.vue'
20
+ import ParagraphEmbed from '../../components/fields/ParagraphEmbed.vue'
21
+ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
22
+ import { useTabIndicator } from '../../composables/useTabIndicator'
23
+
24
+ const route = useRoute()
25
+ const router = useRouter()
26
+ const api = useApiClient()
27
+ const { can } = usePermission()
28
+ const { errors, validate, mergeServerErrors, clearErrors, setError } = useFormValidation()
29
+ const notify = useNotification()
30
+ const schemaStore = useSchemaStore()
31
+ const contentStore = useContentStore()
32
+
33
+ // ── Route params ──────────────────────────────────────────────────────────────
34
+
35
+ const type = computed(() => route.params.type as string)
36
+ const routeId = computed(() => route.params.id as string | undefined)
37
+
38
+ // ── Schema ────────────────────────────────────────────────────────────────────
39
+
40
+ const contentType = computed(() => schemaStore.contentTypes[type.value])
41
+ const allFields = computed(() => contentType.value?.fields ?? [])
42
+ const tabs = computed(() => contentType.value?.ui.tabs ?? [])
43
+
44
+ // ── Mode ──────────────────────────────────────────────────────────────────────
45
+
46
+ const isSingleton = computed(() => contentType.value?.only_one === true)
47
+ const mode = computed<'create' | 'edit' | 'singleton'>(() => {
48
+ if (isSingleton.value) return 'singleton'
49
+ if (!routeId.value) return 'create'
50
+ return 'edit'
51
+ })
52
+
53
+ // ── Form state ────────────────────────────────────────────────────────────────
54
+
55
+ const item = ref<Record<string, unknown> | null>(null)
56
+ const form = ref<Record<string, unknown>>({})
57
+ const isPublished = ref(false)
58
+ const originalSlug = ref('')
59
+ const slugUnlocked = ref(false)
60
+
61
+ const loading = ref(true)
62
+ const saving = ref(false)
63
+ const formError = ref('')
64
+
65
+ const showSlugConfirm = ref(false)
66
+ const showDeleteConfirm = ref(false)
67
+ const pendingAction = ref<(() => Promise<void>) | null>(null)
68
+
69
+ // Active tab index
70
+ const activeTabIndex = ref(0)
71
+ const activeTabId = computed(() => tabs.value[activeTabIndex.value]?.name ?? '')
72
+ const tabBarRef = ref<HTMLElement | null>(null)
73
+ const { left: tabIndLeft, width: tabIndWidth } = useTabIndicator(tabBarRef, activeTabId)
74
+ const activeTabFields = computed<ParsedField[]>(() => {
75
+ if (tabs.value.length === 0) return allFields.value
76
+ const tab = tabs.value[activeTabIndex.value]
77
+ if (!tab) return allFields.value
78
+ return tab.fields
79
+ .map(name => allFields.value.find(f => f.name === name))
80
+ .filter((f): f is ParsedField => f !== undefined)
81
+ })
82
+
83
+ const slugChanged = computed(
84
+ () =>
85
+ mode.value === 'edit' &&
86
+ isPublished.value &&
87
+ (form.value.slug as string | undefined) !== originalSlug.value
88
+ )
89
+
90
+ // ── Field component mapping ───────────────────────────────────────────────────
91
+
92
+ const FIELD_COMP: Record<string, Component> = {
93
+ 'text/plain': markRaw(TextInput),
94
+ 'text/rich': markRaw(RichTextEditor),
95
+ integer: markRaw(NumberInput),
96
+ float: markRaw(NumberInput),
97
+ boolean: markRaw(BooleanToggle),
98
+ date: markRaw(DatePicker),
99
+ image: markRaw(MediaUpload),
100
+ video: markRaw(MediaUpload),
101
+ file: markRaw(MediaUpload),
102
+ enum: markRaw(EnumSelect),
103
+ reference: markRaw(ReferenceSelect),
104
+ paragraph: markRaw(ParagraphEmbed),
105
+ }
106
+
107
+ function componentFor(field: ParsedField): Component {
108
+ return FIELD_COMP[field.field_type] ?? FIELD_COMP['text/plain']!
109
+ }
110
+
111
+ // ── Paragraph form factory (render function — no runtime compiler needed) ─────
112
+
113
+ const paragraphFormCache = new Map<string, Component>()
114
+
115
+ function getParagraphForm(schemaName: string): Component {
116
+ if (paragraphFormCache.has(schemaName)) {
117
+ return paragraphFormCache.get(schemaName)!
118
+ }
119
+
120
+ const comp = markRaw(
121
+ defineComponent({
122
+ name: `ParagraphForm_${schemaName}`,
123
+ props: {
124
+ modelValue: {
125
+ type: Object as PropType<Record<string, unknown>>,
126
+ default: () => ({}),
127
+ },
128
+ disabled: { type: Boolean, default: false },
129
+ },
130
+ emits: ['update:modelValue'],
131
+ setup(props, { emit }) {
132
+ const store = useSchemaStore()
133
+ const schema = computed(() => store.paragraphTypes[schemaName])
134
+
135
+ function update(name: string, val: unknown) {
136
+ emit('update:modelValue', { ...props.modelValue, [name]: val })
137
+ }
138
+
139
+ return () => {
140
+ if (!schema.value) return h('div', { class: 'text-sm text-gray-400' }, 'Unknown paragraph type')
141
+ return h(
142
+ 'div',
143
+ { class: 'space-y-3' },
144
+ schema.value.fields.map(field => {
145
+ const c = FIELD_COMP[field.field_type] ?? FIELD_COMP['text/plain']!
146
+ return h(c as Parameters<typeof h>[0], {
147
+ key: field.name,
148
+ field,
149
+ modelValue: (props.modelValue ?? {})[field.name],
150
+ disabled: props.disabled,
151
+ 'onUpdate:modelValue': (v: unknown) => update(field.name, v),
152
+ })
153
+ })
154
+ )
155
+ }
156
+ },
157
+ })
158
+ )
159
+
160
+ paragraphFormCache.set(schemaName, comp)
161
+ return comp
162
+ }
163
+
164
+ // Extra props injected into paragraph fields.
165
+ function fieldExtraProps(field: ParsedField): Record<string, unknown> {
166
+ if (field.ui_component.component === 'paragraph-embed') {
167
+ return { formComponent: getParagraphForm(field.ui_component.ref) }
168
+ }
169
+ return {}
170
+ }
171
+
172
+ // ── Form helpers ──────────────────────────────────────────────────────────────
173
+
174
+ function defaultForField(field: ParsedField): unknown {
175
+ switch (field.field_type) {
176
+ case 'text/plain':
177
+ case 'text/rich':
178
+ return ''
179
+ case 'integer':
180
+ case 'float':
181
+ return null
182
+ case 'boolean':
183
+ return false
184
+ case 'date':
185
+ return null
186
+ case 'image':
187
+ case 'video':
188
+ case 'file':
189
+ return null
190
+ case 'enum':
191
+ return ''
192
+ case 'reference': {
193
+ const rel =
194
+ field.ui_component.component === 'typeahead-select'
195
+ ? field.ui_component.rel
196
+ : 'one-to-one'
197
+ return rel === 'one-to-one' ? null : []
198
+ }
199
+ case 'paragraph':
200
+ return []
201
+ default:
202
+ return null
203
+ }
204
+ }
205
+
206
+ function initForm(source?: Record<string, unknown>) {
207
+ const f: Record<string, unknown> = {}
208
+ if (!isSingleton.value) {
209
+ f.slug = source?.slug ?? ''
210
+ }
211
+ for (const field of allFields.value) {
212
+ f[field.name] = source?.[field.name] ?? defaultForField(field)
213
+ }
214
+ form.value = f
215
+ isPublished.value = source?.published === true
216
+ if (source?.slug) originalSlug.value = String(source.slug)
217
+ }
218
+
219
+ function updateField(name: string, value: unknown) {
220
+ form.value[name] = value
221
+ }
222
+
223
+ // ── Data loading ──────────────────────────────────────────────────────────────
224
+
225
+ async function loadItem(id: string) {
226
+ const res = await api.get<Record<string, unknown>>(`/content/${type.value}/${id}`)
227
+ if (res.ok) {
228
+ item.value = res.data
229
+ initForm(res.data)
230
+ } else {
231
+ formError.value = res.error.message
232
+ }
233
+ }
234
+
235
+ onMounted(async () => {
236
+ if (!contentType.value) {
237
+ loading.value = false
238
+ return
239
+ }
240
+
241
+ if (mode.value === 'edit' && routeId.value) {
242
+ await loadItem(routeId.value)
243
+ } else if (mode.value === 'singleton' && routeId.value) {
244
+ await loadItem(routeId.value)
245
+ } else {
246
+ initForm()
247
+ }
248
+
249
+ loading.value = false
250
+ })
251
+
252
+ // Reload when navigating between create/edit within the same type (e.g. after create redirect).
253
+ watch(routeId, async (newId, oldId) => {
254
+ if (newId === oldId) return
255
+ loading.value = true
256
+ slugUnlocked.value = false
257
+ clearErrors()
258
+ if (newId) {
259
+ await loadItem(newId)
260
+ } else {
261
+ item.value = null
262
+ initForm()
263
+ }
264
+ loading.value = false
265
+ })
266
+
267
+ // ── Save / publish / delete ───────────────────────────────────────────────────
268
+
269
+ async function doSave(publishedOverride?: boolean) {
270
+ saving.value = true
271
+ formError.value = ''
272
+
273
+ const payload: Record<string, unknown> = { ...form.value }
274
+ if (publishedOverride !== undefined) payload.published = publishedOverride
275
+
276
+ let res:
277
+ | { ok: true; data: Record<string, unknown> }
278
+ | { ok: false; error: { code: string; message: string } }
279
+
280
+ if (mode.value === 'create') {
281
+ res = await api.post<Record<string, unknown>>(`/content/${type.value}`, payload)
282
+ } else if (mode.value === 'singleton') {
283
+ res = await api.put<Record<string, unknown>>(`/content/${type.value}`, payload)
284
+ } else {
285
+ res = await api.patch<Record<string, unknown>>(
286
+ `/content/${type.value}/${routeId.value!}`,
287
+ payload
288
+ )
289
+ }
290
+
291
+ saving.value = false
292
+
293
+ if (!res.ok) {
294
+ const raw = res.error as { code: string; message: string; details?: Array<{ field: string; message: string }> }
295
+ if (raw.code === 'PUBLISH_VALIDATION_ERROR' && raw.details) {
296
+ mergeServerErrors(raw.details)
297
+ } else if (raw.code === 'SLUG_CONFLICT') {
298
+ setError('slug', 'This slug is already taken.')
299
+ } else {
300
+ formError.value = raw.message
301
+ }
302
+ return
303
+ }
304
+
305
+ item.value = res.data
306
+ isPublished.value = res.data.published === true
307
+ originalSlug.value = String(res.data.slug ?? '')
308
+ slugUnlocked.value = false
309
+ contentStore.invalidate(type.value)
310
+ notify.success('Saved.')
311
+
312
+ if (mode.value === 'create') {
313
+ void router.replace({
314
+ name: 'content-edit',
315
+ params: { type: type.value, id: String(res.data.id) },
316
+ })
317
+ }
318
+ }
319
+
320
+ function saveDraft() {
321
+ const valid = validate(allFields.value, form.value)
322
+ if (!valid) return
323
+ if (slugChanged.value) {
324
+ pendingAction.value = () => doSave()
325
+ showSlugConfirm.value = true
326
+ return
327
+ }
328
+ void doSave()
329
+ }
330
+
331
+ function publish() {
332
+ const valid = validate(allFields.value, form.value, true)
333
+ if (!valid) return
334
+ if (slugChanged.value) {
335
+ pendingAction.value = () => doSave(true)
336
+ showSlugConfirm.value = true
337
+ return
338
+ }
339
+ void doSave(true)
340
+ }
341
+
342
+ function unpublish() {
343
+ void doSave(false)
344
+ }
345
+
346
+ function confirmSlugChange() {
347
+ showSlugConfirm.value = false
348
+ if (pendingAction.value) {
349
+ void pendingAction.value()
350
+ pendingAction.value = null
351
+ }
352
+ }
353
+
354
+ function cancelSlugChange() {
355
+ showSlugConfirm.value = false
356
+ pendingAction.value = null
357
+ }
358
+
359
+ async function doDelete() {
360
+ showDeleteConfirm.value = false
361
+ saving.value = true
362
+ const res = await api.del(`/content/${type.value}/${routeId.value!}`)
363
+ saving.value = false
364
+
365
+ if (!res.ok) {
366
+ formError.value = (res as { ok: false; error: { message: string } }).error.message
367
+ return
368
+ }
369
+
370
+ contentStore.invalidate(type.value)
371
+ void router.push({ name: 'content-list', params: { type: type.value } })
372
+ }
373
+
374
+ // ── Page title helper ─────────────────────────────────────────────────────────
375
+
376
+ const pageTitle = computed(() => {
377
+ if (mode.value === 'create') return `New ${contentType.value?.label ?? ''}`
378
+ if (mode.value === 'singleton') return contentType.value?.label ?? ''
379
+ const titleField = allFields.value.find(f => f.field_type === 'text/plain')
380
+ const title = titleField ? String(item.value?.[titleField.name] ?? '') : ''
381
+ return title || (contentType.value?.label ?? '')
382
+ })
383
+ </script>
384
+
385
+ <template>
386
+ <!-- Full-page loading -->
387
+ <div v-if="loading" class="flex items-center justify-center py-20">
388
+ <span
389
+ class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent"
390
+ aria-hidden="true"
391
+ />
392
+ </div>
393
+
394
+ <div v-else-if="!contentType" class="py-12 text-center text-sm text-gray-500">
395
+ Content type not found.
396
+ </div>
397
+
398
+ <div v-else>
399
+ <!-- Page header -->
400
+ <div class="mb-[22px] flex flex-wrap items-start justify-between gap-4">
401
+ <div>
402
+ <p class="mb-1.5 text-[11px] font-bold uppercase tracking-[.06em] text-faint">
403
+ {{ contentType.label }}
404
+ </p>
405
+ <h1 class="text-[25px] font-bold tracking-tight text-ink">{{ pageTitle }}</h1>
406
+ </div>
407
+
408
+ <!-- Action bar -->
409
+ <div class="flex shrink-0 flex-wrap items-center gap-[9px]">
410
+ <button
411
+ v-if="can('content:delete') && mode === 'edit'"
412
+ type="button"
413
+ :disabled="saving"
414
+ class="inline-flex items-center gap-1.5 rounded-[10px] border border-[#F3D2D7] bg-white px-[15px] py-2.5 text-[13px] font-semibold text-[#E1495B] transition-colors hover:bg-[#FDF2F4] disabled:cursor-not-allowed disabled:opacity-50"
415
+ @click="showDeleteConfirm = true"
416
+ >
417
+ Delete
418
+ </button>
419
+
420
+ <template v-if="can('content:edit')">
421
+ <button
422
+ v-if="isPublished"
423
+ type="button"
424
+ :disabled="saving"
425
+ class="rounded-[10px] border border-[#E4E3EE] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#3D3D52] transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
426
+ @click="unpublish"
427
+ >
428
+ Unpublish
429
+ </button>
430
+ <button
431
+ v-else
432
+ type="button"
433
+ :disabled="saving"
434
+ class="rounded-[10px] border border-[#E4E3EE] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#3D3D52] transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
435
+ @click="publish"
436
+ >
437
+ Publish
438
+ </button>
439
+
440
+ <button
441
+ type="button"
442
+ :disabled="saving"
443
+ class="inline-flex items-center gap-1.5 rounded-[10px] bg-indigo-600 px-4 py-2.5 text-[13px] 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"
444
+ @click="saveDraft"
445
+ >
446
+ {{ saving ? 'Saving…' : 'Save draft' }}
447
+ </button>
448
+ </template>
449
+ </div>
450
+ </div>
451
+
452
+ <!-- Slug field (hidden for singletons) -->
453
+ <div v-if="!isSingleton" class="mb-[18px] rounded-2xl border border-card-border bg-white p-5 shadow-[0_1px_2px_rgba(24,24,48,0.04),0_8px_22px_rgba(24,24,48,0.04)]">
454
+ <label for="slug-field" class="block text-[13px] font-semibold text-[#3D3D52]">
455
+ Slug
456
+ <span class="ml-0.5 text-red-500" aria-hidden="true">*</span>
457
+ </label>
458
+
459
+ <div class="mt-2 flex flex-wrap items-center gap-[10px]">
460
+ <input
461
+ id="slug-field"
462
+ v-model="form.slug"
463
+ type="text"
464
+ :disabled="saving || (isPublished && !slugUnlocked)"
465
+ :class="[
466
+ 'block min-w-[180px] flex-1 rounded-[10px] border px-[13px] py-[11px] font-mono text-sm focus:outline-none focus:ring-2',
467
+ errors.slug
468
+ ? 'border-red-300 focus:border-red-400 focus:ring-red-200'
469
+ : 'border-[#E4E3EE] bg-surface focus:border-indigo-400 focus:ring-indigo-200',
470
+ (saving || (isPublished && !slugUnlocked)) &&
471
+ 'cursor-not-allowed bg-gray-100 opacity-70',
472
+ ]"
473
+ placeholder="url-slug"
474
+ />
475
+ <button
476
+ v-if="isPublished && !slugUnlocked"
477
+ type="button"
478
+ class="shrink-0 rounded-[10px] border border-[#E4E3EE] bg-white px-4 py-2.5 text-sm font-semibold text-[#3D3D52] transition-colors hover:bg-surface"
479
+ @click="slugUnlocked = true"
480
+ >
481
+ Edit slug
482
+ </button>
483
+ </div>
484
+
485
+ <p v-if="isPublished" class="mt-[9px] text-[12.5px] text-[#D9913C]">
486
+ Changing this slug will break existing links.
487
+ </p>
488
+ <p v-if="errors.slug" class="mt-1 text-sm text-red-600" role="alert">
489
+ {{ errors.slug }}
490
+ </p>
491
+ </div>
492
+
493
+ <!-- Tab navigation (only when 2+ tabs) -->
494
+ <div v-if="tabs.length > 1" ref="tabBarRef" class="relative mb-[22px] flex gap-0 overflow-x-auto border-b border-card-border">
495
+ <button
496
+ v-for="(tab, i) in tabs"
497
+ :key="tab.name"
498
+ :data-tab="tab.name"
499
+ type="button"
500
+ :class="[
501
+ 'mr-6 whitespace-nowrap bg-none px-0.5 py-[11px] text-sm transition-colors',
502
+ activeTabIndex === i ? 'font-semibold text-indigo-600' : 'font-medium text-faint hover:text-[#8A8A9E]',
503
+ ]"
504
+ @click="activeTabIndex = i"
505
+ >
506
+ {{ tab.label }}
507
+ </button>
508
+ <div
509
+ class="absolute -bottom-px h-0.5 rounded-full bg-indigo-600 transition-[left,width] duration-300"
510
+ :style="{ left: `${tabIndLeft}px`, width: `${tabIndWidth}px` }"
511
+ />
512
+ </div>
513
+
514
+ <!-- Fields -->
515
+ <div class="space-y-5">
516
+ <component
517
+ :is="componentFor(field)"
518
+ v-for="field in activeTabFields"
519
+ :key="field.name"
520
+ :field="field"
521
+ :model-value="form[field.name]"
522
+ :error="errors[field.name]"
523
+ :disabled="saving"
524
+ v-bind="fieldExtraProps(field)"
525
+ @update:model-value="updateField(field.name, $event)"
526
+ />
527
+ </div>
528
+
529
+ <!-- Form-level error -->
530
+ <p v-if="formError" class="mt-4 text-sm text-red-600" role="alert">
531
+ {{ formError }}
532
+ </p>
533
+
534
+ <!-- Dialogs -->
535
+ <ConfirmDialog
536
+ v-if="showSlugConfirm"
537
+ message="This item is published. Changing its slug will break existing links. Continue?"
538
+ :on-confirm="confirmSlugChange"
539
+ :on-cancel="cancelSlugChange"
540
+ />
541
+ <ConfirmDialog
542
+ v-if="showDeleteConfirm"
543
+ message="Are you sure you want to delete this item? This action cannot be undone."
544
+ :on-confirm="doDelete"
545
+ :on-cancel="() => (showDeleteConfirm = false)"
546
+ />
547
+ </div>
548
+ </template>