millas 0.2.2 β 0.2.4
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/package.json +1 -1
- package/src/admin/ActivityLog.js +95 -0
- package/src/admin/Admin.js +336 -20
- package/src/admin/resources/AdminResource.js +140 -82
- package/src/admin/views/layouts/base.njk +96 -1
- package/src/admin/views/pages/dashboard.njk +273 -61
- package/src/admin/views/pages/list.njk +19 -0
- package/src/admin/views/pages/search.njk +139 -0
|
@@ -47,8 +47,8 @@ class AdminResource {
|
|
|
47
47
|
/** Singular label */
|
|
48
48
|
static labelSingular = null;
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
static icon = '
|
|
50
|
+
/** SVG icon id without the ic- prefix e.g. 'users', 'file', 'tag' */
|
|
51
|
+
static icon = 'table';
|
|
52
52
|
|
|
53
53
|
/** Records per page */
|
|
54
54
|
static perPage = 20;
|
|
@@ -68,6 +68,16 @@ class AdminResource {
|
|
|
68
68
|
/** Whether to show Delete buttons */
|
|
69
69
|
static canDelete = true;
|
|
70
70
|
|
|
71
|
+
/** Whether to show a detail/view page */
|
|
72
|
+
static canView = true;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fields that appear on the form as read-only (shown as text, not input).
|
|
76
|
+
* Useful for system-managed fields like created_at, updated_at, uuid.
|
|
77
|
+
* @type {string[]}
|
|
78
|
+
*/
|
|
79
|
+
static readonlyFields = [];
|
|
80
|
+
|
|
71
81
|
/** URL-safe slug used in routes */
|
|
72
82
|
static get slug() {
|
|
73
83
|
return (this.label || this.model?.name || 'resource')
|
|
@@ -75,11 +85,11 @@ class AdminResource {
|
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
/**
|
|
78
|
-
* Define the fields shown in list
|
|
88
|
+
* Define the fields shown in list, detail, and form views.
|
|
89
|
+
* Use AdminField.tab('Tab Name') to group fields into tabs on the form.
|
|
79
90
|
* Must return an array of AdminField instances.
|
|
80
91
|
*/
|
|
81
92
|
static fields() {
|
|
82
|
-
// Default: infer from model.fields if available
|
|
83
93
|
if (!this.model?.fields) return [];
|
|
84
94
|
return Object.entries(this.model.fields).map(([name, def]) =>
|
|
85
95
|
AdminField.fromModelField(name, def)
|
|
@@ -87,7 +97,7 @@ class AdminResource {
|
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
/**
|
|
90
|
-
* Define filter controls shown in the
|
|
100
|
+
* Define filter controls shown in the filter panel.
|
|
91
101
|
* Must return an array of AdminFilter instances.
|
|
92
102
|
*/
|
|
93
103
|
static filters() {
|
|
@@ -101,39 +111,42 @@ class AdminResource {
|
|
|
101
111
|
static async fetchList({ page = 1, perPage, search, sort = 'id', order = 'desc', filters = {} } = {}) {
|
|
102
112
|
const limit = perPage || this.perPage;
|
|
103
113
|
const offset = (page - 1) * limit;
|
|
104
|
-
const db = this.model._db();
|
|
105
114
|
|
|
106
|
-
|
|
115
|
+
// Start from the public query() entry point β works regardless of
|
|
116
|
+
// whether the ORM changes have been applied or not.
|
|
117
|
+
let qb = this.model.query().orderBy(sort, order);
|
|
107
118
|
|
|
108
|
-
// Search
|
|
119
|
+
// Search β build OR conditions across all searchable columns
|
|
109
120
|
if (search && this.searchable.length) {
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
const searchable = this.searchable; // capture for closure
|
|
122
|
+
// Use raw knex where group via the underlying _query
|
|
123
|
+
qb._query = qb._query.where(function () {
|
|
124
|
+
for (const col of searchable) {
|
|
112
125
|
this.orWhere(col, 'like', `%${search}%`);
|
|
113
126
|
}
|
|
114
127
|
});
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
// Filters
|
|
130
|
+
// Filters β support __ lookups (e.g. created_at__gte) as well as plain equality
|
|
118
131
|
for (const [key, value] of Object.entries(filters)) {
|
|
119
132
|
if (value !== '' && value !== null && value !== undefined) {
|
|
120
|
-
|
|
133
|
+
qb.where(key, value);
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
136
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
// Run count and data fetch in parallel
|
|
138
|
+
const [rows, total] = await Promise.all([
|
|
139
|
+
qb._query.clone().limit(limit).offset(offset),
|
|
140
|
+
qb._query.clone().clearSelect().count('* as count').first()
|
|
141
|
+
.then(r => Number(r?.count ?? 0)),
|
|
127
142
|
]);
|
|
128
143
|
|
|
129
|
-
const total = Number(countResult?.count || 0);
|
|
130
|
-
|
|
131
144
|
return {
|
|
132
145
|
data: rows.map(r => this.model._hydrate(r)),
|
|
133
146
|
total,
|
|
134
147
|
page: Number(page),
|
|
135
148
|
perPage: limit,
|
|
136
|
-
lastPage: Math.ceil(total / limit),
|
|
149
|
+
lastPage: Math.ceil(total / limit) || 1,
|
|
137
150
|
};
|
|
138
151
|
}
|
|
139
152
|
|
|
@@ -190,47 +203,91 @@ class AdminResource {
|
|
|
190
203
|
|
|
191
204
|
class AdminField {
|
|
192
205
|
constructor(name, type) {
|
|
193
|
-
this._name
|
|
194
|
-
this._type
|
|
195
|
-
this._label
|
|
196
|
-
this._sortable
|
|
197
|
-
this._hidden
|
|
198
|
-
this._listOnly
|
|
199
|
-
this._detailOnly
|
|
200
|
-
this.
|
|
201
|
-
this.
|
|
206
|
+
this._name = name;
|
|
207
|
+
this._type = type;
|
|
208
|
+
this._label = name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
209
|
+
this._sortable = false;
|
|
210
|
+
this._hidden = false;
|
|
211
|
+
this._listOnly = false;
|
|
212
|
+
this._detailOnly = false;
|
|
213
|
+
this._readonly = false;
|
|
214
|
+
this._colors = {};
|
|
215
|
+
this._format = null;
|
|
216
|
+
this._nullable = true; // defaults to optional; use .required() to enforce
|
|
217
|
+
this._tab = null; // tab name this field belongs to
|
|
218
|
+
this._span = null; // 'full' | 'third' | null (default half-width)
|
|
219
|
+
this._min = null;
|
|
220
|
+
this._max = null;
|
|
202
221
|
}
|
|
203
222
|
|
|
204
|
-
// βββ Field types
|
|
205
|
-
|
|
206
|
-
static id(name = 'id')
|
|
207
|
-
static text(name)
|
|
208
|
-
static email(name)
|
|
209
|
-
static number(name)
|
|
210
|
-
static boolean(name)
|
|
211
|
-
static badge(name)
|
|
212
|
-
static datetime(name)
|
|
213
|
-
static date(name)
|
|
214
|
-
static image(name)
|
|
215
|
-
static textarea(name)
|
|
216
|
-
static
|
|
217
|
-
static
|
|
218
|
-
static
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
223
|
+
// βββ Field types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
224
|
+
|
|
225
|
+
static id(name = 'id') { return new AdminField(name, 'id'); }
|
|
226
|
+
static text(name) { return new AdminField(name, 'text'); }
|
|
227
|
+
static email(name) { return new AdminField(name, 'email'); }
|
|
228
|
+
static number(name) { return new AdminField(name, 'number'); }
|
|
229
|
+
static boolean(name) { return new AdminField(name, 'boolean'); }
|
|
230
|
+
static badge(name) { return new AdminField(name, 'badge'); }
|
|
231
|
+
static datetime(name) { return new AdminField(name, 'datetime'); }
|
|
232
|
+
static date(name) { return new AdminField(name, 'date'); }
|
|
233
|
+
static image(name) { return new AdminField(name, 'image'); }
|
|
234
|
+
static textarea(name) { return new AdminField(name, 'textarea'); }
|
|
235
|
+
static password(name) { return new AdminField(name, 'password'); }
|
|
236
|
+
static json(name) { return new AdminField(name, 'json'); }
|
|
237
|
+
static url(name) { return new AdminField(name, 'url'); }
|
|
238
|
+
static phone(name) { return new AdminField(name, 'phone'); }
|
|
239
|
+
static color(name) { return new AdminField(name, 'color'); }
|
|
240
|
+
static richtext(name) { return new AdminField(name, 'richtext'); }
|
|
241
|
+
|
|
242
|
+
static select(name, options) {
|
|
243
|
+
const f = new AdminField(name, 'select');
|
|
244
|
+
f._options = Array.isArray(options)
|
|
245
|
+
? options.map(o => typeof o === 'string' ? { value: o, label: o } : o)
|
|
246
|
+
: [];
|
|
247
|
+
return f;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Tab separator β splits the form into named tabs.
|
|
252
|
+
* All fields after this marker belong to this tab until the next one.
|
|
253
|
+
*
|
|
254
|
+
* static fields() {
|
|
255
|
+
* return [
|
|
256
|
+
* AdminField.tab('General'),
|
|
257
|
+
* AdminField.text('name'),
|
|
258
|
+
* AdminField.tab('Settings'),
|
|
259
|
+
* AdminField.boolean('active'),
|
|
260
|
+
* ];
|
|
261
|
+
* }
|
|
262
|
+
*/
|
|
263
|
+
static tab(label) {
|
|
264
|
+
const f = new AdminField('__tab__', 'tab');
|
|
265
|
+
f._label = label;
|
|
266
|
+
return f;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// βββ Fluent modifiers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
270
|
+
|
|
271
|
+
label(l) { this._label = l; return this; }
|
|
272
|
+
sortable() { this._sortable = true; return this; }
|
|
273
|
+
hidden() { this._hidden = true; return this; }
|
|
274
|
+
listOnly() { this._listOnly = true; return this; }
|
|
275
|
+
detailOnly() { this._detailOnly = true; return this; }
|
|
276
|
+
readonly() { this._readonly = true; return this; }
|
|
277
|
+
nullable() { this._nullable = true; return this; }
|
|
278
|
+
required() { this._nullable = false; return this; }
|
|
279
|
+
full() { this._span = 'full'; return this; }
|
|
280
|
+
third() { this._span = 'third'; return this; }
|
|
281
|
+
colors(map) { this._colors = map; return this; }
|
|
282
|
+
format(fn) { this._format = fn; return this; }
|
|
283
|
+
placeholder(p) { this._placeholder = p; return this; }
|
|
284
|
+
help(h) { this._help = h; return this; }
|
|
285
|
+
min(n) { this._min = n; return this; }
|
|
286
|
+
max(n) { this._max = n; return this; }
|
|
287
|
+
/** Assign this field to a named tab (alternative to using tab() separators). */
|
|
288
|
+
inTab(name) { this._tab = name; return this; }
|
|
289
|
+
|
|
290
|
+
// βββ Serialise βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
234
291
|
|
|
235
292
|
toJSON() {
|
|
236
293
|
return {
|
|
@@ -241,49 +298,50 @@ class AdminField {
|
|
|
241
298
|
hidden: this._hidden,
|
|
242
299
|
listOnly: this._listOnly,
|
|
243
300
|
detailOnly: this._detailOnly,
|
|
301
|
+
readonly: this._readonly,
|
|
302
|
+
nullable: this._nullable,
|
|
244
303
|
colors: this._colors,
|
|
245
304
|
options: this._options,
|
|
246
305
|
placeholder: this._placeholder,
|
|
247
306
|
help: this._help,
|
|
248
|
-
|
|
307
|
+
tab: this._tab,
|
|
308
|
+
span: this._span,
|
|
309
|
+
min: this._min,
|
|
310
|
+
max: this._max,
|
|
249
311
|
};
|
|
250
312
|
}
|
|
251
313
|
|
|
252
|
-
/**
|
|
253
|
-
* Format a raw value for display.
|
|
254
|
-
*/
|
|
314
|
+
/** Format a raw value for display in the detail/list views. */
|
|
255
315
|
display(value) {
|
|
256
316
|
if (this._format) return this._format(value);
|
|
257
317
|
if (value === null || value === undefined) return 'β';
|
|
258
|
-
if (this._type === 'boolean') return value ? '
|
|
259
|
-
if (this._type === 'datetime' && value)
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
if (this._type === 'date' && value) {
|
|
263
|
-
return new Date(value).toLocaleDateString();
|
|
264
|
-
}
|
|
318
|
+
if (this._type === 'boolean') return value ? 'Yes' : 'No';
|
|
319
|
+
if (this._type === 'datetime' && value) return new Date(value).toLocaleString();
|
|
320
|
+
if (this._type === 'date' && value) return new Date(value).toLocaleDateString();
|
|
265
321
|
if (this._type === 'password') return 'β’β’β’β’β’β’β’β’';
|
|
266
|
-
if (this._type === 'json') return JSON.stringify(value);
|
|
322
|
+
if (this._type === 'json') return JSON.stringify(value, null, 2);
|
|
267
323
|
return String(value);
|
|
268
324
|
}
|
|
269
325
|
|
|
270
326
|
static fromModelField(name, fieldDef) {
|
|
271
327
|
const typeMap = {
|
|
272
|
-
id:
|
|
273
|
-
string:
|
|
274
|
-
text:
|
|
275
|
-
integer:
|
|
276
|
-
bigInteger:() => AdminField.number(name),
|
|
277
|
-
float:
|
|
278
|
-
decimal:
|
|
279
|
-
boolean:
|
|
280
|
-
timestamp:
|
|
281
|
-
date:
|
|
282
|
-
enum:
|
|
283
|
-
json:
|
|
328
|
+
id: () => AdminField.id(name),
|
|
329
|
+
string: () => AdminField.text(name),
|
|
330
|
+
text: () => AdminField.textarea(name),
|
|
331
|
+
integer: () => AdminField.number(name),
|
|
332
|
+
bigInteger: () => AdminField.number(name),
|
|
333
|
+
float: () => AdminField.number(name),
|
|
334
|
+
decimal: () => AdminField.number(name),
|
|
335
|
+
boolean: () => AdminField.boolean(name),
|
|
336
|
+
timestamp: () => AdminField.datetime(name),
|
|
337
|
+
date: () => AdminField.date(name),
|
|
338
|
+
enum: () => AdminField.select(name, fieldDef.enumValues || []),
|
|
339
|
+
json: () => AdminField.json(name),
|
|
284
340
|
};
|
|
285
341
|
const fn = typeMap[fieldDef.type] || (() => AdminField.text(name));
|
|
286
|
-
|
|
342
|
+
const field = fn();
|
|
343
|
+
if (fieldDef.nullable) field.nullable();
|
|
344
|
+
return field;
|
|
287
345
|
}
|
|
288
346
|
}
|
|
289
347
|
|
|
@@ -946,6 +946,27 @@
|
|
|
946
946
|
<div id="main">
|
|
947
947
|
<header id="topbar">
|
|
948
948
|
<span class="topbar-title">{% block topbar_title %}{{ pageTitle }}{% endblock %}</span>
|
|
949
|
+
|
|
950
|
+
{# ββ Global search ββ #}
|
|
951
|
+
<form action="{{ adminPrefix }}/search" method="GET" id="global-search-form" style="flex:1;max-width:280px;margin:0 16px">
|
|
952
|
+
<div class="search-wrap">
|
|
953
|
+
<span class="search-icon-inner">
|
|
954
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
955
|
+
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
956
|
+
</svg>
|
|
957
|
+
</span>
|
|
958
|
+
<input
|
|
959
|
+
type="text"
|
|
960
|
+
name="q"
|
|
961
|
+
id="global-search-input"
|
|
962
|
+
placeholder="Search everything⦠(/)"
|
|
963
|
+
class="form-control search-input"
|
|
964
|
+
value="{% if query is defined %}{{ query }}{% endif %}"
|
|
965
|
+
autocomplete="off"
|
|
966
|
+
style="width:100%;font-size:13px">
|
|
967
|
+
</div>
|
|
968
|
+
</form>
|
|
969
|
+
|
|
949
970
|
<div class="topbar-actions">
|
|
950
971
|
{% block topbar_actions %}{% endblock %}
|
|
951
972
|
</div>
|
|
@@ -1074,7 +1095,81 @@
|
|
|
1074
1095
|
form.submit();
|
|
1075
1096
|
}
|
|
1076
1097
|
|
|
1077
|
-
// ββ
|
|
1098
|
+
// ββ Keyboard shortcuts βββββββββββββββββββββββββββββββββββββββ
|
|
1099
|
+
(function() {
|
|
1100
|
+
const prefix = '{{ adminPrefix }}';
|
|
1101
|
+
const resources = [
|
|
1102
|
+
{% for r in resources %}
|
|
1103
|
+
{ slug: '{{ r.slug }}', index: {{ r.index }} }{% if not loop.last %},{% endif %}
|
|
1104
|
+
{% endfor %}
|
|
1105
|
+
];
|
|
1106
|
+
|
|
1107
|
+
let gPressed = false;
|
|
1108
|
+
let gTimer = null;
|
|
1109
|
+
|
|
1110
|
+
document.addEventListener('keydown', function(e) {
|
|
1111
|
+
// Don't fire when typing in inputs
|
|
1112
|
+
const tag = document.activeElement?.tagName;
|
|
1113
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
1114
|
+
// Allow Escape to blur
|
|
1115
|
+
if (e.key === 'Escape') document.activeElement.blur();
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// / β focus global search
|
|
1120
|
+
if (e.key === '/') {
|
|
1121
|
+
e.preventDefault();
|
|
1122
|
+
document.getElementById('global-search-input')?.focus();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// N β new record (only on list pages)
|
|
1127
|
+
if (e.key === 'n' || e.key === 'N') {
|
|
1128
|
+
const newBtn = document.querySelector('a[href*="/create"].btn-primary');
|
|
1129
|
+
if (newBtn) { e.preventDefault(); window.location.href = newBtn.href; }
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Escape β close modals (already handled), clear selection
|
|
1134
|
+
if (e.key === 'Escape') {
|
|
1135
|
+
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// G β start chord
|
|
1140
|
+
if (e.key === 'g' || e.key === 'G') {
|
|
1141
|
+
if (gPressed) return;
|
|
1142
|
+
gPressed = true;
|
|
1143
|
+
clearTimeout(gTimer);
|
|
1144
|
+
gTimer = setTimeout(() => { gPressed = false; }, 800);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// G+D β dashboard
|
|
1149
|
+
if (gPressed && (e.key === 'd' || e.key === 'D')) {
|
|
1150
|
+
gPressed = false;
|
|
1151
|
+
window.location.href = prefix + '/';
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// G+1β¦9 β jump to resource
|
|
1156
|
+
if (gPressed && e.key >= '1' && e.key <= '9') {
|
|
1157
|
+
gPressed = false;
|
|
1158
|
+
const idx = parseInt(e.key);
|
|
1159
|
+
const r = resources.find(x => x.index === idx);
|
|
1160
|
+
if (r) window.location.href = `${prefix}/${r.slug}`;
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
})();
|
|
1165
|
+
|
|
1166
|
+
// ββ Shortcut hint tooltip ββββββββββββββββββββββββββββββββββββ
|
|
1167
|
+
document.getElementById('global-search-input')?.addEventListener('focus', function() {
|
|
1168
|
+
this.placeholder = 'Search everythingβ¦';
|
|
1169
|
+
});
|
|
1170
|
+
document.getElementById('global-search-input')?.addEventListener('blur', function() {
|
|
1171
|
+
this.placeholder = 'Search everything⦠(/)';
|
|
1172
|
+
});
|
|
1078
1173
|
const flashAlert = document.getElementById('flash-alert');
|
|
1079
1174
|
if (flashAlert) {
|
|
1080
1175
|
setTimeout(() => {
|