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