@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.
- package/LICENSE +21 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/crud/pagination.d.ts +11 -0
- package/dist/crud/pagination.d.ts.map +1 -0
- package/dist/crud/pagination.js +31 -0
- package/dist/crud/pagination.js.map +1 -0
- package/dist/crud/query.d.ts +64 -0
- package/dist/crud/query.d.ts.map +1 -0
- package/dist/crud/query.js +137 -0
- package/dist/crud/query.js.map +1 -0
- package/dist/crud/router.d.ts +49 -0
- package/dist/crud/router.d.ts.map +1 -0
- package/dist/crud/router.js +335 -0
- package/dist/crud/router.js.map +1 -0
- package/dist/dmmf/fields.d.ts +37 -0
- package/dist/dmmf/fields.d.ts.map +1 -0
- package/dist/dmmf/fields.js +88 -0
- package/dist/dmmf/fields.js.map +1 -0
- package/dist/dmmf/parser.d.ts +15 -0
- package/dist/dmmf/parser.d.ts.map +1 -0
- package/dist/dmmf/parser.js +54 -0
- package/dist/dmmf/parser.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +8 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +21 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/mount.d.ts +4 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +21 -0
- package/dist/mount.js.map +1 -0
- package/dist/scaffold/index.d.ts +14 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +35 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/templates.d.ts +19 -0
- package/dist/scaffold/templates.d.ts.map +1 -0
- package/dist/scaffold/templates.js +371 -0
- package/dist/scaffold/templates.js.map +1 -0
- package/dist/templates/Dashboard.tsx +40 -0
- package/dist/templates/ModelForm.tsx +288 -0
- package/dist/templates/ModelIndex.tsx +142 -0
- package/dist/templates/UserCreate.tsx +189 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
- package/templates/Dashboard.tsx +40 -0
- package/templates/ModelForm.tsx +288 -0
- package/templates/ModelIndex.tsx +142 -0
- package/templates/UserCreate.tsx +189 -0
|
@@ -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
|
+
}
|