webspresso 0.0.13 → 0.0.14

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 (41) hide show
  1. package/README.md +4 -7
  2. package/bin/commands/add-tailwind.js +151 -0
  3. package/bin/commands/api.js +70 -0
  4. package/bin/commands/db-make.js +76 -0
  5. package/bin/commands/db-migrate.js +43 -0
  6. package/bin/commands/db-rollback.js +48 -0
  7. package/bin/commands/db-status.js +53 -0
  8. package/bin/commands/dev.js +73 -0
  9. package/bin/commands/new.js +634 -0
  10. package/bin/commands/page.js +134 -0
  11. package/bin/commands/seed.js +154 -0
  12. package/bin/commands/start.js +30 -0
  13. package/bin/utils/db.js +54 -0
  14. package/bin/utils/migration.js +36 -0
  15. package/bin/utils/project.js +97 -0
  16. package/bin/utils/seed.js +112 -0
  17. package/bin/webspresso.js +24 -1696
  18. package/core/orm/index.js +14 -1
  19. package/core/orm/migrations/scaffold.js +5 -0
  20. package/core/orm/model.js +8 -0
  21. package/core/orm/schema-helpers.js +39 -1
  22. package/core/orm/seeder.js +56 -3
  23. package/core/orm/types.js +28 -1
  24. package/index.js +2 -1
  25. package/package.json +1 -1
  26. package/plugins/admin-panel/admin-user-model.js +42 -0
  27. package/plugins/admin-panel/api.js +436 -0
  28. package/plugins/admin-panel/app.js +68 -0
  29. package/plugins/admin-panel/auth.js +157 -0
  30. package/plugins/admin-panel/components.js +359 -0
  31. package/plugins/admin-panel/field-renderers/array.js +57 -0
  32. package/plugins/admin-panel/field-renderers/basic.js +205 -0
  33. package/plugins/admin-panel/field-renderers/file-upload.js +124 -0
  34. package/plugins/admin-panel/field-renderers/index.js +93 -0
  35. package/plugins/admin-panel/field-renderers/json.js +52 -0
  36. package/plugins/admin-panel/field-renderers/relations.js +96 -0
  37. package/plugins/admin-panel/field-renderers/rich-text.js +83 -0
  38. package/plugins/admin-panel/index.js +187 -0
  39. package/plugins/admin-panel/migration-template.js +39 -0
  40. package/plugins/admin-panel/styles.js +9 -0
  41. package/plugins/index.js +2 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * File Upload Field Renderer
3
+ * Droppable file upload component
4
+ */
5
+
6
+ module.exports = {
7
+ FileUploadField: {
8
+ oncreate: (vnode) => {
9
+ const { name, value = '', onchange, meta = {} } = vnode.attrs;
10
+ const dropZoneId = 'drop-zone-' + name;
11
+
12
+ const dropZone = document.getElementById(dropZoneId);
13
+ if (!dropZone) return;
14
+
15
+ // Prevent default drag behaviors
16
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
17
+ dropZone.addEventListener(eventName, (e) => {
18
+ e.preventDefault();
19
+ e.stopPropagation();
20
+ });
21
+ });
22
+
23
+ // Highlight drop zone when item is dragged over it
24
+ ['dragenter', 'dragover'].forEach(eventName => {
25
+ dropZone.addEventListener(eventName, () => {
26
+ dropZone.classList.add('border-blue-500', 'bg-blue-50');
27
+ });
28
+ });
29
+
30
+ ['dragleave', 'drop'].forEach(eventName => {
31
+ dropZone.addEventListener(eventName, () => {
32
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50');
33
+ });
34
+ });
35
+
36
+ // Handle dropped files
37
+ dropZone.addEventListener('drop', (e) => {
38
+ const files = e.dataTransfer.files;
39
+ if (files.length > 0) {
40
+ handleFile(files[0], vnode, meta);
41
+ }
42
+ });
43
+
44
+ // Handle file input change
45
+ const fileInput = dropZone.querySelector('input[type=file]');
46
+ if (fileInput) {
47
+ fileInput.addEventListener('change', (e) => {
48
+ if (e.target.files.length > 0) {
49
+ handleFile(e.target.files[0], vnode, meta);
50
+ }
51
+ });
52
+ }
53
+ },
54
+
55
+ view: (vnode) => {
56
+ const { name, value = '', meta = {}, required = false } = vnode.attrs;
57
+ const dropZoneId = 'drop-zone-' + name;
58
+ const maxSize = meta.maxSize || 5 * 1024 * 1024; // 5MB default
59
+ const accept = meta.accept || '*/*';
60
+
61
+ return m('.mb-4', [
62
+ m('label.block.text-sm.font-medium.mb-2',
63
+ meta.label || name,
64
+ required ? m('span.text-red-500', ' *') : null
65
+ ),
66
+ m('div#drop-zone-' + name + '.border-2.border-dashed.border-gray-300.rounded.p-8.text-center', {
67
+ style: 'cursor: pointer;'
68
+ }, [
69
+ m('input[type=file]', {
70
+ class: 'hidden',
71
+ id: 'file-input-' + name,
72
+ accept,
73
+ onchange: (e) => {
74
+ if (e.target.files.length > 0) {
75
+ handleFile(e.target.files[0], vnode, meta);
76
+ }
77
+ }
78
+ }),
79
+ m('div', [
80
+ m('p.text-gray-600.mb-2', 'Drag and drop a file here, or'),
81
+ m('label.text-blue-600.hover:text-blue-800.cursor-pointer', {
82
+ for: 'file-input-' + name
83
+ }, 'browse'),
84
+ ]),
85
+ value ? m('.mt-4', [
86
+ m('p.text-sm.text-gray-600', 'Current file: ' + (typeof value === 'string' ? value : value.name || 'uploaded')),
87
+ m('button.text-red-600.hover:text-red-800.text-sm.mt-2', {
88
+ onclick: () => {
89
+ if (vnode.attrs.onchange) {
90
+ vnode.attrs.onchange('');
91
+ }
92
+ }
93
+ }, 'Remove'),
94
+ ]) : null,
95
+ ]),
96
+ m('input[type=hidden]', {
97
+ name,
98
+ value: typeof value === 'string' ? value : '',
99
+ }),
100
+ ]);
101
+ }
102
+ },
103
+ };
104
+
105
+ /**
106
+ * Handle file upload
107
+ * @param {File} file - File object
108
+ * @param {Object} vnode - Mithril vnode
109
+ * @param {Object} meta - Field metadata
110
+ */
111
+ function handleFile(file, vnode, meta) {
112
+ const maxSize = meta.maxSize || 5 * 1024 * 1024;
113
+
114
+ if (file.size > maxSize) {
115
+ alert(`File size exceeds maximum allowed size of ${Math.round(maxSize / 1024 / 1024)}MB`);
116
+ return;
117
+ }
118
+
119
+ // For now, just store the file name
120
+ // In a real implementation, you'd upload to server and get URL
121
+ if (vnode.attrs.onchange) {
122
+ vnode.attrs.onchange(file.name);
123
+ }
124
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Field Renderer Registry
3
+ * Registry for field renderer components
4
+ */
5
+
6
+ const basicRenderers = require('./basic');
7
+ const arrayRenderer = require('./array');
8
+ const jsonRenderer = require('./json');
9
+ const richTextRenderer = require('./rich-text');
10
+ const fileUploadRenderer = require('./file-upload');
11
+ const relationRenderers = require('./relations');
12
+
13
+ /**
14
+ * Field renderer registry
15
+ */
16
+ const registry = new Map();
17
+
18
+ /**
19
+ * Register a field renderer
20
+ * @param {string} type - Field type (e.g., 'string', 'integer', 'rich-text')
21
+ * @param {Function} component - Mithril component function
22
+ */
23
+ function registerFieldRenderer(type, component) {
24
+ registry.set(type, component);
25
+ }
26
+
27
+ /**
28
+ * Get field renderer for a column
29
+ * @param {Object} columnMeta - Column metadata
30
+ * @param {Object} modelMeta - Model metadata (for custom fields)
31
+ * @returns {Function|null} Mithril component or null
32
+ */
33
+ function getFieldRenderer(columnMeta, modelMeta = {}) {
34
+ const { name, type, customField } = columnMeta;
35
+
36
+ // Check for custom field first
37
+ if (customField && customField.type) {
38
+ const customRenderer = registry.get(customField.type);
39
+ if (customRenderer) {
40
+ return customRenderer;
41
+ }
42
+ }
43
+
44
+ // Check for standard type
45
+ const standardRenderer = registry.get(type);
46
+ if (standardRenderer) {
47
+ return standardRenderer;
48
+ }
49
+
50
+ // Fallback to text
51
+ return registry.get('text') || registry.get('string');
52
+ }
53
+
54
+ /**
55
+ * Initialize default renderers
56
+ */
57
+ function initializeDefaultRenderers() {
58
+ // Basic types
59
+ registerFieldRenderer('string', basicRenderers.TextField);
60
+ registerFieldRenderer('text', basicRenderers.TextAreaField);
61
+ registerFieldRenderer('integer', basicRenderers.NumberField);
62
+ registerFieldRenderer('bigint', basicRenderers.NumberField);
63
+ registerFieldRenderer('float', basicRenderers.NumberField);
64
+ registerFieldRenderer('decimal', basicRenderers.NumberField);
65
+ registerFieldRenderer('boolean', basicRenderers.BooleanField);
66
+ registerFieldRenderer('date', basicRenderers.DateField);
67
+ registerFieldRenderer('datetime', basicRenderers.DateTimeField);
68
+ registerFieldRenderer('timestamp', basicRenderers.DateTimeField);
69
+ registerFieldRenderer('enum', basicRenderers.SelectField);
70
+ registerFieldRenderer('uuid', basicRenderers.TextField);
71
+ registerFieldRenderer('id', basicRenderers.NumberField);
72
+
73
+ // Complex types
74
+ registerFieldRenderer('array', arrayRenderer.ArrayField);
75
+ registerFieldRenderer('json', jsonRenderer.JsonField);
76
+
77
+ // Custom field types
78
+ registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
79
+ registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
80
+
81
+ // Relation types
82
+ registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);
83
+ registerFieldRenderer('hasMany', relationRenderers.HasManyField);
84
+ }
85
+
86
+ // Initialize on load
87
+ initializeDefaultRenderers();
88
+
89
+ module.exports = {
90
+ registerFieldRenderer,
91
+ getFieldRenderer,
92
+ registry,
93
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * JSON Field Renderer
3
+ * Component for editing JSON fields
4
+ */
5
+
6
+ module.exports = {
7
+ JsonField: {
8
+ view: (vnode) => {
9
+ const { name, value = {}, meta = {}, required = false } = vnode.attrs;
10
+ let jsonString = '';
11
+ let parseError = null;
12
+
13
+ try {
14
+ jsonString = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
15
+ } catch (e) {
16
+ jsonString = String(value);
17
+ parseError = 'Invalid JSON';
18
+ }
19
+
20
+ return m('.mb-4', [
21
+ m('label.block.text-sm.font-medium.mb-2',
22
+ meta.label || name,
23
+ required ? m('span.text-red-500', ' *') : null
24
+ ),
25
+ m('textarea.w-full.px-3.py-2.border.border-gray-300.rounded.font-mono.text-sm', {
26
+ rows: 10,
27
+ value: jsonString,
28
+ oninput: (e) => {
29
+ const newValue = e.target.value;
30
+ try {
31
+ const parsed = JSON.parse(newValue);
32
+ parseError = null;
33
+ if (vnode.attrs.onchange) {
34
+ vnode.attrs.onchange(parsed);
35
+ }
36
+ } catch (err) {
37
+ parseError = 'Invalid JSON';
38
+ if (vnode.attrs.onchange) {
39
+ vnode.attrs.onchange(newValue);
40
+ }
41
+ }
42
+ }
43
+ }),
44
+ parseError ? m('.text-red-600.text-sm.mt-1', parseError) : null,
45
+ m('input[type=hidden]', {
46
+ name,
47
+ value: typeof value === 'string' ? value : JSON.stringify(value),
48
+ }),
49
+ ]);
50
+ }
51
+ },
52
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Relation Field Renderers
3
+ * Components for editing relation fields (belongsTo, hasMany)
4
+ */
5
+
6
+ module.exports = {
7
+ /**
8
+ * BelongsTo relation field (dropdown)
9
+ */
10
+ BelongsToField: {
11
+ view: (vnode) => {
12
+ const { name, value = null, meta = {}, relationData = [], required = false } = vnode.attrs;
13
+ const displayKey = meta.displayKey || 'id';
14
+ const valueKey = meta.valueKey || 'id';
15
+
16
+ return m('.mb-4', [
17
+ m('label.block.text-sm.font-medium.mb-2',
18
+ meta.label || name,
19
+ required ? m('span.text-red-500', ' *') : null
20
+ ),
21
+ m('select.w-full.px-3.py-2.border.border-gray-300.rounded', {
22
+ name,
23
+ value: value ? String(value) : '',
24
+ required,
25
+ onchange: (e) => {
26
+ const selectedValue = e.target.value === '' ? null : e.target.value;
27
+ if (vnode.attrs.onchange) {
28
+ vnode.attrs.onchange(selectedValue);
29
+ }
30
+ }
31
+ }, [
32
+ !required ? m('option', { value: '' }, '-- None --') : null,
33
+ ...relationData.map(item => {
34
+ const itemValue = item[valueKey];
35
+ const itemDisplay = item[displayKey] || String(itemValue);
36
+ return m('option', {
37
+ value: String(itemValue),
38
+ selected: value && String(value) === String(itemValue)
39
+ }, itemDisplay);
40
+ }),
41
+ ]),
42
+ ]);
43
+ }
44
+ },
45
+
46
+ /**
47
+ * HasMany relation field (multi-select)
48
+ */
49
+ HasManyField: {
50
+ view: (vnode) => {
51
+ const { name, value = [], meta = {}, relationData = [], required = false } = vnode.attrs;
52
+ const selectedIds = Array.isArray(value) ? value.map(v => String(v)) : [];
53
+ const displayKey = meta.displayKey || 'id';
54
+ const valueKey = meta.valueKey || 'id';
55
+
56
+ return m('.mb-4', [
57
+ m('label.block.text-sm.font-medium.mb-2',
58
+ meta.label || name,
59
+ required ? m('span.text-red-500', ' *') : null
60
+ ),
61
+ m('.border.border-gray-300.rounded.p-4.max-h-64.overflow-y-auto', [
62
+ relationData.map(item => {
63
+ const itemValue = String(item[valueKey]);
64
+ const itemDisplay = item[displayKey] || itemValue;
65
+ const isSelected = selectedIds.includes(itemValue);
66
+
67
+ return m('label.flex.items-center.mb-2', [
68
+ m('input.mr-2', {
69
+ type: 'checkbox',
70
+ checked: isSelected,
71
+ onchange: (e) => {
72
+ let newSelected = [...selectedIds];
73
+ if (e.target.checked) {
74
+ if (!newSelected.includes(itemValue)) {
75
+ newSelected.push(itemValue);
76
+ }
77
+ } else {
78
+ newSelected = newSelected.filter(id => id !== itemValue);
79
+ }
80
+ if (vnode.attrs.onchange) {
81
+ vnode.attrs.onchange(newSelected);
82
+ }
83
+ }
84
+ }),
85
+ m('span', itemDisplay),
86
+ ]);
87
+ }),
88
+ ]),
89
+ m('input[type=hidden]', {
90
+ name,
91
+ value: JSON.stringify(selectedIds),
92
+ }),
93
+ ]);
94
+ }
95
+ },
96
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Rich Text Editor Field Renderer
3
+ * Uses Quill editor (CDN)
4
+ */
5
+
6
+ module.exports = {
7
+ RichTextField: {
8
+ oncreate: (vnode) => {
9
+ const { name, value = '', onchange } = vnode.attrs;
10
+
11
+ // Load Quill if not already loaded
12
+ if (typeof window.Quill === 'undefined') {
13
+ const link = document.createElement('link');
14
+ link.rel = 'stylesheet';
15
+ link.href = 'https://cdn.quilljs.com/1.3.6/quill.snow.css';
16
+ document.head.appendChild(link);
17
+
18
+ const script = document.createElement('script');
19
+ script.src = 'https://cdn.quilljs.com/1.3.6/quill.js';
20
+ script.onload = () => {
21
+ initEditor(vnode);
22
+ };
23
+ document.head.appendChild(script);
24
+ } else {
25
+ initEditor(vnode);
26
+ }
27
+
28
+ function initEditor(vnode) {
29
+ const editorId = 'quill-editor-' + name;
30
+ const editorEl = document.getElementById(editorId);
31
+ if (editorEl && !editorEl._quill) {
32
+ const quill = new window.Quill(editorEl, {
33
+ theme: 'snow',
34
+ modules: {
35
+ toolbar: [
36
+ [{ 'header': [1, 2, 3, false] }],
37
+ ['bold', 'italic', 'underline', 'strike'],
38
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }],
39
+ ['link', 'image'],
40
+ ['clean']
41
+ ]
42
+ }
43
+ });
44
+
45
+ // Set initial content
46
+ if (value) {
47
+ quill.root.innerHTML = value;
48
+ }
49
+
50
+ // Handle content changes
51
+ quill.on('text-change', () => {
52
+ const content = quill.root.innerHTML;
53
+ if (onchange) {
54
+ onchange(content);
55
+ }
56
+ });
57
+
58
+ editorEl._quill = quill;
59
+ }
60
+ }
61
+ },
62
+
63
+ view: (vnode) => {
64
+ const { name, meta = {}, required = false } = vnode.attrs;
65
+ const editorId = 'quill-editor-' + name;
66
+
67
+ return m('.mb-4', [
68
+ m('label.block.text-sm.font-medium.mb-2',
69
+ meta.label || name,
70
+ required ? m('span.text-red-500', ' *') : null
71
+ ),
72
+ m('div.border.border-gray-300.rounded', {
73
+ id: editorId,
74
+ style: 'min-height: 200px;'
75
+ }),
76
+ m('input[type=hidden]', {
77
+ name,
78
+ id: name + '-value',
79
+ }),
80
+ ]);
81
+ }
82
+ },
83
+ };
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Admin Panel Plugin
3
+ * Lightweight CRUD admin panel for Webspresso models
4
+ * @module plugins/admin-panel
5
+ */
6
+
7
+ const { createAdminUserModel } = require('./admin-user-model');
8
+ const { generateAdminUsersMigration } = require('./migration-template');
9
+ const { createApiHandlers } = require('./api');
10
+ const { requireAuth, optionalAuth } = require('./auth');
11
+
12
+ /**
13
+ * Admin Panel Plugin Factory
14
+ * @param {Object} options - Plugin options
15
+ * @param {string} [options.path='/_admin'] - Admin panel path
16
+ * @param {boolean} [options.enabled=true] - Enable/disable plugin
17
+ * @param {string} [options.sessionSecret] - Session secret (default: random)
18
+ * @param {Object} [options.db] - Database instance (required)
19
+ * @returns {Object} Plugin definition
20
+ */
21
+ function adminPanelPlugin(options = {}) {
22
+ const {
23
+ path: adminPath = '/_admin',
24
+ enabled = true,
25
+ sessionSecret,
26
+ db,
27
+ } = options;
28
+
29
+ // Validate required options
30
+ if (!db) {
31
+ throw new Error('Admin panel plugin requires a database instance. Pass `db` in options.');
32
+ }
33
+
34
+ // Check for peer dependencies
35
+ let session = null;
36
+ let bcrypt = null;
37
+
38
+ try {
39
+ session = require('express-session');
40
+ } catch (e) {
41
+ throw new Error(
42
+ 'Admin panel plugin requires express-session. Install it with: npm install express-session'
43
+ );
44
+ }
45
+
46
+ try {
47
+ bcrypt = require('bcrypt');
48
+ } catch (e) {
49
+ throw new Error(
50
+ 'Admin panel plugin requires bcrypt. Install it with: npm install bcrypt'
51
+ );
52
+ }
53
+
54
+ return {
55
+ name: 'admin-panel',
56
+ version: '1.0.0',
57
+ description: 'Lightweight CRUD admin panel for Webspresso models',
58
+ enabled,
59
+
60
+ /**
61
+ * Register hook - called when plugin is registered
62
+ */
63
+ async register(ctx) {
64
+ // Create and register AdminUser model
65
+ const AdminUser = createAdminUserModel();
66
+
67
+ // Store in plugin context for later use
68
+ this._adminUser = AdminUser;
69
+ this._db = db;
70
+ this._bcrypt = bcrypt;
71
+ this._session = session;
72
+ this._adminPath = adminPath;
73
+ },
74
+
75
+ /**
76
+ * Routes ready hook - called after routes are mounted
77
+ */
78
+ onRoutesReady(ctx) {
79
+ if (!this.enabled) {
80
+ return;
81
+ }
82
+
83
+ const { app } = ctx;
84
+ const AdminUser = this._adminUser;
85
+ const db = this._db;
86
+ const bcrypt = this._bcrypt;
87
+ const session = this._session;
88
+
89
+ // Setup session middleware
90
+ const sessionSecret = options.sessionSecret || process.env.SESSION_SECRET || 'webspresso-admin-secret-change-in-production';
91
+
92
+ app.use(session({
93
+ secret: sessionSecret,
94
+ resave: false,
95
+ saveUninitialized: false,
96
+ cookie: {
97
+ secure: process.env.NODE_ENV === 'production',
98
+ httpOnly: true,
99
+ maxAge: 24 * 60 * 60 * 1000, // 24 hours
100
+ },
101
+ }));
102
+
103
+ // Create API handlers
104
+ const apiHandlers = createApiHandlers({
105
+ path: adminPath,
106
+ db,
107
+ AdminUser,
108
+ hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
109
+ comparePassword: (password, hash) => bcrypt.compare(password, hash),
110
+ });
111
+
112
+ // Auth API routes (no auth required)
113
+ ctx.addRoute('get', `${adminPath}/api/auth/check`, apiHandlers.checkHandler);
114
+ ctx.addRoute('post', `${adminPath}/api/auth/setup`, apiHandlers.setupHandler);
115
+ ctx.addRoute('post', `${adminPath}/api/auth/login`, apiHandlers.loginHandler);
116
+ ctx.addRoute('post', `${adminPath}/api/auth/logout`, requireAuth, apiHandlers.logoutHandler);
117
+ ctx.addRoute('get', `${adminPath}/api/auth/me`, requireAuth, apiHandlers.meHandler);
118
+
119
+ // Model API routes (auth required)
120
+ ctx.addRoute('get', `${adminPath}/api/models`, requireAuth, apiHandlers.modelsHandler);
121
+ ctx.addRoute('get', `${adminPath}/api/models/:model`, requireAuth, apiHandlers.modelHandler);
122
+
123
+ // Record API routes (auth required)
124
+ ctx.addRoute('get', `${adminPath}/api/models/:model/records`, requireAuth, apiHandlers.recordsListHandler);
125
+ ctx.addRoute('get', `${adminPath}/api/models/:model/records/:id`, requireAuth, apiHandlers.recordHandler);
126
+ ctx.addRoute('post', `${adminPath}/api/models/:model/records`, requireAuth, apiHandlers.createRecordHandler);
127
+ ctx.addRoute('put', `${adminPath}/api/models/:model/records/:id`, requireAuth, apiHandlers.updateRecordHandler);
128
+ ctx.addRoute('delete', `${adminPath}/api/models/:model/records/:id`, requireAuth, apiHandlers.deleteRecordHandler);
129
+
130
+ // Relation API routes (auth required)
131
+ ctx.addRoute('get', `${adminPath}/api/models/:model/relations/:relation`, requireAuth, apiHandlers.relationHandler);
132
+
133
+ // Query API routes (auth required)
134
+ ctx.addRoute('get', `${adminPath}/api/models/:model/queries/:query`, requireAuth, apiHandlers.queryHandler);
135
+
136
+ // Admin panel HTML endpoint (optional auth - frontend handles routing)
137
+ ctx.addRoute('get', adminPath, optionalAuth, (req, res) => {
138
+ // This will be handled by Mithril SPA
139
+ // For now, return a simple HTML that loads the SPA
140
+ res.type('text/html');
141
+ res.send(generateAdminPanelHtml(adminPath));
142
+ });
143
+
144
+ // Log admin panel URL
145
+ console.log(`\nšŸ” Admin Panel available at: http://localhost:${process.env.PORT || 3000}${adminPath}\n`);
146
+ },
147
+
148
+ /**
149
+ * Get migration template for admin_users table
150
+ */
151
+ getMigrationTemplate() {
152
+ return generateAdminUsersMigration();
153
+ },
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Generate admin panel HTML
159
+ * @param {string} adminPath - Admin panel path
160
+ * @returns {string} HTML content
161
+ */
162
+ function generateAdminPanelHtml(adminPath) {
163
+ const appScript = require('./app');
164
+
165
+ return `<!DOCTYPE html>
166
+ <html lang="en">
167
+ <head>
168
+ <meta charset="UTF-8">
169
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
170
+ <title>Admin Panel</title>
171
+ <script src="https://unpkg.com/mithril/mithril.js"></script>
172
+ <script src="https://cdn.tailwindcss.com"></script>
173
+ <style>
174
+ body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
175
+ </style>
176
+ </head>
177
+ <body>
178
+ <div id="app"></div>
179
+ <script>
180
+ window.__ADMIN_PATH__ = ${JSON.stringify(adminPath)};
181
+ </script>
182
+ <script>${appScript}</script>
183
+ </body>
184
+ </html>`;
185
+ }
186
+
187
+ module.exports = adminPanelPlugin;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Migration Template for Admin Users Table
3
+ * @module plugins/admin-panel/migration-template
4
+ */
5
+
6
+ /**
7
+ * Generate migration content for admin_users table
8
+ * @returns {string} Migration file content
9
+ */
10
+ function generateAdminUsersMigration() {
11
+ return `/**
12
+ * Migration: Create admin_users table
13
+ * Auto-generated by admin panel plugin
14
+ */
15
+
16
+ exports.up = function(knex) {
17
+ return knex.schema.createTable('admin_users', (table) => {
18
+ table.bigIncrements('id').primary();
19
+ table.string('email', 255).notNullable().unique();
20
+ table.string('password', 255).notNullable();
21
+ table.string('name', 255).notNullable();
22
+ table.string('role', 50).defaultTo('admin');
23
+ table.boolean('active').defaultTo(true);
24
+ table.timestamp('created_at').defaultTo(knex.fn.now());
25
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
26
+
27
+ table.index(['email']);
28
+ });
29
+ };
30
+
31
+ exports.down = function(knex) {
32
+ return knex.schema.dropTableIfExists('admin_users');
33
+ };
34
+ `;
35
+ }
36
+
37
+ module.exports = {
38
+ generateAdminUsersMigration,
39
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Admin Panel Styles
3
+ * Additional Tailwind CSS styles for admin panel
4
+ */
5
+
6
+ module.exports = `
7
+ /* Additional styles can be added here if needed */
8
+ /* Most styling is handled by Tailwind CSS classes */
9
+ `;