@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
package/dist/mount.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createAdminAuthMiddleware } from './middleware/auth.js';
|
|
2
|
+
import { createAdminRouter } from './crud/router.js';
|
|
3
|
+
const DEFAULT_HIDDEN_MODELS = ['Session', 'Account', 'Verification', 'JwksKey'];
|
|
4
|
+
const DEFAULT_PREFIX = '/admin';
|
|
5
|
+
export function mountAdmin(app, prisma, options) {
|
|
6
|
+
const prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
7
|
+
const hidden = new Set([
|
|
8
|
+
...(options.showAuthModels === true ? [] : DEFAULT_HIDDEN_MODELS),
|
|
9
|
+
...(options.hiddenModels ?? []),
|
|
10
|
+
]);
|
|
11
|
+
const models = options.meta.filter((m) => !hidden.has(m.name));
|
|
12
|
+
const authMiddleware = createAdminAuthMiddleware(options.auth);
|
|
13
|
+
const router = createAdminRouter(prisma, models, prefix, {
|
|
14
|
+
allMeta: options.meta,
|
|
15
|
+
logger: options.logger,
|
|
16
|
+
auth: options.auth,
|
|
17
|
+
betterAuthUserModel: options.betterAuthUserModel,
|
|
18
|
+
});
|
|
19
|
+
app.use(prefix, authMiddleware, router);
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=mount.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mount.js","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAA;AAChE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAEpD,MAAM,qBAAqB,GAAG,CAAC,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,SAAS,CAAC,CAAA;AAC/E,MAAM,cAAc,GAAG,QAAQ,CAAA;AAE/B,MAAM,UAAU,UAAU,CACxB,GAAgB,EAChB,MAAwB,EACxB,OAAqB;IAErB,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,cAAc,CAAA;IAE/C,MAAM,MAAM,GAAG,IAAI,GAAG,CAAS;QAC7B,GAAG,CAAC,OAAO,CAAC,cAAc,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,qBAAqB,CAAC;QACjE,GAAG,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC;KAChC,CAAC,CAAA;IACF,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IAE9D,MAAM,cAAc,GAAG,yBAAyB,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9D,MAAM,MAAM,GAAG,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;QACvD,OAAO,EAAE,OAAO,CAAC,IAAI;QACrB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,mBAAmB,EAAE,OAAO,CAAC,mBAAmB;KACjD,CAAC,CAAA;IAEF,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,CAAC,CAAA;AACzC,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ScaffoldOptions, ScaffoldResult } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Copies the admin page components into the user's project.
|
|
4
|
+
* These components are schema-driven and stay in sync with Prisma model changes
|
|
5
|
+
* automatically — no regeneration needed after schema updates.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { scaffoldAdmin } from '@hypersonic-js/admin'
|
|
10
|
+
* await scaffoldAdmin({ targetDir: 'resources/js/Pages', force: false })
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare function scaffoldAdmin(options?: ScaffoldOptions): Promise<ScaffoldResult>;
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scaffold/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAMlE;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CAuB1F"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../templates');
|
|
5
|
+
const SCAFFOLD_FILES = ['Dashboard.tsx', 'ModelIndex.tsx', 'ModelForm.tsx', 'UserCreate.tsx'];
|
|
6
|
+
/**
|
|
7
|
+
* Copies the admin page components into the user's project.
|
|
8
|
+
* These components are schema-driven and stay in sync with Prisma model changes
|
|
9
|
+
* automatically — no regeneration needed after schema updates.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { scaffoldAdmin } from '@hypersonic-js/admin'
|
|
14
|
+
* await scaffoldAdmin({ targetDir: 'resources/js/Pages', force: false })
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export async function scaffoldAdmin(options = {}) {
|
|
18
|
+
const { targetDir = 'resources/js/Pages', force = false } = options;
|
|
19
|
+
const adminDir = join(targetDir, 'Admin');
|
|
20
|
+
mkdirSync(adminDir, { recursive: true });
|
|
21
|
+
const written = [];
|
|
22
|
+
const skipped = [];
|
|
23
|
+
for (const name of SCAFFOLD_FILES) {
|
|
24
|
+
const filePath = join(adminDir, name);
|
|
25
|
+
if (existsSync(filePath) && !force) {
|
|
26
|
+
skipped.push(name);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const content = readFileSync(join(TEMPLATES_DIR, name), 'utf-8');
|
|
30
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
31
|
+
written.push(name);
|
|
32
|
+
}
|
|
33
|
+
return { written, skipped };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/scaffold/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAC5E,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAGxC,MAAM,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,iBAAiB,CAAC,CAAA;AAEtF,MAAM,cAAc,GAAG,CAAC,eAAe,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,CAAU,CAAA;AAEtG;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,UAA2B,EAAE;IAC/D,MAAM,EAAE,SAAS,GAAG,oBAAoB,EAAE,KAAK,GAAG,KAAK,EAAE,GAAG,OAAO,CAAA;IACnE,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;IAEzC,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAExC,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,MAAM,OAAO,GAAa,EAAE,CAAA;IAE5B,KAAK,MAAM,IAAI,IAAI,cAAc,EAAE,CAAC;QAClC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;QAErC,IAAI,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YACnC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YAClB,SAAQ;QACV,CAAC;QAED,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,EAAE,OAAO,CAAC,CAAA;QAChE,aAAa,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,CAAA;QACzC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAA;AAC7B,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template source for resources/js/Pages/Admin/Dashboard.tsx.
|
|
3
|
+
* Shows all admin-managed models with record counts and navigation links.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DASHBOARD_TEMPLATE = "import { Link } from '@inertiajs/react'\n\ninterface ModelCard {\n name: string\n urlSlug: string\n recordCount: number\n}\n\ninterface Props {\n models: ModelCard[]\n prefix: string\n}\n\nexport default function AdminDashboard({ models, prefix }: Props) {\n return (\n <div className=\"min-h-screen bg-gray-50 p-8\">\n <div className=\"max-w-5xl mx-auto\">\n <h1 className=\"text-3xl font-bold text-gray-900 mb-8\">Admin Dashboard</h1>\n\n {models.length === 0 && (\n <p className=\"text-gray-500\">No models are visible. Check your mountAdmin configuration.</p>\n )}\n\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4\">\n {models.map((model) => (\n <Link\n key={model.urlSlug}\n href={`${prefix}/${model.urlSlug}`}\n className=\"block bg-white rounded-lg border border-gray-200 p-6 hover:border-blue-500 transition-colors\"\n >\n <h2 className=\"text-lg font-semibold text-gray-800\">{model.name}</h2>\n <p className=\"text-3xl font-bold text-blue-600 mt-2\">{model.recordCount}</p>\n <p className=\"text-sm text-gray-500 mt-1\">records</p>\n </Link>\n ))}\n </div>\n </div>\n </div>\n )\n}\n";
|
|
6
|
+
/**
|
|
7
|
+
* Template source for resources/js/Pages/Admin/ModelIndex.tsx.
|
|
8
|
+
* Renders a paginated table for any model, driven entirely by the model metadata
|
|
9
|
+
* passed as Inertia props — no per-model customisation needed.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MODEL_INDEX_TEMPLATE = "import { Link, router } from '@inertiajs/react'\n\ninterface FieldMeta {\n name: string\n isId: boolean\n}\n\ninterface ModelMeta {\n name: string\n urlSlug: string\n displayName: string\n idField: string\n listFields: FieldMeta[]\n}\n\ninterface PaginationMeta {\n page: number\n perPage: number\n total: number\n totalPages: number\n}\n\ninterface Props {\n model: ModelMeta\n records: Record<string, unknown>[]\n pagination: PaginationMeta\n models: Array<{ name: string; urlSlug: string }>\n prefix: string\n}\n\nfunction displayValue(value: unknown): string {\n if (value === null || value === undefined) return '\u2014'\n if (value instanceof Date) return value.toLocaleDateString()\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\nexport default function AdminModelIndex({ model, records, pagination, models, prefix }: Props) {\n function handleDelete(id: unknown) {\n if (window.confirm(`Delete this ${model.name}?`)) {\n router.delete(`${prefix}/${model.urlSlug}/${String(id)}`)\n }\n }\n\n return (\n <div className=\"min-h-screen bg-gray-50 p-8\">\n <div className=\"max-w-6xl mx-auto\">\n <div className=\"flex items-center justify-between mb-6\">\n <div className=\"flex items-center gap-4\">\n <Link href={prefix} className=\"text-blue-600 hover:underline text-sm\">\n \u2190 Dashboard\n </Link>\n <h1 className=\"text-2xl font-bold text-gray-900\">{model.displayName}</h1>\n </div>\n <Link\n href={`${prefix}/${model.urlSlug}/new`}\n className=\"bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700\"\n >\n New {model.name}\n </Link>\n </div>\n\n {records.length === 0 ? (\n <div className=\"bg-white rounded-lg border border-gray-200 p-12 text-center\">\n <p className=\"text-gray-500\">No {model.displayName.toLowerCase()} yet.</p>\n </div>\n ) : (\n <div className=\"bg-white rounded-lg border border-gray-200 overflow-hidden\">\n <table className=\"w-full text-sm\">\n <thead className=\"bg-gray-50 border-b border-gray-200\">\n <tr>\n {model.listFields.map((f) => (\n <th key={f.name} className=\"px-4 py-3 text-left font-medium text-gray-600\">\n {f.name}\n </th>\n ))}\n <th className=\"px-4 py-3 text-right font-medium text-gray-600\">Actions</th>\n </tr>\n </thead>\n <tbody className=\"divide-y divide-gray-100\">\n {records.map((record, i) => (\n <tr key={i} className=\"hover:bg-gray-50\">\n {model.listFields.map((f) => (\n <td key={f.name} className=\"px-4 py-3 text-gray-800\">\n {displayValue(record[f.name])}\n </td>\n ))}\n <td className=\"px-4 py-3 text-right space-x-2\">\n <Link\n href={`${prefix}/${model.urlSlug}/${String(record[model.idField])}`}\n className=\"text-blue-600 hover:underline\"\n >\n Edit\n </Link>\n <button\n onClick={() => handleDelete(record[model.idField])}\n className=\"text-red-600 hover:underline\"\n >\n Delete\n </button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n\n {pagination.totalPages > 1 && (\n <div className=\"flex items-center justify-between mt-4 text-sm text-gray-600\">\n <span>\n Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)\n </span>\n <div className=\"flex gap-2\">\n {pagination.page > 1 && (\n <Link\n href={`${prefix}/${model.urlSlug}?page=${pagination.page - 1}`}\n className=\"px-3 py-1 border rounded hover:bg-gray-50\"\n >\n Previous\n </Link>\n )}\n {pagination.page < pagination.totalPages && (\n <Link\n href={`${prefix}/${model.urlSlug}?page=${pagination.page + 1}`}\n className=\"px-3 py-1 border rounded hover:bg-gray-50\"\n >\n Next\n </Link>\n )}\n </div>\n </div>\n )}\n </div>\n </div>\n )\n}\n";
|
|
12
|
+
/**
|
|
13
|
+
* Template source for resources/js/Pages/Admin/ModelForm.tsx.
|
|
14
|
+
* Renders a create or edit form for any model using Inertia's useForm hook.
|
|
15
|
+
* The mode (create vs edit) is determined by whether 'record' is null.
|
|
16
|
+
* FK scalar fields are rendered as <select> dropdowns populated from relatedOptions.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MODEL_FORM_TEMPLATE = "import { useForm } from '@inertiajs/react'\nimport { Link } from '@inertiajs/react'\n\ntype FieldKind = 'scalar' | 'relation' | 'enum'\n\ninterface FieldMeta {\n name: string\n prismaType: string\n kind: FieldKind\n isRequired: boolean\n isForeignKey: boolean\n relatedModelName?: string\n enumValues?: string[]\n}\n\ninterface ModelMeta {\n name: string\n urlSlug: string\n displayName: string\n idField: string\n formFields: FieldMeta[]\n}\n\ninterface Props {\n model: ModelMeta\n record: Record<string, unknown> | null\n models: Array<{ name: string; urlSlug: string }>\n errors: Record<string, string>\n prefix: string\n relatedOptions: Record<string, Array<{ id: string; label: string }>>\n}\n\nfunction buildInitialData(\n formFields: FieldMeta[],\n record: Record<string, unknown> | null,\n): Record<string, string> {\n return Object.fromEntries(\n formFields.map((f) => {\n const value = record?.[f.name]\n if (value instanceof Date) return [f.name, value.toISOString().slice(0, 16)]\n return [f.name, value !== null && value !== undefined ? String(value) : '']\n }),\n )\n}\n\nexport default function AdminModelForm({ model, record, models, errors, prefix, relatedOptions }: Props) {\n const isEdit = record !== null\n const { data, setData, post, patch, processing } = useForm(\n buildInitialData(model.formFields, record),\n )\n\n function handleSubmit(e: React.FormEvent) {\n e.preventDefault()\n if (isEdit) {\n patch(`${prefix}/${model.urlSlug}/${String(record![model.idField])}`)\n } else {\n post(`${prefix}/${model.urlSlug}`)\n }\n }\n\n function renderInput(field: FieldMeta) {\n const value = data[field.name] ?? ''\n const error = errors[field.name]\n const baseClass = 'w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'\n const errorClass = error ? ' border-red-500' : ' border-gray-300'\n\n if (field.isForeignKey) {\n const options = relatedOptions[field.name] ?? []\n return (\n <select\n value={value}\n onChange={(e) => setData(field.name, e.target.value)}\n className={baseClass + errorClass}\n >\n {!field.isRequired && <option value=\"\">\u2014 select \u2014</option>}\n {options.map((opt) => (\n <option key={opt.id} value={opt.id}>{opt.label}</option>\n ))}\n </select>\n )\n }\n\n if (field.kind === 'enum' && field.enumValues) {\n return (\n <select\n value={value}\n onChange={(e) => setData(field.name, e.target.value)}\n className={baseClass + errorClass}\n >\n {!field.isRequired && <option value=\"\">\u2014 select \u2014</option>}\n {field.enumValues.map((v) => (\n <option key={v} value={v}>{v}</option>\n ))}\n </select>\n )\n }\n\n if (field.prismaType === 'Boolean') {\n return (\n <input\n type=\"checkbox\"\n checked={value === 'true'}\n onChange={(e) => setData(field.name, String(e.target.checked))}\n className=\"h-4 w-4 text-blue-600 border-gray-300 rounded\"\n />\n )\n }\n\n const inputType =\n field.prismaType === 'Int' || field.prismaType === 'Float'\n ? 'number'\n : field.prismaType === 'DateTime'\n ? 'datetime-local'\n : 'text'\n\n return (\n <input\n type={inputType}\n value={value}\n onChange={(e) => setData(field.name, e.target.value)}\n required={field.isRequired}\n className={baseClass + errorClass}\n />\n )\n }\n\n return (\n <div className=\"min-h-screen bg-gray-50 p-8\">\n <div className=\"max-w-2xl mx-auto\">\n <div className=\"flex items-center gap-4 mb-6\">\n <Link\n href={`${prefix}/${model.urlSlug}`}\n className=\"text-blue-600 hover:underline text-sm\"\n >\n \u2190 {model.displayName}\n </Link>\n <h1 className=\"text-2xl font-bold text-gray-900\">\n {isEdit ? `Edit ${model.name}` : `New ${model.name}`}\n </h1>\n </div>\n\n <form onSubmit={handleSubmit} className=\"bg-white rounded-lg border border-gray-200 p-6 space-y-5\">\n {model.formFields.map((field) => (\n <div key={field.name}>\n <label className=\"block text-sm font-medium text-gray-700 mb-1\">\n {field.name}\n {field.isRequired && <span className=\"text-red-500 ml-1\">*</span>}\n </label>\n {renderInput(field)}\n {errors[field.name] && (\n <p className=\"mt-1 text-xs text-red-600\">{errors[field.name]}</p>\n )}\n </div>\n ))}\n\n <div className=\"flex items-center gap-3 pt-2\">\n <button\n type=\"submit\"\n disabled={processing}\n className=\"bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50\"\n >\n {processing ? 'Saving\u2026' : isEdit ? 'Update' : 'Create'}\n </button>\n <Link\n href={`${prefix}/${model.urlSlug}`}\n className=\"text-gray-600 hover:underline text-sm\"\n >\n Cancel\n </Link>\n </div>\n </form>\n </div>\n </div>\n )\n}\n";
|
|
19
|
+
//# sourceMappingURL=templates.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../src/scaffold/templates.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,kBAAkB,uyCAwC9B,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,qwJAyIhC,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,o3KA+K/B,CAAA"}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template source for resources/js/Pages/Admin/Dashboard.tsx.
|
|
3
|
+
* Shows all admin-managed models with record counts and navigation links.
|
|
4
|
+
*/
|
|
5
|
+
export const DASHBOARD_TEMPLATE = `import { Link } from '@inertiajs/react'
|
|
6
|
+
|
|
7
|
+
interface ModelCard {
|
|
8
|
+
name: string
|
|
9
|
+
urlSlug: string
|
|
10
|
+
recordCount: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
models: ModelCard[]
|
|
15
|
+
prefix: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function AdminDashboard({ models, prefix }: Props) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="min-h-screen bg-gray-50 p-8">
|
|
21
|
+
<div className="max-w-5xl mx-auto">
|
|
22
|
+
<h1 className="text-3xl font-bold text-gray-900 mb-8">Admin Dashboard</h1>
|
|
23
|
+
|
|
24
|
+
{models.length === 0 && (
|
|
25
|
+
<p className="text-gray-500">No models are visible. Check your mountAdmin configuration.</p>
|
|
26
|
+
)}
|
|
27
|
+
|
|
28
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
29
|
+
{models.map((model) => (
|
|
30
|
+
<Link
|
|
31
|
+
key={model.urlSlug}
|
|
32
|
+
href={\`\${prefix}/\${model.urlSlug}\`}
|
|
33
|
+
className="block bg-white rounded-lg border border-gray-200 p-6 hover:border-blue-500 transition-colors"
|
|
34
|
+
>
|
|
35
|
+
<h2 className="text-lg font-semibold text-gray-800">{model.name}</h2>
|
|
36
|
+
<p className="text-3xl font-bold text-blue-600 mt-2">{model.recordCount}</p>
|
|
37
|
+
<p className="text-sm text-gray-500 mt-1">records</p>
|
|
38
|
+
</Link>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
/**
|
|
47
|
+
* Template source for resources/js/Pages/Admin/ModelIndex.tsx.
|
|
48
|
+
* Renders a paginated table for any model, driven entirely by the model metadata
|
|
49
|
+
* passed as Inertia props — no per-model customisation needed.
|
|
50
|
+
*/
|
|
51
|
+
export const MODEL_INDEX_TEMPLATE = `import { Link, router } from '@inertiajs/react'
|
|
52
|
+
|
|
53
|
+
interface FieldMeta {
|
|
54
|
+
name: string
|
|
55
|
+
isId: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ModelMeta {
|
|
59
|
+
name: string
|
|
60
|
+
urlSlug: string
|
|
61
|
+
displayName: string
|
|
62
|
+
idField: string
|
|
63
|
+
listFields: FieldMeta[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface PaginationMeta {
|
|
67
|
+
page: number
|
|
68
|
+
perPage: number
|
|
69
|
+
total: number
|
|
70
|
+
totalPages: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Props {
|
|
74
|
+
model: ModelMeta
|
|
75
|
+
records: Record<string, unknown>[]
|
|
76
|
+
pagination: PaginationMeta
|
|
77
|
+
models: Array<{ name: string; urlSlug: string }>
|
|
78
|
+
prefix: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function displayValue(value: unknown): string {
|
|
82
|
+
if (value === null || value === undefined) return '—'
|
|
83
|
+
if (value instanceof Date) return value.toLocaleDateString()
|
|
84
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
85
|
+
return String(value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function AdminModelIndex({ model, records, pagination, models, prefix }: Props) {
|
|
89
|
+
function handleDelete(id: unknown) {
|
|
90
|
+
if (window.confirm(\`Delete this \${model.name}?\`)) {
|
|
91
|
+
router.delete(\`\${prefix}/\${model.urlSlug}/\${String(id)}\`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="min-h-screen bg-gray-50 p-8">
|
|
97
|
+
<div className="max-w-6xl mx-auto">
|
|
98
|
+
<div className="flex items-center justify-between mb-6">
|
|
99
|
+
<div className="flex items-center gap-4">
|
|
100
|
+
<Link href={prefix} className="text-blue-600 hover:underline text-sm">
|
|
101
|
+
← Dashboard
|
|
102
|
+
</Link>
|
|
103
|
+
<h1 className="text-2xl font-bold text-gray-900">{model.displayName}</h1>
|
|
104
|
+
</div>
|
|
105
|
+
<Link
|
|
106
|
+
href={\`\${prefix}/\${model.urlSlug}/new\`}
|
|
107
|
+
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
|
108
|
+
>
|
|
109
|
+
New {model.name}
|
|
110
|
+
</Link>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{records.length === 0 ? (
|
|
114
|
+
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
|
|
115
|
+
<p className="text-gray-500">No {model.displayName.toLowerCase()} yet.</p>
|
|
116
|
+
</div>
|
|
117
|
+
) : (
|
|
118
|
+
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
119
|
+
<table className="w-full text-sm">
|
|
120
|
+
<thead className="bg-gray-50 border-b border-gray-200">
|
|
121
|
+
<tr>
|
|
122
|
+
{model.listFields.map((f) => (
|
|
123
|
+
<th key={f.name} className="px-4 py-3 text-left font-medium text-gray-600">
|
|
124
|
+
{f.name}
|
|
125
|
+
</th>
|
|
126
|
+
))}
|
|
127
|
+
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
|
|
128
|
+
</tr>
|
|
129
|
+
</thead>
|
|
130
|
+
<tbody className="divide-y divide-gray-100">
|
|
131
|
+
{records.map((record, i) => (
|
|
132
|
+
<tr key={i} className="hover:bg-gray-50">
|
|
133
|
+
{model.listFields.map((f) => (
|
|
134
|
+
<td key={f.name} className="px-4 py-3 text-gray-800">
|
|
135
|
+
{displayValue(record[f.name])}
|
|
136
|
+
</td>
|
|
137
|
+
))}
|
|
138
|
+
<td className="px-4 py-3 text-right space-x-2">
|
|
139
|
+
<Link
|
|
140
|
+
href={\`\${prefix}/\${model.urlSlug}/\${String(record[model.idField])}\`}
|
|
141
|
+
className="text-blue-600 hover:underline"
|
|
142
|
+
>
|
|
143
|
+
Edit
|
|
144
|
+
</Link>
|
|
145
|
+
<button
|
|
146
|
+
onClick={() => handleDelete(record[model.idField])}
|
|
147
|
+
className="text-red-600 hover:underline"
|
|
148
|
+
>
|
|
149
|
+
Delete
|
|
150
|
+
</button>
|
|
151
|
+
</td>
|
|
152
|
+
</tr>
|
|
153
|
+
))}
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{pagination.totalPages > 1 && (
|
|
160
|
+
<div className="flex items-center justify-between mt-4 text-sm text-gray-600">
|
|
161
|
+
<span>
|
|
162
|
+
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
|
163
|
+
</span>
|
|
164
|
+
<div className="flex gap-2">
|
|
165
|
+
{pagination.page > 1 && (
|
|
166
|
+
<Link
|
|
167
|
+
href={\`\${prefix}/\${model.urlSlug}?page=\${pagination.page - 1}\`}
|
|
168
|
+
className="px-3 py-1 border rounded hover:bg-gray-50"
|
|
169
|
+
>
|
|
170
|
+
Previous
|
|
171
|
+
</Link>
|
|
172
|
+
)}
|
|
173
|
+
{pagination.page < pagination.totalPages && (
|
|
174
|
+
<Link
|
|
175
|
+
href={\`\${prefix}/\${model.urlSlug}?page=\${pagination.page + 1}\`}
|
|
176
|
+
className="px-3 py-1 border rounded hover:bg-gray-50"
|
|
177
|
+
>
|
|
178
|
+
Next
|
|
179
|
+
</Link>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
/**
|
|
190
|
+
* Template source for resources/js/Pages/Admin/ModelForm.tsx.
|
|
191
|
+
* Renders a create or edit form for any model using Inertia's useForm hook.
|
|
192
|
+
* The mode (create vs edit) is determined by whether 'record' is null.
|
|
193
|
+
* FK scalar fields are rendered as <select> dropdowns populated from relatedOptions.
|
|
194
|
+
*/
|
|
195
|
+
export const MODEL_FORM_TEMPLATE = `import { useForm } from '@inertiajs/react'
|
|
196
|
+
import { Link } from '@inertiajs/react'
|
|
197
|
+
|
|
198
|
+
type FieldKind = 'scalar' | 'relation' | 'enum'
|
|
199
|
+
|
|
200
|
+
interface FieldMeta {
|
|
201
|
+
name: string
|
|
202
|
+
prismaType: string
|
|
203
|
+
kind: FieldKind
|
|
204
|
+
isRequired: boolean
|
|
205
|
+
isForeignKey: boolean
|
|
206
|
+
relatedModelName?: string
|
|
207
|
+
enumValues?: string[]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface ModelMeta {
|
|
211
|
+
name: string
|
|
212
|
+
urlSlug: string
|
|
213
|
+
displayName: string
|
|
214
|
+
idField: string
|
|
215
|
+
formFields: FieldMeta[]
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface Props {
|
|
219
|
+
model: ModelMeta
|
|
220
|
+
record: Record<string, unknown> | null
|
|
221
|
+
models: Array<{ name: string; urlSlug: string }>
|
|
222
|
+
errors: Record<string, string>
|
|
223
|
+
prefix: string
|
|
224
|
+
relatedOptions: Record<string, Array<{ id: string; label: string }>>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildInitialData(
|
|
228
|
+
formFields: FieldMeta[],
|
|
229
|
+
record: Record<string, unknown> | null,
|
|
230
|
+
): Record<string, string> {
|
|
231
|
+
return Object.fromEntries(
|
|
232
|
+
formFields.map((f) => {
|
|
233
|
+
const value = record?.[f.name]
|
|
234
|
+
if (value instanceof Date) return [f.name, value.toISOString().slice(0, 16)]
|
|
235
|
+
return [f.name, value !== null && value !== undefined ? String(value) : '']
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default function AdminModelForm({ model, record, models, errors, prefix, relatedOptions }: Props) {
|
|
241
|
+
const isEdit = record !== null
|
|
242
|
+
const { data, setData, post, patch, processing } = useForm(
|
|
243
|
+
buildInitialData(model.formFields, record),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
function handleSubmit(e: React.FormEvent) {
|
|
247
|
+
e.preventDefault()
|
|
248
|
+
if (isEdit) {
|
|
249
|
+
patch(\`\${prefix}/\${model.urlSlug}/\${String(record![model.idField])}\`)
|
|
250
|
+
} else {
|
|
251
|
+
post(\`\${prefix}/\${model.urlSlug}\`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderInput(field: FieldMeta) {
|
|
256
|
+
const value = data[field.name] ?? ''
|
|
257
|
+
const error = errors[field.name]
|
|
258
|
+
const baseClass = 'w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
|
259
|
+
const errorClass = error ? ' border-red-500' : ' border-gray-300'
|
|
260
|
+
|
|
261
|
+
if (field.isForeignKey) {
|
|
262
|
+
const options = relatedOptions[field.name] ?? []
|
|
263
|
+
return (
|
|
264
|
+
<select
|
|
265
|
+
value={value}
|
|
266
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
267
|
+
className={baseClass + errorClass}
|
|
268
|
+
>
|
|
269
|
+
{!field.isRequired && <option value="">— select —</option>}
|
|
270
|
+
{options.map((opt) => (
|
|
271
|
+
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
|
272
|
+
))}
|
|
273
|
+
</select>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (field.kind === 'enum' && field.enumValues) {
|
|
278
|
+
return (
|
|
279
|
+
<select
|
|
280
|
+
value={value}
|
|
281
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
282
|
+
className={baseClass + errorClass}
|
|
283
|
+
>
|
|
284
|
+
{!field.isRequired && <option value="">— select —</option>}
|
|
285
|
+
{field.enumValues.map((v) => (
|
|
286
|
+
<option key={v} value={v}>{v}</option>
|
|
287
|
+
))}
|
|
288
|
+
</select>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (field.prismaType === 'Boolean') {
|
|
293
|
+
return (
|
|
294
|
+
<input
|
|
295
|
+
type="checkbox"
|
|
296
|
+
checked={value === 'true'}
|
|
297
|
+
onChange={(e) => setData(field.name, String(e.target.checked))}
|
|
298
|
+
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
299
|
+
/>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const inputType =
|
|
304
|
+
field.prismaType === 'Int' || field.prismaType === 'Float'
|
|
305
|
+
? 'number'
|
|
306
|
+
: field.prismaType === 'DateTime'
|
|
307
|
+
? 'datetime-local'
|
|
308
|
+
: 'text'
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<input
|
|
312
|
+
type={inputType}
|
|
313
|
+
value={value}
|
|
314
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
315
|
+
required={field.isRequired}
|
|
316
|
+
className={baseClass + errorClass}
|
|
317
|
+
/>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div className="min-h-screen bg-gray-50 p-8">
|
|
323
|
+
<div className="max-w-2xl mx-auto">
|
|
324
|
+
<div className="flex items-center gap-4 mb-6">
|
|
325
|
+
<Link
|
|
326
|
+
href={\`\${prefix}/\${model.urlSlug}\`}
|
|
327
|
+
className="text-blue-600 hover:underline text-sm"
|
|
328
|
+
>
|
|
329
|
+
← {model.displayName}
|
|
330
|
+
</Link>
|
|
331
|
+
<h1 className="text-2xl font-bold text-gray-900">
|
|
332
|
+
{isEdit ? \`Edit \${model.name}\` : \`New \${model.name}\`}
|
|
333
|
+
</h1>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 space-y-5">
|
|
337
|
+
{model.formFields.map((field) => (
|
|
338
|
+
<div key={field.name}>
|
|
339
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
340
|
+
{field.name}
|
|
341
|
+
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
|
342
|
+
</label>
|
|
343
|
+
{renderInput(field)}
|
|
344
|
+
{errors[field.name] && (
|
|
345
|
+
<p className="mt-1 text-xs text-red-600">{errors[field.name]}</p>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
))}
|
|
349
|
+
|
|
350
|
+
<div className="flex items-center gap-3 pt-2">
|
|
351
|
+
<button
|
|
352
|
+
type="submit"
|
|
353
|
+
disabled={processing}
|
|
354
|
+
className="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
355
|
+
>
|
|
356
|
+
{processing ? 'Saving…' : isEdit ? 'Update' : 'Create'}
|
|
357
|
+
</button>
|
|
358
|
+
<Link
|
|
359
|
+
href={\`\${prefix}/\${model.urlSlug}\`}
|
|
360
|
+
className="text-gray-600 hover:underline text-sm"
|
|
361
|
+
>
|
|
362
|
+
Cancel
|
|
363
|
+
</Link>
|
|
364
|
+
</div>
|
|
365
|
+
</form>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
`;
|
|
371
|
+
//# sourceMappingURL=templates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"templates.js","sourceRoot":"","sources":["../../src/scaffold/templates.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCjC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyInC,CAAA;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+KlC,CAAA"}
|
|
@@ -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
|
+
}
|