@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.
- package/dist/assets/{ChangePasswordView-8UW4RWd8.js → ChangePasswordView-B0wg3O_I.js} +1 -1
- package/dist/assets/{EnumSelect-BOrBkA76.js → ComputedDisplay-C2FpwSB1.js} +2 -2
- package/dist/assets/ContentFormView-D92O0ICh.js +1 -0
- package/dist/assets/{ContentListView-BiPHUBHa.js → ContentListView-DM4xggDx.js} +1 -1
- package/dist/assets/{ContentSettingsView-DPKF5MRL.js → ContentSettingsView-B9UHNleT.js} +1 -1
- package/dist/assets/{LoginView-WELPH4Vh.js → LoginView-DPpdT3Ro.js} +1 -1
- package/dist/assets/{MediaDetailView-BT66fV9b.js → MediaDetailView-OUzzhO1B.js} +1 -1
- package/dist/assets/{MediaLibraryView-BcdiJ_7a.js → MediaLibraryView-Dx9t8r8T.js} +1 -1
- package/dist/assets/TaxonomyFormView-CZlOjxOY.js +1 -0
- package/dist/assets/{TaxonomyListView-BB7iTfjb.js → TaxonomyListView-Dkga11a3.js} +1 -1
- package/dist/assets/{UserFormView-C2p-FV0A.js → UserFormView-CaOcoKX5.js} +1 -1
- package/dist/assets/{UserListView-BnlU2Ovr.js → UserListView-B6_Hdemt.js} +1 -1
- package/dist/assets/{index-BkazhAun.js → index-s-2X8XCa.js} +2 -2
- package/dist/assets/{useNotification-DU6J5vms.js → useNotification-CEPVkHak.js} +1 -1
- package/dist/codegen/index.cjs +7 -2
- package/dist/codegen/index.js +7 -2
- package/dist/index.html +1 -1
- package/index.html +13 -0
- package/package.json +11 -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/ComputedDisplay.vue +23 -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__/ComputedDisplay.test.ts +34 -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 +553 -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 +286 -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
- package/dist/assets/ContentFormView-vaIInaYs.js +0 -1
- 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">×</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">×</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
|
+
})
|