@bobbykim/manguito-cms-admin 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +13 -0
- package/package.json +10 -6
- 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
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
7
|
+
<title>Manguito CMS</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobbykim/manguito-cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Bobby Kim <bobby.sihun.kim@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/bobbykim89/manguito-cms",
|
|
@@ -21,33 +21,37 @@
|
|
|
21
21
|
"main": "./dist/index.js",
|
|
22
22
|
"types": "./dist/index.d.ts",
|
|
23
23
|
"files": [
|
|
24
|
-
"dist"
|
|
24
|
+
"dist",
|
|
25
|
+
"src",
|
|
26
|
+
"public",
|
|
27
|
+
"index.html",
|
|
28
|
+
"vite.config.ts"
|
|
25
29
|
],
|
|
26
30
|
"dependencies": {
|
|
27
31
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
|
28
32
|
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
|
33
|
+
"@tailwindcss/vite": "^4.3.2",
|
|
29
34
|
"@tiptap/extension-link": "^2.27.2",
|
|
30
35
|
"@tiptap/starter-kit": "^2.27.2",
|
|
31
36
|
"@tiptap/vue-3": "^2.27.2",
|
|
37
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
32
38
|
"@vueuse/core": "^12.8.2",
|
|
33
39
|
"pinia": "^3.0.4",
|
|
40
|
+
"tailwindcss": "^4.3.2",
|
|
41
|
+
"vite": "^8.1.3",
|
|
34
42
|
"vue": "^3.5.39",
|
|
35
43
|
"vue-router": "^4.6.4",
|
|
36
44
|
"@bobbykim/manguito-cms-core": "0.1.1"
|
|
37
45
|
},
|
|
38
46
|
"devDependencies": {
|
|
39
|
-
"@tailwindcss/vite": "^4.3.2",
|
|
40
47
|
"@testing-library/vue": "^8.1.0",
|
|
41
48
|
"@types/jsdom": "^28.0.3",
|
|
42
|
-
"@vitejs/plugin-vue": "^6.0.7",
|
|
43
49
|
"@vue/test-utils": "^2.4.11",
|
|
44
50
|
"dotenv-cli": "latest",
|
|
45
51
|
"jsdom": "^29.1.1",
|
|
46
52
|
"msw": "^2.15.0",
|
|
47
|
-
"tailwindcss": "^4.3.2",
|
|
48
53
|
"tsup": "^8.5.1",
|
|
49
54
|
"typescript": "^6.0.3",
|
|
50
|
-
"vite": "^8.1.3",
|
|
51
55
|
"vitest": "^4.1.10",
|
|
52
56
|
"vue-tsc": "^3.3.7"
|
|
53
57
|
},
|
|
Binary file
|
package/src/App.vue
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, watch } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import type { ParsedRole } from '@bobbykim/manguito-cms-core'
|
|
5
|
+
import { useAuthStore } from './stores/auth'
|
|
6
|
+
import { useSchemaStore, type ApiSchemaResponse } from './stores/schema'
|
|
7
|
+
import { useUiStore } from './stores/ui'
|
|
8
|
+
|
|
9
|
+
// ─── Config response shape ────────────────────────────────────────────────────
|
|
10
|
+
// Matches the spec in phase-08-api-client.md.
|
|
11
|
+
// The current API config endpoint returns cms_name, version, and roles.
|
|
12
|
+
// user and media are included here for when the API is extended.
|
|
13
|
+
|
|
14
|
+
type ConfigResponse = {
|
|
15
|
+
cms_name: string
|
|
16
|
+
version: string
|
|
17
|
+
roles: Array<{ name: string; label: string; hierarchy_level: number }>
|
|
18
|
+
all_roles?: Array<{
|
|
19
|
+
name: string
|
|
20
|
+
label: string
|
|
21
|
+
hierarchy_level: number
|
|
22
|
+
is_system: boolean
|
|
23
|
+
permissions: string[]
|
|
24
|
+
}>
|
|
25
|
+
user?: {
|
|
26
|
+
id: string
|
|
27
|
+
email: string
|
|
28
|
+
role: string
|
|
29
|
+
must_change_password: boolean
|
|
30
|
+
}
|
|
31
|
+
media?: {
|
|
32
|
+
max_file_size: number
|
|
33
|
+
presigned_uploads?: boolean
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Component state ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const loading = ref(true)
|
|
40
|
+
const router = useRouter()
|
|
41
|
+
const authStore = useAuthStore()
|
|
42
|
+
const schemaStore = useSchemaStore()
|
|
43
|
+
const uiStore = useUiStore()
|
|
44
|
+
|
|
45
|
+
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async function fetchConfigAndSchema(redirectOn401 = true): Promise<void> {
|
|
48
|
+
const response = await fetch(`${__ADMIN_PREFIX__}/api/config`, {
|
|
49
|
+
credentials: 'include',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (response.status === 401) {
|
|
53
|
+
if (redirectOn401) {
|
|
54
|
+
authStore.clear()
|
|
55
|
+
void router.push(`${__ADMIN_PREFIX__}/login`)
|
|
56
|
+
}
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (response.ok) {
|
|
61
|
+
const result = await response.json() as { ok: true; data: ConfigResponse }
|
|
62
|
+
const data = result.data
|
|
63
|
+
|
|
64
|
+
const roleSource = data.all_roles ?? []
|
|
65
|
+
schemaStore.setRoles(
|
|
66
|
+
roleSource.map(r => ({
|
|
67
|
+
name: r.name,
|
|
68
|
+
label: r.label,
|
|
69
|
+
hierarchy_level: r.hierarchy_level,
|
|
70
|
+
is_system: r.is_system,
|
|
71
|
+
permissions: r.permissions as ParsedRole['permissions'],
|
|
72
|
+
}))
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if (data.user) {
|
|
76
|
+
authStore.setUser({
|
|
77
|
+
id: data.user.id,
|
|
78
|
+
email: data.user.email,
|
|
79
|
+
role: data.user.role,
|
|
80
|
+
mustChangePassword: data.user.must_change_password,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
uiStore.setCmsName(data.cms_name)
|
|
85
|
+
|
|
86
|
+
if (data.media?.max_file_size) {
|
|
87
|
+
uiStore.setMaxFileSize(data.media.max_file_size)
|
|
88
|
+
}
|
|
89
|
+
uiStore.setPresignedUploads(data.media?.presigned_uploads ?? false)
|
|
90
|
+
|
|
91
|
+
const schemaRes = await fetch(`${__ADMIN_PREFIX__}/api/schema`, { credentials: 'include' })
|
|
92
|
+
if (schemaRes.ok) {
|
|
93
|
+
const schemaResult = await schemaRes.json() as { ok: true; data: ApiSchemaResponse }
|
|
94
|
+
schemaStore.setFromApiSchema(schemaResult.data)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (authStore.mustChangePassword) {
|
|
98
|
+
void router.push(`${__ADMIN_PREFIX__}/change-password`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onMounted(async () => {
|
|
104
|
+
if (!authStore.isAuthenticated) {
|
|
105
|
+
loading.value = false
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await fetchConfigAndSchema()
|
|
110
|
+
} finally {
|
|
111
|
+
loading.value = false
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
watch(() => uiStore.cmsName, (name) => { document.title = name }, { immediate: true })
|
|
116
|
+
|
|
117
|
+
// Re-run bootstrap when the user logs in (isAuthenticated flips false → true).
|
|
118
|
+
// Show the loading spinner during the fetch so AppShell never renders with an
|
|
119
|
+
// empty schema store (same guarantee as a page refresh gives).
|
|
120
|
+
watch(() => authStore.isAuthenticated, async (isAuth, wasAuth) => {
|
|
121
|
+
if (isAuth && !wasAuth) {
|
|
122
|
+
loading.value = true
|
|
123
|
+
try {
|
|
124
|
+
await fetchConfigAndSchema(false)
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore
|
|
127
|
+
} finally {
|
|
128
|
+
loading.value = false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<template>
|
|
135
|
+
<div
|
|
136
|
+
v-if="loading"
|
|
137
|
+
class="fixed inset-0 flex items-center justify-center bg-white"
|
|
138
|
+
aria-label="Loading"
|
|
139
|
+
>
|
|
140
|
+
<div
|
|
141
|
+
class="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-gray-600"
|
|
142
|
+
role="status"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<router-view v-else />
|
|
147
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AdminAdapter } from '@bobbykim/manguito-cms-core'
|
|
2
|
+
|
|
3
|
+
export type AdminAdapterOptions = {
|
|
4
|
+
prefix?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createAdminAdapter(
|
|
8
|
+
options: AdminAdapterOptions = {}
|
|
9
|
+
): AdminAdapter {
|
|
10
|
+
const prefix = options.prefix ?? '/admin'
|
|
11
|
+
|
|
12
|
+
return { prefix }
|
|
13
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } 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: boolean]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const boolValue = computed(() => props.modelValue === true)
|
|
17
|
+
|
|
18
|
+
function toggle() {
|
|
19
|
+
if (!props.disabled) {
|
|
20
|
+
emit('update:modelValue', !boolValue.value)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div>
|
|
27
|
+
<span class="block text-[13px] font-semibold text-[#3D3D52]">
|
|
28
|
+
{{ field.label }}
|
|
29
|
+
<span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
|
|
30
|
+
</span>
|
|
31
|
+
|
|
32
|
+
<div class="mt-2 flex items-center gap-3">
|
|
33
|
+
<button
|
|
34
|
+
:id="field.name"
|
|
35
|
+
type="button"
|
|
36
|
+
role="switch"
|
|
37
|
+
:aria-checked="boolValue"
|
|
38
|
+
:aria-label="field.label"
|
|
39
|
+
:disabled="disabled"
|
|
40
|
+
:class="[
|
|
41
|
+
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
|
|
42
|
+
boolValue ? 'bg-indigo-600' : 'bg-gray-200',
|
|
43
|
+
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
|
44
|
+
]"
|
|
45
|
+
@click="toggle"
|
|
46
|
+
>
|
|
47
|
+
<span
|
|
48
|
+
:class="[
|
|
49
|
+
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
|
50
|
+
boolValue ? 'translate-x-6' : 'translate-x-1',
|
|
51
|
+
]"
|
|
52
|
+
/>
|
|
53
|
+
</button>
|
|
54
|
+
<span class="text-sm text-gray-600 select-none">
|
|
55
|
+
{{ boolValue ? 'Yes' : 'No' }}
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
|
|
60
|
+
{{ error }}
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } 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
|
+
// datetime-local requires "YYYY-MM-DDTHH:MM". ISO strings are "YYYY-MM-DDTHH:MM:SS.mssZ".
|
|
17
|
+
// Slice the first 16 characters to get the compatible format, treating stored value as UTC.
|
|
18
|
+
const displayValue = computed(() => {
|
|
19
|
+
if (typeof props.modelValue !== 'string' || !props.modelValue) return ''
|
|
20
|
+
return props.modelValue.slice(0, 16)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function onInput(event: Event) {
|
|
24
|
+
const value = (event.target as HTMLInputElement).value
|
|
25
|
+
// Append seconds and Z to form a valid ISO 8601 UTC datetime string.
|
|
26
|
+
emit('update:modelValue', value ? `${value}:00.000Z` : '')
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div>
|
|
32
|
+
<label
|
|
33
|
+
:for="field.name"
|
|
34
|
+
class="block text-[13px] font-semibold text-[#3D3D52]"
|
|
35
|
+
>
|
|
36
|
+
{{ field.label }}
|
|
37
|
+
<span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
|
|
38
|
+
</label>
|
|
39
|
+
|
|
40
|
+
<div class="mt-1">
|
|
41
|
+
<input
|
|
42
|
+
:id="field.name"
|
|
43
|
+
type="datetime-local"
|
|
44
|
+
:value="displayValue"
|
|
45
|
+
:disabled="disabled"
|
|
46
|
+
:class="[
|
|
47
|
+
'block w-full rounded-[11px] border px-3.5 py-3 text-sm focus:outline-none focus:ring-[3px]',
|
|
48
|
+
error
|
|
49
|
+
? 'border-red-300 focus:border-red-400 focus:ring-red-200'
|
|
50
|
+
: 'border-[#E4E3EE] bg-[#FBFBFD] focus:border-indigo-400 focus:bg-white focus:ring-indigo-50',
|
|
51
|
+
disabled && 'cursor-not-allowed bg-gray-50 opacity-60',
|
|
52
|
+
]"
|
|
53
|
+
@input="onInput"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
|
|
58
|
+
{{ error }}
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } 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 options = computed(() => props.field.validation.allowed_values ?? [])
|
|
17
|
+
const hasOptions = computed(() => options.value.length > 0)
|
|
18
|
+
|
|
19
|
+
const selectValue = computed(() =>
|
|
20
|
+
typeof props.modelValue === 'string' ? props.modelValue : ''
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
function onChange(event: Event) {
|
|
24
|
+
emit('update:modelValue', (event.target as HTMLSelectElement).value)
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div>
|
|
30
|
+
<label
|
|
31
|
+
:for="field.name"
|
|
32
|
+
class="block text-[13px] font-semibold text-[#3D3D52]"
|
|
33
|
+
>
|
|
34
|
+
{{ field.label }}
|
|
35
|
+
<span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
|
|
36
|
+
</label>
|
|
37
|
+
|
|
38
|
+
<div class="mt-1">
|
|
39
|
+
<select
|
|
40
|
+
:id="field.name"
|
|
41
|
+
:value="selectValue"
|
|
42
|
+
:disabled="disabled || !hasOptions"
|
|
43
|
+
:class="[
|
|
44
|
+
'block w-full rounded-[11px] border px-3.5 py-3 text-sm focus:outline-none focus:ring-[3px]',
|
|
45
|
+
error
|
|
46
|
+
? 'border-red-300 focus:border-red-400 focus:ring-red-200'
|
|
47
|
+
: 'border-[#E4E3EE] bg-[#FBFBFD] focus:border-indigo-400 focus:bg-white focus:ring-indigo-50',
|
|
48
|
+
(disabled || !hasOptions) && 'cursor-not-allowed bg-gray-50 opacity-60',
|
|
49
|
+
]"
|
|
50
|
+
@change="onChange"
|
|
51
|
+
>
|
|
52
|
+
<option value="" disabled>
|
|
53
|
+
{{ hasOptions ? `Select ${field.label}` : 'No options available' }}
|
|
54
|
+
</option>
|
|
55
|
+
<option
|
|
56
|
+
v-for="opt in options"
|
|
57
|
+
:key="opt"
|
|
58
|
+
:value="opt"
|
|
59
|
+
>
|
|
60
|
+
{{ opt }}
|
|
61
|
+
</option>
|
|
62
|
+
</select>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
|
|
66
|
+
{{ error }}
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|