@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,159 @@
1
+ export type AdminFieldKind = 'scalar' | 'relation' | 'enum';
2
+ export interface AdminFieldMeta {
3
+ name: string;
4
+ /** Raw Prisma scalar type: 'String' | 'Int' | 'Boolean' | 'DateTime' | … */
5
+ prismaType: string;
6
+ kind: AdminFieldKind;
7
+ isRequired: boolean;
8
+ isId: boolean;
9
+ isUnique: boolean;
10
+ hasDefault: boolean;
11
+ isReadOnly: boolean;
12
+ /**
13
+ * True when this scalar field is a FK column backing a Prisma relation
14
+ * (e.g. `userId` for a `user User @relation(…)` field).
15
+ * Prisma marks FK scalars isReadOnly in its DMMF, but admins still need to
16
+ * set them when creating/editing records. The router renders them as a
17
+ * <select> dropdown populated from the related model.
18
+ */
19
+ isForeignKey: boolean;
20
+ /**
21
+ * For FK scalar fields, the name of the related Prisma model
22
+ * (e.g. `'User'` for `userId`). Used by the admin router to fetch
23
+ * <select> options when rendering the create/edit form.
24
+ */
25
+ relatedModelName?: string;
26
+ /**
27
+ * For FK scalar fields, the URL slug of the related model as registered in
28
+ * the admin router (e.g. `'user'` for `userId`, `'userprofile'` for a
29
+ * `UserProfile` model). Used by ModelForm to construct the correct
30
+ * `/related-options/:slug` URL for load-more pagination. Always equals the
31
+ * related model's `urlSlug` — never a client-side derivation.
32
+ */
33
+ relatedModelSlug?: string;
34
+ isList: boolean;
35
+ relationTo?: string;
36
+ enumValues?: string[];
37
+ }
38
+ export interface AdminModelMeta {
39
+ name: string;
40
+ urlSlug: string;
41
+ displayName: string;
42
+ idField: string;
43
+ idType: 'string' | 'number';
44
+ displayField: string;
45
+ fields: AdminFieldMeta[];
46
+ listFields: AdminFieldMeta[];
47
+ formFields: AdminFieldMeta[];
48
+ }
49
+ export interface AdminPaginationMeta {
50
+ page: number;
51
+ perPage: number;
52
+ total: number;
53
+ totalPages: number;
54
+ }
55
+ export interface PaginationParams {
56
+ page: number;
57
+ perPage: number;
58
+ skip: number;
59
+ take: number;
60
+ }
61
+ /**
62
+ * Minimal auth interface satisfied by any Better Auth instance.
63
+ *
64
+ * The three optional admin methods (`createUser`, `adminUpdateUser`,
65
+ * `removeUser`) are present when the Better Auth admin plugin is enabled.
66
+ * When they exist, the admin router routes User model mutations through Better
67
+ * Auth instead of calling Prisma directly — ensuring password hashing,
68
+ * session cleanup, and other auth lifecycle hooks are respected.
69
+ */
70
+ export interface AdminAuthLike {
71
+ api: {
72
+ getSession(opts: {
73
+ headers: unknown;
74
+ }): Promise<{
75
+ user: {
76
+ role: string;
77
+ };
78
+ } | null>;
79
+ /** Creates a user via the Better Auth admin plugin. */
80
+ createUser?: (opts: {
81
+ body: {
82
+ email: string;
83
+ name: string;
84
+ password: string;
85
+ role?: string;
86
+ data?: Record<string, unknown>;
87
+ };
88
+ }) => Promise<{
89
+ user: unknown;
90
+ }>;
91
+ /**
92
+ * Updates a user via the Better Auth admin plugin.
93
+ * Requires the calling admin's session headers for permission checks.
94
+ */
95
+ adminUpdateUser?: (opts: {
96
+ body: {
97
+ userId: string;
98
+ data: Record<string, unknown>;
99
+ };
100
+ headers: unknown;
101
+ }) => Promise<unknown>;
102
+ /**
103
+ * Deletes a user via the Better Auth admin plugin.
104
+ * Also revokes all active sessions for that user.
105
+ * Requires the calling admin's session headers for permission checks.
106
+ */
107
+ removeUser?: (opts: {
108
+ body: {
109
+ userId: string;
110
+ };
111
+ headers: unknown;
112
+ }) => Promise<unknown>;
113
+ };
114
+ }
115
+ export interface PrismaClientLike {
116
+ $disconnect(): Promise<void>;
117
+ }
118
+ /**
119
+ * Minimal structured-logging interface satisfied by any Pino Logger instance.
120
+ * Keeping this as a local interface decouples the admin package from a direct
121
+ * pino dependency while still enabling rich structured log output when the
122
+ * host application passes `app.logger` from @hypersonic-js/core.
123
+ */
124
+ export interface LoggerLike {
125
+ error(obj: unknown, msg?: string): void;
126
+ warn(obj: unknown, msg?: string): void;
127
+ info(obj: unknown, msg?: string): void;
128
+ }
129
+ export interface AdminOptions {
130
+ /** Pre-generated admin model metadata — pass the content of prisma/admin-meta.json. */
131
+ meta: AdminModelMeta[];
132
+ /** Better Auth instance. When the admin plugin is enabled, user CRUD is routed through it. */
133
+ auth: AdminAuthLike;
134
+ /** Route prefix for all admin routes. Defaults to '/admin'. */
135
+ prefix?: string;
136
+ /** When true, shows the default hidden Better Auth tables. Defaults to false. */
137
+ showAuthModels?: boolean;
138
+ /** Additional model names to hide from the admin nav. */
139
+ hiddenModels?: string[];
140
+ /** Optional structured logger — pass `app.logger` from createApp(). */
141
+ logger?: LoggerLike;
142
+ /**
143
+ * Name of the Better Auth user model in your Prisma schema.
144
+ * When `auth.api.createUser` is present, all create / update / delete
145
+ * operations on this model are routed through the Better Auth admin API
146
+ * instead of calling Prisma directly.
147
+ * Defaults to `'User'`.
148
+ */
149
+ betterAuthUserModel?: string;
150
+ }
151
+ export interface ScaffoldOptions {
152
+ targetDir?: string;
153
+ force?: boolean;
154
+ }
155
+ export interface ScaffoldResult {
156
+ written: string[];
157
+ skipped: string[];
158
+ }
159
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // ── Admin field / model metadata ─────────────────────────────────────────────
2
+ export {};
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gFAAgF"}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@hypersonic-js/admin",
3
+ "version": "0.2.0",
4
+ "description": "Auto-generated admin dashboard for Hypersonic.js — full CRUD from your Prisma schema",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "templates"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/hypersonic-js/hypersonic-js"
21
+ },
22
+ "author": "Joaquim Dalton-Pereira",
23
+ "license": "MIT",
24
+ "homepage": "https://hypersonic-js.com",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "node": "^24.0.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@prisma/client": "7.8.0",
33
+ "better-auth": "1.6.12"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@prisma/client": {
37
+ "optional": false
38
+ },
39
+ "better-auth": {
40
+ "optional": false
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@types/express": "5.0.6",
45
+ "@types/node": "25.9.3",
46
+ "@types/supertest": "7.2.0",
47
+ "@vitest/coverage-v8": "4.1.8",
48
+ "express": "5.2.1",
49
+ "supertest": "7.2.2",
50
+ "typescript": "6.0.3",
51
+ "vitest": "4.1.8"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc && node scripts/copy-templates.mjs",
55
+ "test": "vitest run",
56
+ "test:coverage": "vitest run --coverage",
57
+ "type-check": "tsc --noEmit",
58
+ "clean": "rm -rf dist coverage"
59
+ }
60
+ }
@@ -0,0 +1,40 @@
1
+ import { Link } from '@inertiajs/react'
2
+
3
+ interface ModelCard {
4
+ name: string
5
+ urlSlug: string
6
+ recordCount: number
7
+ }
8
+
9
+ interface Props {
10
+ models: ModelCard[]
11
+ prefix: string
12
+ }
13
+
14
+ export default function AdminDashboard({ models, prefix }: Props) {
15
+ return (
16
+ <div className="min-h-screen bg-gray-50 p-8">
17
+ <div className="max-w-5xl mx-auto">
18
+ <h1 className="text-3xl font-bold text-gray-900 mb-8">Admin Dashboard</h1>
19
+
20
+ {models.length === 0 && (
21
+ <p className="text-gray-500">No models are visible. Check your mountAdmin configuration.</p>
22
+ )}
23
+
24
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
25
+ {models.map((model) => (
26
+ <Link
27
+ key={model.urlSlug}
28
+ href={`${prefix}/${model.urlSlug}`}
29
+ className="block bg-white rounded-lg border border-gray-200 p-6 hover:border-blue-500 transition-colors"
30
+ >
31
+ <h2 className="text-lg font-semibold text-gray-800">{model.name}</h2>
32
+ <p className="text-3xl font-bold text-blue-600 mt-2">{model.recordCount}</p>
33
+ <p className="text-sm text-gray-500 mt-1">records</p>
34
+ </Link>
35
+ ))}
36
+ </div>
37
+ </div>
38
+ </div>
39
+ )
40
+ }
@@ -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
+ }