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.
- package/README.md +4 -7
- package/bin/commands/add-tailwind.js +151 -0
- package/bin/commands/api.js +70 -0
- package/bin/commands/db-make.js +76 -0
- package/bin/commands/db-migrate.js +43 -0
- package/bin/commands/db-rollback.js +48 -0
- package/bin/commands/db-status.js +53 -0
- package/bin/commands/dev.js +73 -0
- package/bin/commands/new.js +634 -0
- package/bin/commands/page.js +134 -0
- package/bin/commands/seed.js +154 -0
- package/bin/commands/start.js +30 -0
- package/bin/utils/db.js +54 -0
- package/bin/utils/migration.js +36 -0
- package/bin/utils/project.js +97 -0
- package/bin/utils/seed.js +112 -0
- package/bin/webspresso.js +24 -1696
- package/core/orm/index.js +14 -1
- package/core/orm/migrations/scaffold.js +5 -0
- package/core/orm/model.js +8 -0
- package/core/orm/schema-helpers.js +39 -1
- package/core/orm/seeder.js +56 -3
- package/core/orm/types.js +28 -1
- package/index.js +2 -1
- package/package.json +1 -1
- package/plugins/admin-panel/admin-user-model.js +42 -0
- package/plugins/admin-panel/api.js +436 -0
- package/plugins/admin-panel/app.js +68 -0
- package/plugins/admin-panel/auth.js +157 -0
- package/plugins/admin-panel/components.js +359 -0
- package/plugins/admin-panel/field-renderers/array.js +57 -0
- package/plugins/admin-panel/field-renderers/basic.js +205 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +124 -0
- package/plugins/admin-panel/field-renderers/index.js +93 -0
- package/plugins/admin-panel/field-renderers/json.js +52 -0
- package/plugins/admin-panel/field-renderers/relations.js +96 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +83 -0
- package/plugins/admin-panel/index.js +187 -0
- package/plugins/admin-panel/migration-template.js +39 -0
- package/plugins/admin-panel/styles.js +9 -0
- 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
|
+
};
|