@hypersonic-js/admin 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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/dist/constants.d.ts +6 -0
  3. package/dist/constants.d.ts.map +1 -0
  4. package/dist/constants.js +6 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/crud/pagination.d.ts +11 -0
  7. package/dist/crud/pagination.d.ts.map +1 -0
  8. package/dist/crud/pagination.js +31 -0
  9. package/dist/crud/pagination.js.map +1 -0
  10. package/dist/crud/query.d.ts +64 -0
  11. package/dist/crud/query.d.ts.map +1 -0
  12. package/dist/crud/query.js +137 -0
  13. package/dist/crud/query.js.map +1 -0
  14. package/dist/crud/router.d.ts +49 -0
  15. package/dist/crud/router.d.ts.map +1 -0
  16. package/dist/crud/router.js +335 -0
  17. package/dist/crud/router.js.map +1 -0
  18. package/dist/dmmf/fields.d.ts +37 -0
  19. package/dist/dmmf/fields.d.ts.map +1 -0
  20. package/dist/dmmf/fields.js +88 -0
  21. package/dist/dmmf/fields.js.map +1 -0
  22. package/dist/dmmf/parser.d.ts +15 -0
  23. package/dist/dmmf/parser.d.ts.map +1 -0
  24. package/dist/dmmf/parser.js +54 -0
  25. package/dist/dmmf/parser.js.map +1 -0
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/middleware/auth.d.ts +8 -0
  31. package/dist/middleware/auth.d.ts.map +1 -0
  32. package/dist/middleware/auth.js +21 -0
  33. package/dist/middleware/auth.js.map +1 -0
  34. package/dist/mount.d.ts +4 -0
  35. package/dist/mount.d.ts.map +1 -0
  36. package/dist/mount.js +21 -0
  37. package/dist/mount.js.map +1 -0
  38. package/dist/scaffold/index.d.ts +14 -0
  39. package/dist/scaffold/index.d.ts.map +1 -0
  40. package/dist/scaffold/index.js +35 -0
  41. package/dist/scaffold/index.js.map +1 -0
  42. package/dist/scaffold/templates.d.ts +19 -0
  43. package/dist/scaffold/templates.d.ts.map +1 -0
  44. package/dist/scaffold/templates.js +371 -0
  45. package/dist/scaffold/templates.js.map +1 -0
  46. package/dist/templates/Dashboard.tsx +40 -0
  47. package/dist/templates/ModelForm.tsx +288 -0
  48. package/dist/templates/ModelIndex.tsx +142 -0
  49. package/dist/templates/UserCreate.tsx +189 -0
  50. package/dist/types.d.ts +159 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +3 -0
  53. package/dist/types.js.map +1 -0
  54. package/package.json +60 -0
  55. package/templates/Dashboard.tsx +40 -0
  56. package/templates/ModelForm.tsx +288 -0
  57. package/templates/ModelIndex.tsx +142 -0
  58. package/templates/UserCreate.tsx +189 -0
@@ -0,0 +1,288 @@
1
+ import { useState, useRef } from 'react'
2
+ import { useForm, Link } from '@inertiajs/react'
3
+
4
+ type FieldKind = 'scalar' | 'relation' | 'enum'
5
+
6
+ interface FieldMeta {
7
+ name: string
8
+ prismaType: string
9
+ kind: FieldKind
10
+ isRequired: boolean
11
+ isForeignKey: boolean
12
+ relatedModelName?: string
13
+ relatedModelSlug?: string
14
+ enumValues?: string[]
15
+ }
16
+
17
+ interface ModelMeta {
18
+ name: string
19
+ urlSlug: string
20
+ displayName: string
21
+ idField: string
22
+ formFields: FieldMeta[]
23
+ }
24
+
25
+ interface FkOption {
26
+ id: string
27
+ label: string
28
+ }
29
+
30
+ type RelatedOptionsMap = Record<string, { options: FkOption[]; hasMore: boolean }>
31
+
32
+ interface FieldOptionsState {
33
+ options: FkOption[]
34
+ hasMore: boolean
35
+ page: number
36
+ loading: boolean
37
+ }
38
+
39
+ interface Props {
40
+ model: ModelMeta
41
+ record: Record<string, unknown> | null
42
+ models: Array<{ name: string; urlSlug: string }>
43
+ errors: Record<string, string>
44
+ prefix: string
45
+ relatedOptions: RelatedOptionsMap
46
+ }
47
+
48
+ function toLocalDateTimeString(date: Date): string {
49
+ const pad = (n: number): string => String(n).padStart(2, '0')
50
+ return (
51
+ `${date.getFullYear()}-` +
52
+ `${pad(date.getMonth() + 1)}-` +
53
+ `${pad(date.getDate())}T` +
54
+ `${pad(date.getHours())}:` +
55
+ `${pad(date.getMinutes())}:` +
56
+ `${pad(date.getSeconds())}`
57
+ )
58
+ }
59
+
60
+ function buildInitialData(
61
+ formFields: FieldMeta[],
62
+ record: Record<string, unknown> | null,
63
+ relatedOptions: RelatedOptionsMap = {},
64
+ ): Record<string, string> {
65
+ return Object.fromEntries(
66
+ formFields.map((f) => {
67
+ const value = record?.[f.name]
68
+ if (value instanceof Date) return [f.name, toLocalDateTimeString(value)]
69
+ if (value !== null && value !== undefined) return [f.name, String(value)]
70
+
71
+ // New record — use type-aware defaults only for REQUIRED fields so that
72
+ // optional columns are left unset rather than silently written with a
73
+ // synthetic value the user never chose.
74
+ if (f.prismaType === 'Boolean' && f.isRequired) return [f.name, 'false']
75
+ if (f.kind === 'enum' && f.isRequired && f.enumValues !== undefined && f.enumValues.length > 0) {
76
+ return [f.name, f.enumValues[0]!]
77
+ }
78
+
79
+ // For required FK fields on create forms, default to the first available
80
+ // option so the controlled <select> value matches what the browser renders
81
+ // as visually selected. Without this the select appears to have a valid
82
+ // selection but the underlying form value is '', which coerceData converts
83
+ // to undefined for required fields, causing a Prisma validation error.
84
+ if (f.isForeignKey && f.isRequired) {
85
+ const firstOption = relatedOptions[f.name]?.options[0]
86
+ if (firstOption !== undefined) {
87
+ return [f.name, String(firstOption.id)]
88
+ }
89
+ }
90
+
91
+ return [f.name, '']
92
+ }),
93
+ )
94
+ }
95
+
96
+ export default function AdminModelForm({ model, record, errors, prefix, relatedOptions }: Props) {
97
+ const isEdit = record !== null
98
+ const { data, setData, post, patch, processing } = useForm(
99
+ buildInitialData(model.formFields, record, relatedOptions),
100
+ )
101
+
102
+ const [fkOptions, setFkOptions] = useState<Record<string, FieldOptionsState>>(() =>
103
+ Object.fromEntries(
104
+ Object.entries(relatedOptions).map(([key, val]) => [
105
+ key,
106
+ { options: val.options, hasMore: val.hasMore, page: 1, loading: false },
107
+ ]),
108
+ ),
109
+ )
110
+
111
+ // Ref-based guard prevents duplicate in-flight requests for the same field.
112
+ // A ref is used (not state) so the guard is updated synchronously — two rapid
113
+ // clicks both read the ref before any setState is committed, ensuring only
114
+ // the first click proceeds.
115
+ const inflight = useRef(new Set<string>())
116
+
117
+ async function loadMore(fieldName: string, relatedModelSlug: string): Promise<void> {
118
+ if (inflight.current.has(fieldName)) return
119
+
120
+ const current = fkOptions[fieldName]
121
+ if (current === undefined) return
122
+
123
+ inflight.current.add(fieldName)
124
+ setFkOptions((prev) => ({
125
+ ...prev,
126
+ [fieldName]: { ...prev[fieldName]!, loading: true },
127
+ }))
128
+
129
+ try {
130
+ const nextPage = current.page + 1
131
+ const res = await fetch(`${prefix}/related-options/${relatedModelSlug}?page=${nextPage}`)
132
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
133
+ const payload = (await res.json()) as { options: FkOption[]; hasMore: boolean }
134
+ setFkOptions((prev) => ({
135
+ ...prev,
136
+ [fieldName]: {
137
+ options: [...prev[fieldName]!.options, ...payload.options],
138
+ hasMore: payload.hasMore,
139
+ page: nextPage,
140
+ loading: false,
141
+ },
142
+ }))
143
+ } catch {
144
+ setFkOptions((prev) => ({
145
+ ...prev,
146
+ [fieldName]: { ...prev[fieldName]!, loading: false },
147
+ }))
148
+ } finally {
149
+ inflight.current.delete(fieldName)
150
+ }
151
+ }
152
+
153
+ function handleSubmit(e: React.FormEvent) {
154
+ e.preventDefault()
155
+ if (isEdit) {
156
+ patch(`${prefix}/${model.urlSlug}/${String(record![model.idField])}`)
157
+ } else {
158
+ post(`${prefix}/${model.urlSlug}`)
159
+ }
160
+ }
161
+
162
+ function renderInput(field: FieldMeta) {
163
+ const value = data[field.name] ?? ''
164
+ const error = errors[field.name]
165
+ const baseClass = 'w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
166
+ const errorClass = error ? ' border-red-500' : ' border-gray-300'
167
+
168
+ if (field.isForeignKey) {
169
+ const state = fkOptions[field.name] ?? { options: [], hasMore: false, page: 1, loading: false }
170
+ return (
171
+ <div>
172
+ <select
173
+ value={value}
174
+ onChange={(e) => setData(field.name, e.target.value)}
175
+ className={baseClass + errorClass}
176
+ >
177
+ {!field.isRequired && <option value="">— select —</option>}
178
+ {state.options.map((opt) => (
179
+ <option key={String(opt.id)} value={String(opt.id)}>{opt.label}</option>
180
+ ))}
181
+ </select>
182
+ {state.hasMore && (
183
+ <button
184
+ type="button"
185
+ onClick={() => void loadMore(field.name, field.relatedModelSlug!)}
186
+ disabled={state.loading}
187
+ className="mt-1 text-xs text-blue-600 hover:underline disabled:opacity-50"
188
+ >
189
+ {state.loading ? 'Loading…' : 'Load more'}
190
+ </button>
191
+ )}
192
+ </div>
193
+ )
194
+ }
195
+
196
+ if (field.kind === 'enum' && field.enumValues) {
197
+ return (
198
+ <select
199
+ value={value}
200
+ onChange={(e) => setData(field.name, e.target.value)}
201
+ className={baseClass + errorClass}
202
+ >
203
+ {!field.isRequired && <option value="">— select —</option>}
204
+ {field.enumValues.map((v) => (
205
+ <option key={v} value={v}>{v}</option>
206
+ ))}
207
+ </select>
208
+ )
209
+ }
210
+
211
+ if (field.prismaType === 'Boolean') {
212
+ return (
213
+ <input
214
+ type="checkbox"
215
+ checked={value === 'true'}
216
+ onChange={(e) => setData(field.name, String(e.target.checked))}
217
+ className="h-4 w-4 text-blue-600 border-gray-300 rounded"
218
+ />
219
+ )
220
+ }
221
+
222
+ const inputType =
223
+ field.prismaType === 'Int' || field.prismaType === 'Float'
224
+ ? 'number'
225
+ : field.prismaType === 'DateTime'
226
+ ? 'datetime-local'
227
+ : 'text'
228
+
229
+ return (
230
+ <input
231
+ type={inputType}
232
+ value={value}
233
+ onChange={(e) => setData(field.name, e.target.value)}
234
+ required={field.isRequired}
235
+ className={baseClass + errorClass}
236
+ />
237
+ )
238
+ }
239
+
240
+ return (
241
+ <div className="min-h-screen bg-gray-50 p-8">
242
+ <div className="max-w-2xl mx-auto">
243
+ <div className="flex items-center gap-4 mb-6">
244
+ <Link
245
+ href={`${prefix}/${model.urlSlug}`}
246
+ className="text-blue-600 hover:underline text-sm"
247
+ >
248
+ ← {model.displayName}
249
+ </Link>
250
+ <h1 className="text-2xl font-bold text-gray-900">
251
+ {isEdit ? `Edit ${model.name}` : `New ${model.name}`}
252
+ </h1>
253
+ </div>
254
+
255
+ <form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 space-y-5">
256
+ {model.formFields.map((field) => (
257
+ <div key={field.name}>
258
+ <label className="block text-sm font-medium text-gray-700 mb-1">
259
+ {field.name}
260
+ {field.isRequired && <span className="text-red-500 ml-1">*</span>}
261
+ </label>
262
+ {renderInput(field)}
263
+ {errors[field.name] && (
264
+ <p className="mt-1 text-xs text-red-600">{errors[field.name]}</p>
265
+ )}
266
+ </div>
267
+ ))}
268
+
269
+ <div className="flex items-center gap-3 pt-2">
270
+ <button
271
+ type="submit"
272
+ disabled={processing}
273
+ className="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
274
+ >
275
+ {processing ? 'Saving…' : isEdit ? 'Update' : 'Create'}
276
+ </button>
277
+ <Link
278
+ href={`${prefix}/${model.urlSlug}`}
279
+ className="text-gray-600 hover:underline text-sm"
280
+ >
281
+ Cancel
282
+ </Link>
283
+ </div>
284
+ </form>
285
+ </div>
286
+ </div>
287
+ )
288
+ }
@@ -0,0 +1,142 @@
1
+ import { Link, router } from '@inertiajs/react'
2
+
3
+ interface FieldMeta {
4
+ name: string
5
+ isId: boolean
6
+ prismaType: string
7
+ }
8
+
9
+ interface ModelMeta {
10
+ name: string
11
+ urlSlug: string
12
+ displayName: string
13
+ idField: string
14
+ listFields: FieldMeta[]
15
+ }
16
+
17
+ interface PaginationMeta {
18
+ page: number
19
+ perPage: number
20
+ total: number
21
+ totalPages: number
22
+ }
23
+
24
+ interface Props {
25
+ model: ModelMeta
26
+ records: Record<string, unknown>[]
27
+ pagination: PaginationMeta
28
+ models: Array<{ name: string; urlSlug: string }>
29
+ prefix: string
30
+ }
31
+
32
+ function displayValue(value: unknown, prismaType: string): string {
33
+ if (value === null || value === undefined) return '—'
34
+ if (prismaType === 'DateTime') {
35
+ const date = value instanceof Date ? value : new Date(String(value))
36
+ return date.toLocaleString()
37
+ }
38
+ if (value instanceof Date) return value.toLocaleString()
39
+ if (typeof value === 'object') return JSON.stringify(value)
40
+ return String(value)
41
+ }
42
+
43
+ export default function AdminModelIndex({ model, records, pagination, prefix }: Props) {
44
+ function handleDelete(id: unknown) {
45
+ if (window.confirm(`Delete this ${model.name}?`)) {
46
+ router.delete(`${prefix}/${model.urlSlug}/${String(id)}`)
47
+ }
48
+ }
49
+
50
+ return (
51
+ <div className="min-h-screen bg-gray-50 p-8">
52
+ <div className="max-w-6xl mx-auto">
53
+ <div className="flex items-center justify-between mb-6">
54
+ <div className="flex items-center gap-4">
55
+ <Link href={prefix} className="text-blue-600 hover:underline text-sm">
56
+ ← Dashboard
57
+ </Link>
58
+ <h1 className="text-2xl font-bold text-gray-900">{model.displayName}</h1>
59
+ </div>
60
+ <Link
61
+ href={`${prefix}/${model.urlSlug}/new`}
62
+ className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
63
+ >
64
+ New {model.name}
65
+ </Link>
66
+ </div>
67
+
68
+ {records.length === 0 ? (
69
+ <div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
70
+ <p className="text-gray-500">No {model.displayName.toLowerCase()} yet.</p>
71
+ </div>
72
+ ) : (
73
+ <div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
74
+ <table className="w-full text-sm">
75
+ <thead className="bg-gray-50 border-b border-gray-200">
76
+ <tr>
77
+ {model.listFields.map((f) => (
78
+ <th key={f.name} className="px-4 py-3 text-left font-medium text-gray-600">
79
+ {f.name}
80
+ </th>
81
+ ))}
82
+ <th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
83
+ </tr>
84
+ </thead>
85
+ <tbody className="divide-y divide-gray-100">
86
+ {records.map((record) => (
87
+ <tr key={String(record[model.idField])} className="hover:bg-gray-50">
88
+ {model.listFields.map((f) => (
89
+ <td key={f.name} className="px-4 py-3 text-gray-800">
90
+ {displayValue(record[f.name], f.prismaType)}
91
+ </td>
92
+ ))}
93
+ <td className="px-4 py-3 text-right space-x-2">
94
+ <Link
95
+ href={`${prefix}/${model.urlSlug}/${String(record[model.idField])}`}
96
+ className="text-blue-600 hover:underline"
97
+ >
98
+ Edit
99
+ </Link>
100
+ <button
101
+ onClick={() => handleDelete(record[model.idField])}
102
+ className="text-red-600 hover:underline"
103
+ >
104
+ Delete
105
+ </button>
106
+ </td>
107
+ </tr>
108
+ ))}
109
+ </tbody>
110
+ </table>
111
+ </div>
112
+ )}
113
+
114
+ {pagination.totalPages > 1 && (
115
+ <div className="flex items-center justify-between mt-4 text-sm text-gray-600">
116
+ <span>
117
+ Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
118
+ </span>
119
+ <div className="flex gap-2">
120
+ {pagination.page > 1 && (
121
+ <Link
122
+ href={`${prefix}/${model.urlSlug}?page=${pagination.page - 1}`}
123
+ className="px-3 py-1 border rounded hover:bg-gray-50"
124
+ >
125
+ Previous
126
+ </Link>
127
+ )}
128
+ {pagination.page < pagination.totalPages && (
129
+ <Link
130
+ href={`${prefix}/${model.urlSlug}?page=${pagination.page + 1}`}
131
+ className="px-3 py-1 border rounded hover:bg-gray-50"
132
+ >
133
+ Next
134
+ </Link>
135
+ )}
136
+ </div>
137
+ </div>
138
+ )}
139
+ </div>
140
+ </div>
141
+ )
142
+ }
@@ -0,0 +1,189 @@
1
+ import { useForm, Link } from '@inertiajs/react'
2
+
3
+ type FieldKind = 'scalar' | 'relation' | 'enum'
4
+
5
+ interface FieldMeta {
6
+ name: string
7
+ prismaType: string
8
+ kind: FieldKind
9
+ isRequired: boolean
10
+ enumValues?: string[]
11
+ }
12
+
13
+ interface ModelMeta {
14
+ name: string
15
+ urlSlug: string
16
+ displayName: string
17
+ formFields: FieldMeta[]
18
+ }
19
+
20
+ interface Props {
21
+ model: ModelMeta
22
+ models: Array<{ name: string; urlSlug: string }>
23
+ errors: Record<string, string>
24
+ prefix: string
25
+ }
26
+
27
+ /**
28
+ * Fields rendered by dedicated hardcoded inputs — excluded from the generic
29
+ * metadata loop. `password` is also hardcoded but is not a Prisma field and
30
+ * therefore never appears in model.formFields.
31
+ */
32
+ const CORE_FIELD_NAMES = new Set(['name', 'email'])
33
+
34
+ export default function AdminUserCreate({ model, errors, prefix }: Props) {
35
+ // All formFields except the two that are rendered by dedicated hardcoded
36
+ // inputs. Includes `role` (if present) and any custom fields.
37
+ const extraFields = model.formFields.filter((f) => !CORE_FIELD_NAMES.has(f.name))
38
+
39
+ const extraInitialValues = Object.fromEntries(
40
+ extraFields.map((f) => [
41
+ f.name,
42
+ f.kind === 'enum' && f.enumValues != null && f.enumValues.length > 0
43
+ ? (f.enumValues[0] ?? '')
44
+ : '',
45
+ ]),
46
+ )
47
+
48
+ const { data, setData, post, processing } = useForm<Record<string, string>>({
49
+ name: '',
50
+ email: '',
51
+ password: '',
52
+ ...extraInitialValues,
53
+ })
54
+
55
+ function handleSubmit(e: React.FormEvent) {
56
+ e.preventDefault()
57
+ post(`${prefix}/${model.urlSlug}`)
58
+ }
59
+
60
+ const inputClass =
61
+ 'w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
62
+
63
+ function borderClass(field: string): string {
64
+ return errors[field] ? ' border-red-500' : ' border-gray-300'
65
+ }
66
+
67
+ return (
68
+ <div className="min-h-screen bg-gray-50 p-8">
69
+ <div className="max-w-2xl mx-auto">
70
+ <div className="flex items-center gap-4 mb-6">
71
+ <Link
72
+ href={`${prefix}/${model.urlSlug}`}
73
+ className="text-blue-600 hover:underline text-sm"
74
+ >
75
+ ← {model.displayName}
76
+ </Link>
77
+ <h1 className="text-2xl font-bold text-gray-900">New {model.name}</h1>
78
+ </div>
79
+
80
+ <form
81
+ onSubmit={handleSubmit}
82
+ className="bg-white rounded-lg border border-gray-200 p-6 space-y-5"
83
+ >
84
+ {/* name — hardcoded: always required by the Better Auth createUser API */}
85
+ <div>
86
+ <label className="block text-sm font-medium text-gray-700 mb-1">
87
+ name <span className="text-red-500">*</span>
88
+ </label>
89
+ <input
90
+ type="text"
91
+ value={data['name']}
92
+ onChange={(e) => setData('name', e.target.value)}
93
+ required
94
+ className={inputClass + borderClass('name')}
95
+ />
96
+ {errors['name'] && (
97
+ <p className="mt-1 text-xs text-red-600">{errors['name']}</p>
98
+ )}
99
+ </div>
100
+
101
+ {/* email — hardcoded: always required by the Better Auth createUser API */}
102
+ <div>
103
+ <label className="block text-sm font-medium text-gray-700 mb-1">
104
+ email <span className="text-red-500">*</span>
105
+ </label>
106
+ <input
107
+ type="email"
108
+ value={data['email']}
109
+ onChange={(e) => setData('email', e.target.value)}
110
+ required
111
+ className={inputClass + borderClass('email')}
112
+ />
113
+ {errors['email'] && (
114
+ <p className="mt-1 text-xs text-red-600">{errors['email']}</p>
115
+ )}
116
+ </div>
117
+
118
+ {/* password — hardcoded: always required by the Better Auth createUser API.
119
+ Not a Prisma field so it never appears in model.formFields. */}
120
+ <div>
121
+ <label className="block text-sm font-medium text-gray-700 mb-1">
122
+ password <span className="text-red-500">*</span>
123
+ </label>
124
+ <input
125
+ type="password"
126
+ value={data['password']}
127
+ onChange={(e) => setData('password', e.target.value)}
128
+ required
129
+ className={inputClass + borderClass('password')}
130
+ />
131
+ {errors['password'] && (
132
+ <p className="mt-1 text-xs text-red-600">{errors['password']}</p>
133
+ )}
134
+ </div>
135
+
136
+ {/* role and any custom fields — driven from model.formFields metadata */}
137
+ {extraFields.map((field) => (
138
+ <div key={field.name}>
139
+ <label className="block text-sm font-medium text-gray-700 mb-1">
140
+ {field.name}
141
+ {field.isRequired && <span className="text-red-500"> *</span>}
142
+ </label>
143
+ {field.kind === 'enum' && field.enumValues != null ? (
144
+ <select
145
+ value={data[field.name] ?? ''}
146
+ onChange={(e) => setData(field.name, e.target.value)}
147
+ className={inputClass + borderClass(field.name)}
148
+ >
149
+ {field.enumValues.map((v) => (
150
+ <option key={v} value={v}>
151
+ {v}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ ) : (
156
+ <input
157
+ type="text"
158
+ value={data[field.name] ?? ''}
159
+ onChange={(e) => setData(field.name, e.target.value)}
160
+ required={field.isRequired}
161
+ className={inputClass + borderClass(field.name)}
162
+ />
163
+ )}
164
+ {errors[field.name] && (
165
+ <p className="mt-1 text-xs text-red-600">{errors[field.name]}</p>
166
+ )}
167
+ </div>
168
+ ))}
169
+
170
+ <div className="flex items-center gap-3 pt-2">
171
+ <button
172
+ type="submit"
173
+ disabled={processing}
174
+ className="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
175
+ >
176
+ {processing ? 'Creating…' : 'Create'}
177
+ </button>
178
+ <Link
179
+ href={`${prefix}/${model.urlSlug}`}
180
+ className="text-gray-600 hover:underline text-sm"
181
+ >
182
+ Cancel
183
+ </Link>
184
+ </div>
185
+ </form>
186
+ </div>
187
+ </div>
188
+ )
189
+ }