@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,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>
|