@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,275 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { useDebounceFn } from '@vueuse/core'
4
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
5
+ import { useApiClient } from '../../composables/useApiClient'
6
+ import { useSchemaStore } from '../../stores/schema'
7
+
8
+ const props = defineProps<{
9
+ field: ParsedField
10
+ modelValue: unknown
11
+ error?: string
12
+ disabled?: boolean
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ 'update:modelValue': [value: string | null | string[]]
17
+ }>()
18
+
19
+ const api = useApiClient()
20
+ const schemaStore = useSchemaStore()
21
+
22
+ const typeaheadComp = computed(() => {
23
+ const c = props.field.ui_component
24
+ return c.component === 'typeahead-select' ? c : null
25
+ })
26
+
27
+ const refName = computed(() => typeaheadComp.value?.ref ?? '')
28
+ const relType = computed(() => typeaheadComp.value?.rel ?? 'one-to-one')
29
+ const maxItems = computed(() => props.field.validation.max_items ?? null)
30
+ const isMulti = computed(() => relType.value !== 'one-to-one')
31
+ const isContentRef = computed(() => !!schemaStore.contentTypes[refName.value])
32
+
33
+ // First text/plain field in the referenced schema — used as the display label.
34
+ const titleField = computed<string>(() => {
35
+ const schema = isContentRef.value
36
+ ? schemaStore.contentTypes[refName.value]
37
+ : schemaStore.taxonomyTypes[refName.value]
38
+ return schema?.fields.find(f => f.field_type === 'text/plain')?.name ?? 'id'
39
+ })
40
+
41
+ const selectedIds = computed<string[]>(() => {
42
+ if (isMulti.value) {
43
+ return Array.isArray(props.modelValue)
44
+ ? (props.modelValue as unknown[]).filter((v): v is string => typeof v === 'string')
45
+ : []
46
+ }
47
+ return typeof props.modelValue === 'string' ? [props.modelValue] : []
48
+ })
49
+
50
+ const atLimit = computed(
51
+ () => maxItems.value !== null && selectedIds.value.length >= maxItems.value
52
+ )
53
+
54
+ // Safe accessor for the one-to-one case — avoids undefined index type errors.
55
+ const singleId = computed(() => selectedIds.value[0] ?? '')
56
+
57
+ // id → display label for chips; populated on select and on mount.
58
+ const selectedLabels = ref<Record<string, string>>({})
59
+
60
+ const query = ref('')
61
+ const loading = ref(false)
62
+ const results = ref<Record<string, unknown>[]>([])
63
+ const showDropdown = ref(false)
64
+
65
+ function basePath(): string {
66
+ return isContentRef.value
67
+ ? `/content/${refName.value}`
68
+ : `/taxonomy/${refName.value}`
69
+ }
70
+
71
+ function buildSearchPath(q: string): string {
72
+ const params = new URLSearchParams({
73
+ [`filters[${titleField.value}][like]`]: q,
74
+ per_page: '10',
75
+ })
76
+ return `${basePath()}?${params.toString()}`
77
+ }
78
+
79
+ function labelFor(item: Record<string, unknown>): string {
80
+ return String(item[titleField.value] ?? item.id ?? '')
81
+ }
82
+
83
+ const doSearch = useDebounceFn(async (q: string) => {
84
+ if (q.length < 2) {
85
+ results.value = []
86
+ showDropdown.value = false
87
+ return
88
+ }
89
+ loading.value = true
90
+ showDropdown.value = true
91
+ const res = await api.get<Record<string, unknown>[]>(buildSearchPath(q))
92
+ loading.value = false
93
+ if (res.ok) {
94
+ results.value = res.data
95
+ }
96
+ }, 300)
97
+
98
+ function onInput(e: Event) {
99
+ query.value = (e.target as HTMLInputElement).value
100
+ doSearch(query.value)
101
+ }
102
+
103
+ function onFocusOut() {
104
+ // Delay so mousedown on a result item can fire before the dropdown closes.
105
+ setTimeout(() => {
106
+ showDropdown.value = false
107
+ }, 150)
108
+ }
109
+
110
+ function onFocus() {
111
+ if (query.value.length >= 2) showDropdown.value = true
112
+ }
113
+
114
+ function selectItem(item: Record<string, unknown>) {
115
+ const id = String(item.id)
116
+ selectedLabels.value[id] = labelFor(item)
117
+ query.value = ''
118
+ results.value = []
119
+ showDropdown.value = false
120
+
121
+ if (isMulti.value) {
122
+ const next = [...selectedIds.value]
123
+ if (!next.includes(id)) next.push(id)
124
+ emit('update:modelValue', next)
125
+ } else {
126
+ emit('update:modelValue', id)
127
+ }
128
+ }
129
+
130
+ function removeItem(id: string) {
131
+ if (isMulti.value) {
132
+ emit('update:modelValue', selectedIds.value.filter(i => i !== id))
133
+ } else {
134
+ emit('update:modelValue', null)
135
+ }
136
+ }
137
+
138
+ // Fetch display labels for any IDs already present when the component mounts.
139
+ onMounted(async () => {
140
+ for (const id of selectedIds.value) {
141
+ if (selectedLabels.value[id]) continue
142
+ const res = await api.get<Record<string, unknown>>(`${basePath()}/${id}`)
143
+ if (res.ok) {
144
+ selectedLabels.value[id] = labelFor(res.data as Record<string, unknown>)
145
+ } else {
146
+ selectedLabels.value[id] = id
147
+ }
148
+ }
149
+ })
150
+ </script>
151
+
152
+ <template>
153
+ <div>
154
+ <label class="block text-[13px] font-semibold text-[#3D3D52]">
155
+ {{ field.label }}
156
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
157
+ </label>
158
+
159
+ <!-- Removable chips — one-to-many / many-to-many -->
160
+ <div
161
+ v-if="isMulti && selectedIds.length"
162
+ class="mt-1.5 flex flex-wrap gap-1.5"
163
+ >
164
+ <span
165
+ v-for="id in selectedIds"
166
+ :key="id"
167
+ class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800"
168
+ >
169
+ {{ selectedLabels[id] ?? id }}
170
+ <button
171
+ type="button"
172
+ :disabled="disabled"
173
+ class="ml-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full hover:bg-indigo-200 disabled:cursor-not-allowed"
174
+ :aria-label="`Remove ${selectedLabels[id] ?? id}`"
175
+ @click="removeItem(id)"
176
+ >
177
+ <span aria-hidden="true">&times;</span>
178
+ </button>
179
+ </span>
180
+ </div>
181
+
182
+ <!-- Single chip — one-to-one -->
183
+ <div
184
+ v-else-if="!isMulti && selectedIds.length"
185
+ class="mt-1.5"
186
+ >
187
+ <span
188
+ class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800"
189
+ >
190
+ {{ selectedLabels[singleId] ?? singleId }}
191
+ <button
192
+ type="button"
193
+ :disabled="disabled"
194
+ class="ml-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full hover:bg-indigo-200 disabled:cursor-not-allowed"
195
+ aria-label="Clear selection"
196
+ @click="removeItem(singleId)"
197
+ >
198
+ <span aria-hidden="true">&times;</span>
199
+ </button>
200
+ </span>
201
+ </div>
202
+
203
+ <!-- X / Y counter when a max is configured -->
204
+ <p
205
+ v-if="maxItems !== null"
206
+ class="mt-0.5 text-xs text-gray-500"
207
+ >
208
+ {{ selectedIds.length }} / {{ maxItems }} selected
209
+ </p>
210
+
211
+ <!-- Search input -->
212
+ <div class="relative mt-1">
213
+ <input
214
+ :id="field.name"
215
+ type="text"
216
+ :value="query"
217
+ :disabled="disabled || atLimit"
218
+ :placeholder="atLimit ? 'Limit reached' : `Search ${field.label}…`"
219
+ :class="[
220
+ 'block w-full rounded-[11px] border px-3.5 py-3 text-sm focus:outline-none focus:ring-[3px]',
221
+ error
222
+ ? 'border-red-300 focus:border-red-400 focus:ring-red-200'
223
+ : 'border-[#E4E3EE] bg-[#FBFBFD] focus:border-indigo-400 focus:bg-white focus:ring-indigo-50',
224
+ (disabled || atLimit) && 'cursor-not-allowed bg-gray-50 opacity-60',
225
+ ]"
226
+ autocomplete="off"
227
+ @input="onInput"
228
+ @focus="onFocus"
229
+ @focusout="onFocusOut"
230
+ />
231
+
232
+ <!-- Dropdown -->
233
+ <ul
234
+ v-if="showDropdown"
235
+ role="listbox"
236
+ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 focus:outline-none"
237
+ >
238
+ <!-- Loading spinner -->
239
+ <li v-if="loading" class="flex items-center gap-2 px-3 py-2 text-gray-400">
240
+ <span
241
+ class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"
242
+ aria-hidden="true"
243
+ />
244
+ Searching…
245
+ </li>
246
+
247
+ <template v-else>
248
+ <!-- No results -->
249
+ <li
250
+ v-if="results.length === 0"
251
+ class="px-3 py-2 text-gray-500"
252
+ >
253
+ No results found for "{{ query }}"
254
+ </li>
255
+
256
+ <!-- Result items -->
257
+ <li
258
+ v-for="item in results"
259
+ :key="String(item.id)"
260
+ role="option"
261
+ class="cursor-pointer px-3 py-2 hover:bg-indigo-50"
262
+ @mousedown.prevent
263
+ @click="selectItem(item)"
264
+ >
265
+ {{ labelFor(item) }}
266
+ </li>
267
+ </template>
268
+ </ul>
269
+ </div>
270
+
271
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
272
+ {{ error }}
273
+ </p>
274
+ </div>
275
+ </template>
@@ -0,0 +1,293 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import { onClickOutside } from '@vueuse/core'
4
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import Link from '@tiptap/extension-link'
7
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
8
+
9
+ const props = defineProps<{
10
+ field: ParsedField
11
+ modelValue: unknown
12
+ error?: string
13
+ disabled?: boolean
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ 'update:modelValue': [value: string]
18
+ }>()
19
+
20
+ const editor = useEditor({
21
+ content: typeof props.modelValue === 'string' ? props.modelValue : '',
22
+ editable: !props.disabled,
23
+ extensions: [
24
+ StarterKit,
25
+ Link.configure({ openOnClick: false }),
26
+ ],
27
+ onUpdate: ({ editor: e }) => {
28
+ emit('update:modelValue', e.getHTML())
29
+ },
30
+ })
31
+
32
+ // Sync editor content when modelValue changes from outside (form reset / initial load).
33
+ watch(
34
+ () => props.modelValue,
35
+ (v) => {
36
+ const html = typeof v === 'string' ? v : ''
37
+ if (editor.value && editor.value.getHTML() !== html) {
38
+ editor.value.commands.setContent(html, false)
39
+ }
40
+ }
41
+ )
42
+
43
+ // Sync editable state when disabled changes.
44
+ watch(
45
+ () => props.disabled,
46
+ (val) => {
47
+ editor.value?.setEditable(!val)
48
+ }
49
+ )
50
+
51
+ type Level = 1 | 2 | 3
52
+
53
+ function cmd(fn: () => void) {
54
+ if (!props.disabled) fn()
55
+ }
56
+
57
+ function isActive(name: string, attrs?: Record<string, unknown>) {
58
+ return editor.value?.isActive(name, attrs) ?? false
59
+ }
60
+
61
+ // ── Link popover ──────────────────────────────────────────────────────────────
62
+
63
+ const showLinkPanel = ref(false)
64
+ const linkUrl = ref('')
65
+ const linkTarget = ref<'_self' | '_blank'>('_self')
66
+ const linkClass = ref('')
67
+ const linkPanelRef = ref<HTMLElement | null>(null)
68
+
69
+ onClickOutside(linkPanelRef, () => closeLinkPanel())
70
+
71
+ function toggleLinkPanel() {
72
+ if (showLinkPanel.value) {
73
+ closeLinkPanel()
74
+ return
75
+ }
76
+ const attrs = editor.value?.getAttributes('link') ?? {}
77
+ linkUrl.value = typeof attrs['href'] === 'string' ? attrs['href'] : ''
78
+ linkTarget.value = attrs['target'] === '_blank' ? '_blank' : '_self'
79
+ linkClass.value = typeof attrs['class'] === 'string' ? attrs['class'] : ''
80
+ showLinkPanel.value = true
81
+ }
82
+
83
+ function closeLinkPanel() {
84
+ showLinkPanel.value = false
85
+ }
86
+
87
+ function applyLink() {
88
+ const url = linkUrl.value.trim()
89
+ if (!url) {
90
+ editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
91
+ } else {
92
+ editor.value
93
+ ?.chain()
94
+ .focus()
95
+ .extendMarkRange('link')
96
+ .setLink({
97
+ href: url,
98
+ target: linkTarget.value,
99
+ rel: linkTarget.value === '_blank' ? 'noopener noreferrer' : null,
100
+ class: linkClass.value.trim() || null,
101
+ })
102
+ .run()
103
+ }
104
+ closeLinkPanel()
105
+ }
106
+
107
+ function removeLink() {
108
+ editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
109
+ closeLinkPanel()
110
+ }
111
+ </script>
112
+
113
+ <template>
114
+ <div>
115
+ <span class="block text-[13px] font-semibold text-[#3D3D52]">
116
+ {{ field.label }}
117
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
118
+ </span>
119
+
120
+ <div
121
+ :class="[
122
+ 'mt-1 rounded-md border shadow-sm',
123
+ error ? 'border-red-300' : 'border-gray-300',
124
+ disabled && 'opacity-60',
125
+ ]"
126
+ >
127
+ <!-- Toolbar — rounded-t (not overflow-hidden on the wrapper) so the link
128
+ popover below isn't clipped by the editor's own rounded-corner box. -->
129
+ <div
130
+ class="flex flex-wrap gap-0.5 rounded-t-md border-b border-gray-200 bg-gray-50 px-2 py-1.5"
131
+ :aria-label="`${field.label} formatting toolbar`"
132
+ >
133
+ <!-- Bold -->
134
+ <button
135
+ type="button"
136
+ title="Bold"
137
+ :class="['rounded px-2 py-1 text-xs font-bold', isActive('bold') ? 'bg-gray-200' : 'hover:bg-gray-200']"
138
+ :disabled="disabled"
139
+ @click="cmd(() => editor?.chain().focus().toggleBold().run())"
140
+ >
141
+ B
142
+ </button>
143
+
144
+ <!-- Italic -->
145
+ <button
146
+ type="button"
147
+ title="Italic"
148
+ :class="['rounded px-2 py-1 text-xs italic', isActive('italic') ? 'bg-gray-200' : 'hover:bg-gray-200']"
149
+ :disabled="disabled"
150
+ @click="cmd(() => editor?.chain().focus().toggleItalic().run())"
151
+ >
152
+ I
153
+ </button>
154
+
155
+ <span class="mx-1 text-gray-300" aria-hidden="true">|</span>
156
+
157
+ <!-- H1 -->
158
+ <button
159
+ v-for="level in ([1, 2, 3] as Level[])"
160
+ :key="level"
161
+ type="button"
162
+ :title="`Heading ${level}`"
163
+ :class="[
164
+ 'rounded px-2 py-1 text-xs font-semibold',
165
+ isActive('heading', { level }) ? 'bg-gray-200' : 'hover:bg-gray-200',
166
+ ]"
167
+ :disabled="disabled"
168
+ @click="cmd(() => editor?.chain().focus().toggleHeading({ level }).run())"
169
+ >
170
+ H{{ level }}
171
+ </button>
172
+
173
+ <span class="mx-1 text-gray-300" aria-hidden="true">|</span>
174
+
175
+ <!-- Bullet list -->
176
+ <button
177
+ type="button"
178
+ title="Bullet list"
179
+ :class="['rounded px-2 py-1 text-xs', isActive('bulletList') ? 'bg-gray-200' : 'hover:bg-gray-200']"
180
+ :disabled="disabled"
181
+ @click="cmd(() => editor?.chain().focus().toggleBulletList().run())"
182
+ >
183
+ • List
184
+ </button>
185
+
186
+ <!-- Ordered list -->
187
+ <button
188
+ type="button"
189
+ title="Ordered list"
190
+ :class="['rounded px-2 py-1 text-xs', isActive('orderedList') ? 'bg-gray-200' : 'hover:bg-gray-200']"
191
+ :disabled="disabled"
192
+ @click="cmd(() => editor?.chain().focus().toggleOrderedList().run())"
193
+ >
194
+ 1. List
195
+ </button>
196
+
197
+ <span class="mx-1 text-gray-300" aria-hidden="true">|</span>
198
+
199
+ <!-- Link -->
200
+ <div ref="linkPanelRef" class="relative">
201
+ <button
202
+ type="button"
203
+ title="Link"
204
+ :class="['rounded px-2 py-1 text-xs', isActive('link') ? 'bg-gray-200' : 'hover:bg-gray-200']"
205
+ :disabled="disabled"
206
+ @click="cmd(toggleLinkPanel)"
207
+ >
208
+ Link
209
+ </button>
210
+
211
+ <div
212
+ v-if="showLinkPanel"
213
+ class="absolute left-0 top-full z-10 mt-1 w-64 rounded-md border border-gray-200 bg-white p-3 text-left shadow-lg"
214
+ >
215
+ <label class="block text-xs font-medium text-gray-700">URL</label>
216
+ <input
217
+ v-model="linkUrl"
218
+ type="text"
219
+ placeholder="https://example.com"
220
+ class="mt-1 block w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200"
221
+ />
222
+
223
+ <label class="mt-2 block text-xs font-medium text-gray-700">Target</label>
224
+ <select
225
+ v-model="linkTarget"
226
+ class="mt-1 block w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200"
227
+ >
228
+ <option value="_self">Same tab (_self)</option>
229
+ <option value="_blank">New tab (_blank)</option>
230
+ </select>
231
+
232
+ <label class="mt-2 block text-xs font-medium text-gray-700">
233
+ CSS class <span class="font-normal text-gray-400">(optional)</span>
234
+ </label>
235
+ <input
236
+ v-model="linkClass"
237
+ type="text"
238
+ placeholder="btn btn-primary"
239
+ class="mt-1 block w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200"
240
+ />
241
+
242
+ <div class="mt-3 flex items-center justify-between gap-2">
243
+ <button
244
+ v-if="isActive('link')"
245
+ type="button"
246
+ class="text-xs font-medium text-red-600 hover:text-red-800"
247
+ @click="removeLink"
248
+ >
249
+ Remove link
250
+ </button>
251
+ <div class="ml-auto flex gap-2">
252
+ <button
253
+ type="button"
254
+ class="rounded px-2 py-1 text-xs text-gray-600 hover:bg-gray-100"
255
+ @click="closeLinkPanel"
256
+ >
257
+ Cancel
258
+ </button>
259
+ <button
260
+ type="button"
261
+ class="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-700"
262
+ @click="applyLink"
263
+ >
264
+ Apply
265
+ </button>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ </div>
271
+
272
+ <!-- Editor content — v-if narrows Editor | undefined → Editor for the prop -->
273
+ <EditorContent
274
+ v-if="editor"
275
+ :editor="editor"
276
+ :class="[
277
+ 'rounded-b-md px-3 py-2 text-sm [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:cursor-text [&_.ProseMirror]:outline-none',
278
+ '[&_.ProseMirror_h1]:text-xl [&_.ProseMirror_h1]:font-bold',
279
+ '[&_.ProseMirror_h2]:text-lg [&_.ProseMirror_h2]:font-bold',
280
+ '[&_.ProseMirror_h3]:text-base [&_.ProseMirror_h3]:font-semibold',
281
+ '[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-5',
282
+ '[&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-5',
283
+ '[&_.ProseMirror_a]:text-indigo-600 [&_.ProseMirror_a]:underline',
284
+ disabled && 'cursor-not-allowed [&_.ProseMirror]:cursor-not-allowed',
285
+ ]"
286
+ />
287
+ </div>
288
+
289
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
290
+ {{ error }}
291
+ </p>
292
+ </div>
293
+ </template>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch } from 'vue'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+
5
+ const props = defineProps<{
6
+ field: ParsedField
7
+ modelValue: unknown
8
+ error?: string
9
+ disabled?: boolean
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: string]
14
+ }>()
15
+
16
+ const isSlugField = computed(
17
+ () => props.field.name === 'slug' || props.field.name.endsWith('_slug')
18
+ )
19
+
20
+ // Local raw text — allows free typing while emitting formatted slug value.
21
+ const localText = ref(typeof props.modelValue === 'string' ? props.modelValue : '')
22
+
23
+ // Sync when modelValue changes from outside (form reset / initial load).
24
+ watch(
25
+ () => props.modelValue,
26
+ (v) => {
27
+ if (typeof v === 'string' && v !== localText.value) {
28
+ localText.value = v
29
+ }
30
+ }
31
+ )
32
+
33
+ function formatSlug(value: string): string {
34
+ return value
35
+ .toLowerCase()
36
+ .replace(/\s+/g, '-')
37
+ .replace(/[^a-z0-9-]/g, '')
38
+ .replace(/-+/g, '-')
39
+ .replace(/^-|-$/g, '')
40
+ }
41
+
42
+ const slugPreview = computed(() =>
43
+ isSlugField.value ? formatSlug(localText.value) : ''
44
+ )
45
+
46
+ function onInput(event: Event) {
47
+ const value = (event.target as HTMLInputElement).value
48
+ localText.value = value
49
+ emit('update:modelValue', isSlugField.value ? formatSlug(value) : value)
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <div>
55
+ <label
56
+ :for="field.name"
57
+ class="block text-[13px] font-semibold text-[#3D3D52]"
58
+ >
59
+ {{ field.label }}
60
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
61
+ </label>
62
+
63
+ <div class="mt-1">
64
+ <input
65
+ :id="field.name"
66
+ type="text"
67
+ :value="localText"
68
+ :disabled="disabled"
69
+ :placeholder="field.label"
70
+ :class="[
71
+ 'block w-full rounded-[11px] border px-3.5 py-3 text-sm focus:outline-none focus:ring-[3px]',
72
+ error
73
+ ? 'border-red-300 focus:border-red-400 focus:ring-red-200'
74
+ : 'border-[#E4E3EE] bg-[#FBFBFD] focus:border-indigo-400 focus:bg-white focus:ring-indigo-50',
75
+ disabled && 'cursor-not-allowed bg-gray-50 opacity-60',
76
+ ]"
77
+ @input="onInput"
78
+ />
79
+ </div>
80
+
81
+ <!-- Slug format preview — shown below for slug fields -->
82
+ <p
83
+ v-if="isSlugField && localText"
84
+ class="mt-1 text-xs text-gray-500"
85
+ >
86
+ Slug: <span class="font-mono">{{ slugPreview }}</span>
87
+ </p>
88
+
89
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
90
+ {{ error }}
91
+ </p>
92
+ </div>
93
+ </template>
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+ import BooleanToggle from '../BooleanToggle.vue'
5
+
6
+ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
7
+ return {
8
+ name: 'is_active',
9
+ label: 'Is Active',
10
+ field_type: 'boolean',
11
+ required: false,
12
+ nullable: false,
13
+ order: 0,
14
+ validation: { required: false },
15
+ db_column: { column_name: 'is_active', column_type: 'boolean', nullable: false },
16
+ ui_component: { component: 'checkbox' },
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('BooleanToggle', () => {
22
+ it('emits true when toggled from off to on', async () => {
23
+ const wrapper = mount(BooleanToggle, {
24
+ props: { field: makeField(), modelValue: false },
25
+ })
26
+ await wrapper.find('button[role="switch"]').trigger('click')
27
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
28
+ })
29
+
30
+ it('emits false when toggled from on to off', async () => {
31
+ const wrapper = mount(BooleanToggle, {
32
+ props: { field: makeField(), modelValue: true },
33
+ })
34
+ await wrapper.find('button[role="switch"]').trigger('click')
35
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
36
+ })
37
+ })