millas 0.1.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/README.md +137 -0
- package/bin/millas.js +6 -0
- package/package.json +56 -0
- package/src/admin/Admin.js +617 -0
- package/src/admin/index.js +13 -0
- package/src/admin/resources/AdminResource.js +317 -0
- package/src/auth/Auth.js +254 -0
- package/src/auth/AuthController.js +188 -0
- package/src/auth/AuthMiddleware.js +67 -0
- package/src/auth/Hasher.js +51 -0
- package/src/auth/JwtDriver.js +74 -0
- package/src/auth/RoleMiddleware.js +44 -0
- package/src/cache/Cache.js +231 -0
- package/src/cache/drivers/FileDriver.js +152 -0
- package/src/cache/drivers/MemoryDriver.js +158 -0
- package/src/cache/drivers/NullDriver.js +27 -0
- package/src/cache/index.js +8 -0
- package/src/cli.js +27 -0
- package/src/commands/make.js +61 -0
- package/src/commands/migrate.js +174 -0
- package/src/commands/new.js +50 -0
- package/src/commands/queue.js +92 -0
- package/src/commands/route.js +93 -0
- package/src/commands/serve.js +50 -0
- package/src/container/Application.js +177 -0
- package/src/container/Container.js +281 -0
- package/src/container/index.js +13 -0
- package/src/controller/Controller.js +367 -0
- package/src/errors/HttpError.js +29 -0
- package/src/events/Event.js +39 -0
- package/src/events/EventEmitter.js +151 -0
- package/src/events/Listener.js +46 -0
- package/src/events/index.js +15 -0
- package/src/index.js +93 -0
- package/src/mail/Mail.js +210 -0
- package/src/mail/MailMessage.js +196 -0
- package/src/mail/TemplateEngine.js +150 -0
- package/src/mail/drivers/LogDriver.js +36 -0
- package/src/mail/drivers/MailgunDriver.js +84 -0
- package/src/mail/drivers/SendGridDriver.js +97 -0
- package/src/mail/drivers/SmtpDriver.js +67 -0
- package/src/mail/index.js +19 -0
- package/src/middleware/AuthMiddleware.js +46 -0
- package/src/middleware/CorsMiddleware.js +59 -0
- package/src/middleware/LogMiddleware.js +61 -0
- package/src/middleware/Middleware.js +36 -0
- package/src/middleware/MiddlewarePipeline.js +94 -0
- package/src/middleware/ThrottleMiddleware.js +61 -0
- package/src/orm/drivers/DatabaseManager.js +135 -0
- package/src/orm/fields/index.js +132 -0
- package/src/orm/index.js +19 -0
- package/src/orm/migration/MigrationRunner.js +216 -0
- package/src/orm/migration/ModelInspector.js +338 -0
- package/src/orm/migration/SchemaBuilder.js +173 -0
- package/src/orm/model/Model.js +371 -0
- package/src/orm/query/QueryBuilder.js +197 -0
- package/src/providers/AdminServiceProvider.js +40 -0
- package/src/providers/AuthServiceProvider.js +53 -0
- package/src/providers/CacheStorageServiceProvider.js +71 -0
- package/src/providers/DatabaseServiceProvider.js +45 -0
- package/src/providers/EventServiceProvider.js +34 -0
- package/src/providers/MailServiceProvider.js +51 -0
- package/src/providers/ProviderRegistry.js +82 -0
- package/src/providers/QueueServiceProvider.js +52 -0
- package/src/providers/ServiceProvider.js +45 -0
- package/src/queue/Job.js +135 -0
- package/src/queue/Queue.js +147 -0
- package/src/queue/drivers/DatabaseDriver.js +194 -0
- package/src/queue/drivers/SyncDriver.js +72 -0
- package/src/queue/index.js +16 -0
- package/src/queue/workers/QueueWorker.js +140 -0
- package/src/router/MiddlewareRegistry.js +82 -0
- package/src/router/Route.js +255 -0
- package/src/router/RouteGroup.js +19 -0
- package/src/router/RouteRegistry.js +55 -0
- package/src/router/Router.js +138 -0
- package/src/router/index.js +15 -0
- package/src/scaffold/generator.js +34 -0
- package/src/scaffold/maker.js +272 -0
- package/src/scaffold/templates.js +350 -0
- package/src/storage/Storage.js +170 -0
- package/src/storage/drivers/LocalDriver.js +215 -0
- package/src/storage/index.js +6 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AdminResource, AdminField, AdminFilter } = require('./resources/AdminResource');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Admin
|
|
7
|
+
*
|
|
8
|
+
* The Millas admin panel registry and HTTP handler.
|
|
9
|
+
*
|
|
10
|
+
* Usage in AppServiceProvider.boot():
|
|
11
|
+
*
|
|
12
|
+
* const { Admin } = require('millas/src');
|
|
13
|
+
*
|
|
14
|
+
* Admin.register(UserResource);
|
|
15
|
+
* Admin.register(PostResource);
|
|
16
|
+
* Admin.register(OrderResource);
|
|
17
|
+
*
|
|
18
|
+
* Then mount in bootstrap/app.js:
|
|
19
|
+
*
|
|
20
|
+
* Admin.mount(app, expressApp);
|
|
21
|
+
*
|
|
22
|
+
* Access at: http://localhost:3000/admin
|
|
23
|
+
*/
|
|
24
|
+
class Admin {
|
|
25
|
+
constructor() {
|
|
26
|
+
this._resources = new Map(); // slug → AdminResource class
|
|
27
|
+
this._config = {
|
|
28
|
+
prefix: '/admin',
|
|
29
|
+
title: 'Millas Admin',
|
|
30
|
+
auth: false, // set to a middleware fn to protect
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
configure(config = {}) {
|
|
37
|
+
Object.assign(this._config, config);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Registration ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register a resource (AdminResource subclass or raw Model).
|
|
45
|
+
*
|
|
46
|
+
* Admin.register(UserResource)
|
|
47
|
+
* Admin.register(User) — auto-generates a basic resource
|
|
48
|
+
*/
|
|
49
|
+
register(ResourceOrModel) {
|
|
50
|
+
let Resource = ResourceOrModel;
|
|
51
|
+
|
|
52
|
+
// Auto-wrap plain Model classes
|
|
53
|
+
if (!ResourceOrModel.prototype && ResourceOrModel.fields !== undefined) {
|
|
54
|
+
Resource = this._autoResource(ResourceOrModel);
|
|
55
|
+
} else if (!(ResourceOrModel.prototype instanceof AdminResource) &&
|
|
56
|
+
ResourceOrModel !== AdminResource) {
|
|
57
|
+
// It's a Model class (has static fields property)
|
|
58
|
+
if (ResourceOrModel.fields !== undefined) {
|
|
59
|
+
Resource = this._autoResource(ResourceOrModel);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const slug = Resource.slug || Resource._getLabel?.().toLowerCase() || Resource.name.toLowerCase();
|
|
64
|
+
this._resources.set(slug, Resource);
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register multiple resources at once.
|
|
70
|
+
*/
|
|
71
|
+
registerMany(resources = []) {
|
|
72
|
+
resources.forEach(r => this.register(r));
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get all registered resources.
|
|
78
|
+
*/
|
|
79
|
+
resources() {
|
|
80
|
+
return [...this._resources.values()];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Mount ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Mount admin routes onto the Millas Route instance.
|
|
87
|
+
* Call this in routes/api.js or bootstrap/app.js.
|
|
88
|
+
*
|
|
89
|
+
* Admin.mount(route, expressApp);
|
|
90
|
+
*/
|
|
91
|
+
mount(route, expressApp) {
|
|
92
|
+
const prefix = this._config.prefix;
|
|
93
|
+
const admin = this;
|
|
94
|
+
|
|
95
|
+
// Serve the admin HTML shell (SPA-style)
|
|
96
|
+
expressApp.get(`${prefix}`, (req, res) => this._serveShell(req, res));
|
|
97
|
+
expressApp.get(`${prefix}/*`, (req, res) => this._serveShell(req, res));
|
|
98
|
+
|
|
99
|
+
// JSON API for the panel
|
|
100
|
+
route.group({ prefix: `${prefix}/api` }, () => {
|
|
101
|
+
|
|
102
|
+
// Meta: list all resources for sidebar
|
|
103
|
+
route.get('/resources', (req, res) => {
|
|
104
|
+
res.json(admin.resources().map(r => ({
|
|
105
|
+
slug: r.slug,
|
|
106
|
+
label: r._getLabel ? r._getLabel() : r.label,
|
|
107
|
+
singular: r._getLabelSingular ? r._getLabelSingular() : r.labelSingular,
|
|
108
|
+
icon: r.icon,
|
|
109
|
+
canCreate: r.canCreate,
|
|
110
|
+
})));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Per-resource CRUD API
|
|
114
|
+
route.get('/:resource', (req, res) => admin._list(req, res));
|
|
115
|
+
route.get('/:resource/:id', (req, res) => admin._show(req, res));
|
|
116
|
+
route.post('/:resource', (req, res) => admin._store(req, res));
|
|
117
|
+
route.put('/:resource/:id', (req, res) => admin._update(req, res));
|
|
118
|
+
route.delete('/:resource/:id', (req, res) => admin._destroy(req, res));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Request Handlers ─────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
async _list(req, res) {
|
|
127
|
+
try {
|
|
128
|
+
const Resource = this._resolve(req.params.resource, res);
|
|
129
|
+
if (!Resource) return;
|
|
130
|
+
|
|
131
|
+
const page = Number(req.query.page) || 1;
|
|
132
|
+
const search = req.query.search || '';
|
|
133
|
+
const sort = req.query.sort || 'id';
|
|
134
|
+
const order = req.query.order || 'desc';
|
|
135
|
+
const filters = req.query.filters
|
|
136
|
+
? JSON.parse(req.query.filters)
|
|
137
|
+
: {};
|
|
138
|
+
|
|
139
|
+
const result = await Resource.fetchList({ page, search, sort, order, filters });
|
|
140
|
+
|
|
141
|
+
// Attach field definitions for the frontend
|
|
142
|
+
const fields = Resource.fields().map(f => f.toJSON());
|
|
143
|
+
const fltrs = Resource.filters().map(f => f.toJSON());
|
|
144
|
+
const sortable = Resource.sortable || [];
|
|
145
|
+
|
|
146
|
+
res.json({
|
|
147
|
+
...result,
|
|
148
|
+
fields,
|
|
149
|
+
filters: fltrs,
|
|
150
|
+
sortable,
|
|
151
|
+
canCreate: Resource.canCreate,
|
|
152
|
+
canEdit: Resource.canEdit,
|
|
153
|
+
canDelete: Resource.canDelete,
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
this._error(res, err);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async _show(req, res) {
|
|
161
|
+
try {
|
|
162
|
+
const Resource = this._resolve(req.params.resource, res);
|
|
163
|
+
if (!Resource) return;
|
|
164
|
+
|
|
165
|
+
const record = await Resource.fetchOne(req.params.id);
|
|
166
|
+
const fields = Resource.fields().map(f => f.toJSON());
|
|
167
|
+
|
|
168
|
+
res.json({ data: record, fields });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
this._error(res, err);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _store(req, res) {
|
|
175
|
+
try {
|
|
176
|
+
const Resource = this._resolve(req.params.resource, res);
|
|
177
|
+
if (!Resource) return;
|
|
178
|
+
if (!Resource.canCreate) return res.status(403).json({ error: 'Create not allowed' });
|
|
179
|
+
|
|
180
|
+
const record = await Resource.create(req.body);
|
|
181
|
+
res.status(201).json({ data: record, message: 'Created successfully' });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this._error(res, err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _update(req, res) {
|
|
188
|
+
try {
|
|
189
|
+
const Resource = this._resolve(req.params.resource, res);
|
|
190
|
+
if (!Resource) return;
|
|
191
|
+
if (!Resource.canEdit) return res.status(403).json({ error: 'Edit not allowed' });
|
|
192
|
+
|
|
193
|
+
const record = await Resource.update(req.params.id, req.body);
|
|
194
|
+
res.json({ data: record, message: 'Updated successfully' });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
this._error(res, err);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async _destroy(req, res) {
|
|
201
|
+
try {
|
|
202
|
+
const Resource = this._resolve(req.params.resource, res);
|
|
203
|
+
if (!Resource) return;
|
|
204
|
+
if (!Resource.canDelete) return res.status(403).json({ error: 'Delete not allowed' });
|
|
205
|
+
|
|
206
|
+
await Resource.destroy(req.params.id);
|
|
207
|
+
res.json({ message: 'Deleted successfully' });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
this._error(res, err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── HTML Shell ───────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
_serveShell(req, res) {
|
|
216
|
+
const title = this._config.title;
|
|
217
|
+
const resources = this.resources().map(r => ({
|
|
218
|
+
slug: r.slug,
|
|
219
|
+
label: r._getLabel ? r._getLabel() : r.label || r.name,
|
|
220
|
+
icon: r.icon,
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
res.setHeader('Content-Type', 'text/html');
|
|
224
|
+
res.send(this._renderShell(title, resources));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_renderShell(title, resources) {
|
|
228
|
+
const navItems = resources.map(r =>
|
|
229
|
+
`<a href="/admin/${r.slug}" class="nav-item" data-slug="${r.slug}">
|
|
230
|
+
<span class="nav-icon">${r.icon}</span>
|
|
231
|
+
<span class="nav-label">${r.label}</span>
|
|
232
|
+
</a>`
|
|
233
|
+
).join('\n');
|
|
234
|
+
|
|
235
|
+
return `<!DOCTYPE html>
|
|
236
|
+
<html lang="en">
|
|
237
|
+
<head>
|
|
238
|
+
<meta charset="UTF-8">
|
|
239
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
240
|
+
<title>${title}</title>
|
|
241
|
+
<style>
|
|
242
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
243
|
+
:root {
|
|
244
|
+
--bg: #0f1117;
|
|
245
|
+
--surface: #1a1d27;
|
|
246
|
+
--surface2: #222535;
|
|
247
|
+
--border: #2e3146;
|
|
248
|
+
--primary: #6366f1;
|
|
249
|
+
--primary-h: #818cf8;
|
|
250
|
+
--text: #e2e8f0;
|
|
251
|
+
--text-muted:#64748b;
|
|
252
|
+
--success: #22c55e;
|
|
253
|
+
--danger: #ef4444;
|
|
254
|
+
--warning: #f59e0b;
|
|
255
|
+
--radius: 8px;
|
|
256
|
+
--font: 'Inter', system-ui, sans-serif;
|
|
257
|
+
}
|
|
258
|
+
body { font-family: var(--font); background: var(--bg); color: var(--text); display: flex; height: 100vh; overflow: hidden; }
|
|
259
|
+
/* ── Sidebar ── */
|
|
260
|
+
#sidebar {
|
|
261
|
+
width: 240px; min-width: 240px; background: var(--surface); border-right: 1px solid var(--border);
|
|
262
|
+
display: flex; flex-direction: column; overflow-y: auto;
|
|
263
|
+
}
|
|
264
|
+
.sidebar-header { padding: 20px 16px; border-bottom: 1px solid var(--border); }
|
|
265
|
+
.sidebar-title { font-size: 16px; font-weight: 700; color: var(--primary); letter-spacing: -0.3px; }
|
|
266
|
+
.sidebar-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
|
267
|
+
.nav-section { padding: 12px 8px 8px; }
|
|
268
|
+
.nav-section-label { font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.8px; padding: 0 8px 6px; }
|
|
269
|
+
.nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: var(--radius); color: var(--text-muted); text-decoration: none; font-size: 13px; font-weight: 500; transition: all .15s; cursor: pointer; }
|
|
270
|
+
.nav-item:hover, .nav-item.active { background: var(--surface2); color: var(--text); }
|
|
271
|
+
.nav-item.active { color: var(--primary-h); }
|
|
272
|
+
.nav-icon { font-size: 15px; width: 20px; text-align: center; }
|
|
273
|
+
/* ── Main ── */
|
|
274
|
+
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
275
|
+
#topbar { background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
|
|
276
|
+
#page-title { font-size: 15px; font-weight: 600; }
|
|
277
|
+
#content { flex: 1; overflow-y: auto; padding: 24px; }
|
|
278
|
+
/* ── Table ── */
|
|
279
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
280
|
+
.card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
281
|
+
.card-title { font-size: 14px; font-weight: 600; }
|
|
282
|
+
table { width: 100%; border-collapse: collapse; }
|
|
283
|
+
th { text-align: left; padding: 10px 16px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); background: var(--surface2); border-bottom: 1px solid var(--border); white-space: nowrap; cursor: pointer; user-select: none; }
|
|
284
|
+
th:hover { color: var(--text); }
|
|
285
|
+
td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }
|
|
286
|
+
tr:last-child td { border-bottom: none; }
|
|
287
|
+
tr:hover td { background: var(--surface2); }
|
|
288
|
+
/* ── Controls ── */
|
|
289
|
+
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all .15s; }
|
|
290
|
+
.btn-primary { background: var(--primary); color: #fff; }
|
|
291
|
+
.btn-primary:hover { background: var(--primary-h); }
|
|
292
|
+
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
|
|
293
|
+
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
|
|
294
|
+
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--border); }
|
|
295
|
+
.btn-danger:hover { background: var(--danger); color: #fff; }
|
|
296
|
+
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
297
|
+
input, select, textarea { background: var(--surface2); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 8px 12px; font-size: 13px; width: 100%; outline: none; font-family: var(--font); }
|
|
298
|
+
input:focus, select:focus, textarea:focus { border-color: var(--primary); }
|
|
299
|
+
.search-input { width: 240px; }
|
|
300
|
+
/* ── Badge ── */
|
|
301
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
|
302
|
+
.badge-blue { background: #1e3a5f; color: #60a5fa; }
|
|
303
|
+
.badge-red { background: #3b1212; color: #f87171; }
|
|
304
|
+
.badge-green { background: #14342b; color: #4ade80; }
|
|
305
|
+
.badge-yellow { background: #3b2800; color: #fbbf24; }
|
|
306
|
+
.badge-gray { background: var(--surface2); color: var(--text-muted); }
|
|
307
|
+
/* ── Pagination ── */
|
|
308
|
+
.pagination { display: flex; align-items: center; gap: 6px; padding: 14px 20px; border-top: 1px solid var(--border); }
|
|
309
|
+
.page-info { font-size: 12px; color: var(--text-muted); margin-left: auto; }
|
|
310
|
+
.page-btn { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--text); cursor: pointer; font-size: 12px; }
|
|
311
|
+
.page-btn:hover { background: var(--surface2); }
|
|
312
|
+
.page-btn.active { background: var(--primary); border-color: var(--primary); }
|
|
313
|
+
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
314
|
+
/* ── Modal ── */
|
|
315
|
+
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
|
|
316
|
+
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 540px; max-height: 90vh; overflow-y: auto; }
|
|
317
|
+
.modal-header { padding: 20px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
|
318
|
+
.modal-title { font-size: 15px; font-weight: 600; }
|
|
319
|
+
.modal-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 16px; }
|
|
320
|
+
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
|
|
321
|
+
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
|
322
|
+
.form-label { font-size: 12px; font-weight: 500; color: var(--text-muted); }
|
|
323
|
+
/* ── States ── */
|
|
324
|
+
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
|
325
|
+
.empty-icon { font-size: 36px; margin-bottom: 12px; }
|
|
326
|
+
.loading { opacity: .5; pointer-events: none; }
|
|
327
|
+
.bool-yes { color: var(--success); } .bool-no { color: var(--danger); }
|
|
328
|
+
.actions { display: flex; gap: 6px; }
|
|
329
|
+
.close-btn { background: none; border: none; color: var(--text-muted); font-size: 20px; cursor: pointer; line-height: 1; padding: 2px; }
|
|
330
|
+
.close-btn:hover { color: var(--text); }
|
|
331
|
+
.alert { padding: 10px 14px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; }
|
|
332
|
+
.alert-success { background: #14342b; color: #4ade80; border: 1px solid #166534; }
|
|
333
|
+
.alert-error { background: #3b1212; color: #f87171; border: 1px solid #7f1d1d; }
|
|
334
|
+
</style>
|
|
335
|
+
</head>
|
|
336
|
+
<body>
|
|
337
|
+
<nav id="sidebar">
|
|
338
|
+
<div class="sidebar-header">
|
|
339
|
+
<div class="sidebar-title">⚡ ${title}</div>
|
|
340
|
+
<div class="sidebar-subtitle">Admin Panel</div>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="nav-section">
|
|
343
|
+
<div class="nav-section-label">Resources</div>
|
|
344
|
+
${navItems}
|
|
345
|
+
</div>
|
|
346
|
+
</nav>
|
|
347
|
+
<div id="main">
|
|
348
|
+
<div id="topbar">
|
|
349
|
+
<span id="page-title">Dashboard</span>
|
|
350
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
351
|
+
<span style="font-size:12px;color:var(--text-muted)" id="record-count"></span>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
<div id="content">
|
|
355
|
+
<div class="empty-state">
|
|
356
|
+
<div class="empty-icon">📋</div>
|
|
357
|
+
<p>Select a resource from the sidebar to get started.</p>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
<div id="modal-root"></div>
|
|
362
|
+
|
|
363
|
+
<script>
|
|
364
|
+
const API = '/admin/api';
|
|
365
|
+
let state = { resource: null, page: 1, search: '', sort: 'id', order: 'desc', filters: {}, data: null };
|
|
366
|
+
|
|
367
|
+
// ── Navigation ──────────────────────────────────────────────────
|
|
368
|
+
document.querySelectorAll('.nav-item').forEach(el => {
|
|
369
|
+
el.addEventListener('click', e => {
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
loadResource(el.dataset.slug);
|
|
372
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
373
|
+
el.classList.add('active');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Handle browser back/forward
|
|
378
|
+
window.addEventListener('popstate', () => {
|
|
379
|
+
const slug = location.pathname.split('/admin/')[1]?.split('/')[0];
|
|
380
|
+
if (slug) loadResource(slug);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
async function loadResource(slug) {
|
|
384
|
+
state = { ...state, resource: slug, page: 1, search: '', sort: 'id', order: 'desc', filters: {} };
|
|
385
|
+
history.pushState({}, '', '/admin/' + slug);
|
|
386
|
+
await fetchAndRender();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Fetch + Render ───────────────────────────────────────────────
|
|
390
|
+
async function fetchAndRender() {
|
|
391
|
+
const { resource, page, search, sort, order, filters } = state;
|
|
392
|
+
const params = new URLSearchParams({ page, sort, order });
|
|
393
|
+
if (search) params.set('search', search);
|
|
394
|
+
if (Object.keys(filters).length) params.set('filters', JSON.stringify(filters));
|
|
395
|
+
|
|
396
|
+
const res = await fetch(API + '/' + resource + '?' + params);
|
|
397
|
+
const json = await res.json();
|
|
398
|
+
state.data = json;
|
|
399
|
+
|
|
400
|
+
document.getElementById('page-title').textContent =
|
|
401
|
+
json.resource?.label || resource.replace(/-/g,' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
|
402
|
+
document.getElementById('record-count').textContent = json.total + ' records';
|
|
403
|
+
|
|
404
|
+
renderTable(json);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function renderTable({ data, fields, total, page, perPage, lastPage, canCreate, canEdit, canDelete, sortable = [] }) {
|
|
408
|
+
const listFields = fields.filter(f => !f.detailOnly && !f.hidden);
|
|
409
|
+
const content = document.getElementById('content');
|
|
410
|
+
|
|
411
|
+
const searchBar = \`
|
|
412
|
+
<div class="card-header">
|
|
413
|
+
<span class="card-title">\${state.resource}</span>
|
|
414
|
+
<div style="display:flex;gap:8px;align-items:center;flex:1;justify-content:flex-end">
|
|
415
|
+
<input class="search-input" type="text" placeholder="Search..." value="\${state.search}"
|
|
416
|
+
oninput="debSearch(this.value)">
|
|
417
|
+
\${canCreate ? '<button class="btn btn-primary" onclick="openCreate()">+ New</button>' : ''}
|
|
418
|
+
</div>
|
|
419
|
+
</div>\`;
|
|
420
|
+
|
|
421
|
+
const thead = '<tr>' + listFields.map(f => {
|
|
422
|
+
const canSort = sortable.includes(f.name);
|
|
423
|
+
const arrow = state.sort === f.name ? (state.order === 'asc' ? ' ↑' : ' ↓') : '';
|
|
424
|
+
return \`<th onclick="\${canSort ? 'toggleSort("' + f.name + '")' : ''}"
|
|
425
|
+
style="\${canSort ? '' : 'cursor:default'}">\${f.label}\${arrow}</th>\`;
|
|
426
|
+
}).join('') + (canEdit || canDelete ? '<th style="cursor:default">Actions</th>' : '') + '</tr>';
|
|
427
|
+
|
|
428
|
+
const tbody = data.length === 0
|
|
429
|
+
? \`<tr><td colspan="\${listFields.length + 1}" style="text-align:center;padding:40px;color:var(--text-muted)">No records found</td></tr>\`
|
|
430
|
+
: data.map(row => {
|
|
431
|
+
const cells = listFields.map(f => \`<td>\${renderCell(f, row[f.name])}</td>\`).join('');
|
|
432
|
+
const actions = (canEdit || canDelete) ? \`<td><div class="actions">
|
|
433
|
+
\${canEdit ? \`<button class="btn btn-ghost btn-sm" onclick='openEdit(\${JSON.stringify(row.id)})'>Edit</button>\` : ''}
|
|
434
|
+
\${canDelete ? \`<button class="btn btn-danger btn-sm" onclick='confirmDelete(\${JSON.stringify(row.id)})'>Delete</button>\` : ''}
|
|
435
|
+
</div></td>\` : '';
|
|
436
|
+
return \`<tr>\${cells}\${actions}</tr>\`;
|
|
437
|
+
}).join('');
|
|
438
|
+
|
|
439
|
+
const pageNums = Array.from({ length: Math.min(lastPage, 7) }, (_, i) => i + 1)
|
|
440
|
+
.map(n => \`<button class="page-btn \${n === page ? 'active' : ''}" onclick="goPage(\${n})">\${n}</button>\`).join('');
|
|
441
|
+
|
|
442
|
+
content.innerHTML = \`
|
|
443
|
+
<div class="card">
|
|
444
|
+
\${searchBar}
|
|
445
|
+
<div style="overflow-x:auto"><table><thead>\${thead}</thead><tbody>\${tbody}</tbody></table></div>
|
|
446
|
+
<div class="pagination">
|
|
447
|
+
<button class="page-btn" \${page <= 1 ? 'disabled' : ''} onclick="goPage(\${page-1})">‹</button>
|
|
448
|
+
\${pageNums}
|
|
449
|
+
<button class="page-btn" \${page >= lastPage ? 'disabled' : ''} onclick="goPage(\${page+1})">›</button>
|
|
450
|
+
<span class="page-info">Showing \${(page-1)*perPage+1}–\${Math.min(page*perPage,total)} of \${total}</span>
|
|
451
|
+
</div>
|
|
452
|
+
</div>\`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function renderCell(field, value) {
|
|
456
|
+
if (value === null || value === undefined) return '<span style="color:var(--text-muted)">—</span>';
|
|
457
|
+
if (field.type === 'boolean') return value
|
|
458
|
+
? '<span class="bool-yes">✓</span>'
|
|
459
|
+
: '<span class="bool-no">✗</span>';
|
|
460
|
+
if (field.type === 'badge') {
|
|
461
|
+
const colorMap = { admin:'red', user:'blue', active:'green', inactive:'gray', pending:'yellow' };
|
|
462
|
+
const c = (field.colors && field.colors[value]) || colorMap[value] || 'gray';
|
|
463
|
+
return \`<span class="badge badge-\${c}">\${value}</span>\`;
|
|
464
|
+
}
|
|
465
|
+
if (field.type === 'datetime' && value) return new Date(value).toLocaleString();
|
|
466
|
+
if (field.type === 'date' && value) return new Date(value).toLocaleDateString();
|
|
467
|
+
if (field.type === 'image' && value) return \`<img src="\${value}" style="width:32px;height:32px;border-radius:4px;object-fit:cover">\`;
|
|
468
|
+
if (field.type === 'password') return '••••••••';
|
|
469
|
+
const str = String(value);
|
|
470
|
+
return str.length > 60 ? str.slice(0, 60) + '…' : str;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Sort / Page / Search ─────────────────────────────────────────
|
|
474
|
+
function toggleSort(col) {
|
|
475
|
+
if (state.sort === col) state.order = state.order === 'asc' ? 'desc' : 'asc';
|
|
476
|
+
else { state.sort = col; state.order = 'asc'; }
|
|
477
|
+
state.page = 1;
|
|
478
|
+
fetchAndRender();
|
|
479
|
+
}
|
|
480
|
+
function goPage(p) { state.page = p; fetchAndRender(); }
|
|
481
|
+
let _searchTimer;
|
|
482
|
+
function debSearch(v) { clearTimeout(_searchTimer); _searchTimer = setTimeout(() => { state.search = v; state.page = 1; fetchAndRender(); }, 300); }
|
|
483
|
+
|
|
484
|
+
// ── Create / Edit ────────────────────────────────────────────────
|
|
485
|
+
async function openCreate() {
|
|
486
|
+
const { fields } = state.data;
|
|
487
|
+
const editFields = fields.filter(f => f.type !== 'id' && !f.listOnly && !f.readonly);
|
|
488
|
+
showModal('New ' + state.resource, renderForm(editFields, {}), async () => {
|
|
489
|
+
const data = collectForm();
|
|
490
|
+
const res = await fetch(API + '/' + state.resource, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
|
491
|
+
if (res.ok) { closeModal(); showAlert('Created successfully', 'success'); fetchAndRender(); }
|
|
492
|
+
else { const e = await res.json(); showAlert(e.message || 'Error', 'error'); }
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function openEdit(id) {
|
|
497
|
+
const res = await fetch(API + '/' + state.resource + '/' + id);
|
|
498
|
+
const { data: record, fields } = await res.json();
|
|
499
|
+
const editFields = fields.filter(f => f.type !== 'id' && !f.listOnly && !f.readonly);
|
|
500
|
+
showModal('Edit ' + state.resource + ' #' + id, renderForm(editFields, record), async () => {
|
|
501
|
+
const data = collectForm();
|
|
502
|
+
const res2 = await fetch(API + '/' + state.resource + '/' + id, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
|
503
|
+
if (res2.ok) { closeModal(); showAlert('Updated successfully', 'success'); fetchAndRender(); }
|
|
504
|
+
else { const e = await res2.json(); showAlert(e.message || 'Error', 'error'); }
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderForm(fields, record) {
|
|
509
|
+
return fields.map(f => {
|
|
510
|
+
const val = record[f.name] !== undefined ? record[f.name] : '';
|
|
511
|
+
let input;
|
|
512
|
+
if (f.type === 'boolean') {
|
|
513
|
+
input = \`<select name="\${f.name}"><option value="1" \${val ? 'selected' : ''}>Yes</option><option value="0" \${!val ? 'selected' : ''}>No</option></select>\`;
|
|
514
|
+
} else if (f.type === 'select' && f.options) {
|
|
515
|
+
const opts = f.options.map(o => \`<option value="\${o}" \${val===o?'selected':''}>\${o}</option>\`).join('');
|
|
516
|
+
input = \`<select name="\${f.name}">\${opts}</select>\`;
|
|
517
|
+
} else if (f.type === 'textarea') {
|
|
518
|
+
input = \`<textarea name="\${f.name}" rows="4">\${val}</textarea>\`;
|
|
519
|
+
} else {
|
|
520
|
+
const t = f.type === 'password' ? 'password' : f.type === 'email' ? 'email' : f.type === 'number' ? 'number' : 'text';
|
|
521
|
+
input = \`<input type="\${t}" name="\${f.name}" value="\${val}" placeholder="\${f.placeholder || ''}">\`;
|
|
522
|
+
}
|
|
523
|
+
return \`<div class="form-group"><label class="form-label">\${f.label}</label>\${input}\${f.help ? '<small style="color:var(--text-muted)">' + f.help + '</small>' : ''}</div>\`;
|
|
524
|
+
}).join('');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function collectForm() {
|
|
528
|
+
const modal = document.querySelector('.modal');
|
|
529
|
+
const data = {};
|
|
530
|
+
modal.querySelectorAll('[name]').forEach(el => { data[el.name] = el.value; });
|
|
531
|
+
return data;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── Delete ───────────────────────────────────────────────────────
|
|
535
|
+
function confirmDelete(id) {
|
|
536
|
+
showModal('Confirm Delete', \`<p style="color:var(--text-muted)">Are you sure you want to delete record #\${id}? This cannot be undone.</p>\`, async () => {
|
|
537
|
+
const res = await fetch(API + '/' + state.resource + '/' + id, { method: 'DELETE' });
|
|
538
|
+
if (res.ok) { closeModal(); showAlert('Deleted', 'success'); fetchAndRender(); }
|
|
539
|
+
else { const e = await res.json(); showAlert(e.message || 'Error', 'error'); }
|
|
540
|
+
}, { confirmLabel: 'Delete', confirmClass: 'btn-danger' });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── Modal ────────────────────────────────────────────────────────
|
|
544
|
+
function showModal(title, bodyHtml, onConfirm, opts = {}) {
|
|
545
|
+
document.getElementById('modal-root').innerHTML = \`
|
|
546
|
+
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
|
547
|
+
<div class="modal">
|
|
548
|
+
<div class="modal-header">
|
|
549
|
+
<span class="modal-title">\${title}</span>
|
|
550
|
+
<button class="close-btn" onclick="closeModal()">×</button>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="modal-body">\${bodyHtml}</div>
|
|
553
|
+
<div class="modal-footer">
|
|
554
|
+
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
|
555
|
+
<button class="btn \${opts.confirmClass || 'btn-primary'}" onclick="modalConfirm()">\${opts.confirmLabel || 'Save'}</button>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
</div>\`;
|
|
559
|
+
window._modalConfirm = onConfirm;
|
|
560
|
+
}
|
|
561
|
+
function closeModal() { document.getElementById('modal-root').innerHTML = ''; }
|
|
562
|
+
function modalConfirm() { if (window._modalConfirm) window._modalConfirm(); }
|
|
563
|
+
|
|
564
|
+
// ── Alert ────────────────────────────────────────────────────────
|
|
565
|
+
function showAlert(msg, type = 'success') {
|
|
566
|
+
const el = document.createElement('div');
|
|
567
|
+
el.className = \`alert alert-\${type}\`;
|
|
568
|
+
el.textContent = msg;
|
|
569
|
+
document.getElementById('content').prepend(el);
|
|
570
|
+
setTimeout(() => el.remove(), 3000);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Init: load from URL ──────────────────────────────────────────
|
|
574
|
+
(function init() {
|
|
575
|
+
const slug = location.pathname.split('/admin/')[1]?.split('/')[0];
|
|
576
|
+
if (slug) {
|
|
577
|
+
const navEl = document.querySelector(\`.nav-item[data-slug="\${slug}"]\`);
|
|
578
|
+
if (navEl) { navEl.classList.add('active'); loadResource(slug); }
|
|
579
|
+
}
|
|
580
|
+
})();
|
|
581
|
+
</script>
|
|
582
|
+
</body>
|
|
583
|
+
</html>`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
_resolve(slug, res) {
|
|
589
|
+
const Resource = this._resources.get(slug);
|
|
590
|
+
if (!Resource) {
|
|
591
|
+
res.status(404).json({ error: `Resource "${slug}" not registered` });
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
return Resource;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
_error(res, err) {
|
|
598
|
+
const status = err.status || err.statusCode || 500;
|
|
599
|
+
res.status(status).json({ error: err.message, status });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
_autoResource(ModelClass) {
|
|
603
|
+
const R = class extends AdminResource {};
|
|
604
|
+
R.model = ModelClass;
|
|
605
|
+
R.label = ModelClass.name + 's';
|
|
606
|
+
R.labelSingular = ModelClass.name;
|
|
607
|
+
return R;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Singleton
|
|
612
|
+
const admin = new Admin();
|
|
613
|
+
module.exports = admin;
|
|
614
|
+
module.exports.Admin = Admin;
|
|
615
|
+
module.exports.AdminResource = AdminResource;
|
|
616
|
+
module.exports.AdminField = AdminField;
|
|
617
|
+
module.exports.AdminFilter = AdminFilter;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Admin = require('./Admin');
|
|
4
|
+
const AdminServiceProvider = require('../providers/AdminServiceProvider');
|
|
5
|
+
const { AdminResource, AdminField, AdminFilter } = require('./resources/AdminResource');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
Admin,
|
|
9
|
+
AdminResource,
|
|
10
|
+
AdminField,
|
|
11
|
+
AdminFilter,
|
|
12
|
+
AdminServiceProvider,
|
|
13
|
+
};
|