webspresso 0.0.80 → 0.0.82

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 (38) hide show
  1. package/README.md +133 -15
  2. package/bin/commands/doctor.js +83 -0
  3. package/bin/commands/favicon-generate.js +3 -1
  4. package/bin/commands/skill.js +6 -4
  5. package/core/auth/hash.js +1 -1
  6. package/core/auth/index.js +30 -0
  7. package/core/auth/manager.js +190 -0
  8. package/core/auth/tokens.js +135 -0
  9. package/core/kernel/app.js +7 -1
  10. package/index.d.ts +32 -0
  11. package/index.js +2 -0
  12. package/package.json +5 -2
  13. package/plugins/admin-panel/app.js +43 -1
  14. package/plugins/admin-panel/core/admin-module.js +156 -16
  15. package/plugins/admin-panel/core/registry.js +3 -0
  16. package/plugins/admin-panel/field-renderers/file-upload.js +48 -2
  17. package/plugins/admin-panel/index.js +17 -1
  18. package/plugins/csrf/index.js +222 -0
  19. package/plugins/email/admin-component.js +226 -0
  20. package/plugins/email/api-handlers.js +139 -0
  21. package/plugins/email/auth-bridge.js +209 -0
  22. package/plugins/email/bundled-templates.js +68 -0
  23. package/plugins/email/fs-env.js +101 -0
  24. package/plugins/email/index.js +186 -0
  25. package/plugins/email/manifest.js +25 -0
  26. package/plugins/email/migration-template.js +40 -0
  27. package/plugins/email/purge.js +28 -0
  28. package/plugins/email/render.js +154 -0
  29. package/plugins/email/service.js +244 -0
  30. package/plugins/email/template-registry.js +134 -0
  31. package/plugins/email/templates/auth-email-verify.mjml +13 -0
  32. package/plugins/email/templates/auth-password-reset.mjml +13 -0
  33. package/plugins/email/templates/auth-welcome.mjml +11 -0
  34. package/plugins/email/transport.js +71 -0
  35. package/plugins/index.js +4 -0
  36. package/src/file-router.js +75 -0
  37. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
  38. package/templates/skills/webspresso-usage/SKILL.md +2 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Auth token types and helpers (password reset, email verification)
3
+ * @module core/auth/tokens
4
+ */
5
+
6
+ const { generateToken, hashToken, hash } = require('./hash');
7
+
8
+ const TOKEN_TYPES = {
9
+ PASSWORD_RESET: 'password_reset',
10
+ EMAIL_VERIFY: 'email_verify',
11
+ };
12
+
13
+ /**
14
+ * @param {import('knex').Knex} knex
15
+ * @returns {Promise<void>}
16
+ */
17
+ async function createAuthTokensTable(knex) {
18
+ const exists = await knex.schema.hasTable('auth_tokens');
19
+ if (exists) return;
20
+
21
+ await knex.schema.createTable('auth_tokens', (table) => {
22
+ table.bigIncrements('id').primary();
23
+ table.bigInteger('user_id').unsigned().notNullable();
24
+ table.string('token', 64).notNullable().unique();
25
+ table.string('type', 32).notNullable();
26
+ table.timestamp('expires_at').notNullable();
27
+ table.timestamp('created_at').defaultTo(knex.fn.now());
28
+
29
+ table.index(['user_id', 'type']);
30
+ table.index('token');
31
+ table.index('expires_at');
32
+ });
33
+ }
34
+
35
+ /**
36
+ * @param {import('knex').Knex} knex
37
+ * @returns {Promise<void>}
38
+ */
39
+ async function dropAuthTokensTable(knex) {
40
+ await knex.schema.dropTableIfExists('auth_tokens');
41
+ }
42
+
43
+ /**
44
+ * Create Knex auth_tokens adapter
45
+ * @param {Object} db - Database with knex
46
+ * @returns {Object}
47
+ */
48
+ function createKnexAuthTokensAdapter(db) {
49
+ const knex = db.knex || db;
50
+
51
+ return {
52
+ create: async (userId, tokenHash, type, expiresAt) => {
53
+ await knex('auth_tokens').insert({
54
+ user_id: userId,
55
+ token: tokenHash,
56
+ type,
57
+ expires_at: expiresAt,
58
+ created_at: new Date(),
59
+ });
60
+ },
61
+ find: async (tokenHash, type) => {
62
+ return knex('auth_tokens').where({ token: tokenHash, type }).first();
63
+ },
64
+ delete: async (tokenHash) => {
65
+ await knex('auth_tokens').where({ token: tokenHash }).delete();
66
+ },
67
+ deleteAllForUser: async (userId, type) => {
68
+ const q = knex('auth_tokens').where({ user_id: userId });
69
+ if (type) q.where({ type });
70
+ await q.delete();
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * @param {Object} authTokensAdapter
77
+ * @param {string} type
78
+ * @param {*} userId
79
+ * @param {number} ttlMs
80
+ * @returns {Promise<{ rawToken: string, expiresAt: Date }>}
81
+ */
82
+ async function createAuthToken(authTokensAdapter, type, userId, ttlMs) {
83
+ const rawToken = generateToken(32);
84
+ const tokenHash = hashToken(rawToken);
85
+ const expiresAt = new Date(Date.now() + ttlMs);
86
+
87
+ await authTokensAdapter.deleteAllForUser(userId, type);
88
+ await authTokensAdapter.create(userId, tokenHash, type, expiresAt);
89
+
90
+ return { rawToken, expiresAt };
91
+ }
92
+
93
+ /**
94
+ * @param {Object} authTokensAdapter
95
+ * @param {string} type
96
+ * @param {string} rawToken
97
+ * @returns {Promise<{ userId: *, expiresAt: Date }|null>}
98
+ */
99
+ async function verifyAuthToken(authTokensAdapter, type, rawToken) {
100
+ if (!rawToken) return null;
101
+
102
+ const tokenHash = hashToken(rawToken);
103
+ const record = await authTokensAdapter.find(tokenHash, type);
104
+ if (!record) return null;
105
+
106
+ if (new Date(record.expires_at) < new Date()) {
107
+ await authTokensAdapter.delete(tokenHash);
108
+ return null;
109
+ }
110
+
111
+ return {
112
+ userId: record.user_id,
113
+ expiresAt: new Date(record.expires_at),
114
+ tokenHash,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * @param {Object} authTokensAdapter
120
+ * @param {string} tokenHash
121
+ */
122
+ async function consumeAuthToken(authTokensAdapter, tokenHash) {
123
+ await authTokensAdapter.delete(tokenHash);
124
+ }
125
+
126
+ module.exports = {
127
+ TOKEN_TYPES,
128
+ createAuthTokensTable,
129
+ dropAuthTokensTable,
130
+ createKnexAuthTokensAdapter,
131
+ createAuthToken,
132
+ verifyAuthToken,
133
+ consumeAuthToken,
134
+ hash,
135
+ };
@@ -50,7 +50,13 @@ function createApp(options = {}) {
50
50
  }
51
51
  };
52
52
  events.on(flowDef.trigger, handler);
53
- const unregister = () => events.off(flowDef.trigger, handler);
53
+ const unregister = () => {
54
+ events.off(flowDef.trigger, handler);
55
+ const idx = flowHandles.findIndex((h) => h.unregister === unregister);
56
+ if (idx !== -1) {
57
+ flowHandles.splice(idx, 1);
58
+ }
59
+ };
54
60
  flowHandles.push({
55
61
  id,
56
62
  trigger: flowDef.trigger,
package/index.d.ts CHANGED
@@ -74,6 +74,15 @@ export interface CreateAppResult {
74
74
 
75
75
  export function createApp(options?: CreateAppOptions): CreateAppResult;
76
76
 
77
+ export function resolveClientRuntime(options?: {
78
+ clientRuntime?: {
79
+ alpine?: boolean | Record<string, unknown>;
80
+ swup?: boolean | Record<string, unknown>;
81
+ };
82
+ }): { alpine: boolean; swup: boolean };
83
+
84
+ export const CLIENT_RUNTIME_BASE: string;
85
+
77
86
  // --- App context ---
78
87
 
79
88
  export function attachDbMiddleware(req: Request, res: Response, next: NextFunction): void;
@@ -595,6 +604,29 @@ export interface RateLimitPluginOptions {
595
604
 
596
605
  export function rateLimitPlugin(options?: RateLimitPluginOptions): WebspressoPlugin;
597
606
 
607
+ export interface CsrfCookieOptions {
608
+ key?: string;
609
+ signed?: boolean;
610
+ path?: string;
611
+ httpOnly?: boolean;
612
+ secure?: boolean;
613
+ sameSite?: 'lax' | 'strict' | 'none';
614
+ maxAge?: number;
615
+ [key: string]: unknown;
616
+ }
617
+
618
+ export interface CsrfPluginOptions {
619
+ cookie?: boolean | CsrfCookieOptions;
620
+ sessionKey?: string;
621
+ methods?: string[];
622
+ ignorePaths?: Array<string | RegExp | ((req: Request) => boolean)>;
623
+ global?: boolean;
624
+ errorMessage?: string;
625
+ errorStatus?: number;
626
+ }
627
+
628
+ export function csrfPlugin(options?: CsrfPluginOptions): WebspressoPlugin;
629
+
598
630
  export interface ContentPluginOptions {
599
631
  db: import('./index').Database;
600
632
  adminPath?: string;
package/index.js CHANGED
@@ -63,6 +63,7 @@ const {
63
63
  redirectPlugin,
64
64
  rateLimitPlugin,
65
65
  contentPlugin,
66
+ csrfPlugin,
66
67
  } = require('./plugins');
67
68
 
68
69
  const content = require('./core/content');
@@ -133,6 +134,7 @@ module.exports = {
133
134
  redirectPlugin,
134
135
  rateLimitPlugin,
135
136
  contentPlugin,
137
+ csrfPlugin,
136
138
 
137
139
  // Schema-driven CMS core (framework-agnostic)
138
140
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.80",
3
+ "version": "0.0.82",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -67,7 +67,9 @@
67
67
  "helmet": "^7.2.0",
68
68
  "inquirer": "^8.2.6",
69
69
  "knex": "^3.1.0",
70
+ "mjml": "^5.4.0",
70
71
  "multer": "^2.1.1",
72
+ "nodemailer": "^9.0.3",
71
73
  "nunjucks": "^3.2.4",
72
74
  "sanitize-html": "^2.17.3",
73
75
  "sharp": "^0.33.5",
@@ -125,6 +127,7 @@
125
127
  "overrides": {
126
128
  "basic-ftp": "^5.3.0",
127
129
  "tar": "^7.5.11",
128
- "undici": "^6.24.0"
130
+ "undici": "^6.24.0",
131
+ "uuid": "^11.1.1"
129
132
  }
130
133
  }
@@ -221,6 +221,47 @@ var routes = {
221
221
  },
222
222
  };
223
223
 
224
+ function wrapCustomPageWithLayout(page, rawComp) {
225
+ if (!rawComp) return createCustomPage(page);
226
+ if (page.layout === false) return rawComp;
227
+
228
+ return {
229
+ oninit: function(vnode) {
230
+ if (typeof rawComp.oninit === 'function') return rawComp.oninit.call(this, vnode);
231
+ },
232
+ oncreate: function(vnode) {
233
+ if (typeof rawComp.oncreate === 'function') return rawComp.oncreate.call(this, vnode);
234
+ },
235
+ onupdate: function(vnode) {
236
+ if (typeof rawComp.onupdate === 'function') return rawComp.onupdate.call(this, vnode);
237
+ },
238
+ onbeforeremove: function(vnode) {
239
+ if (typeof rawComp.onbeforeremove === 'function') return rawComp.onbeforeremove.call(this, vnode);
240
+ },
241
+ onremove: function(vnode) {
242
+ if (typeof rawComp.onremove === 'function') return rawComp.onremove.call(this, vnode);
243
+ },
244
+ view: function(vnode) {
245
+ var content;
246
+ if (typeof rawComp.view === 'function') {
247
+ content = rawComp.view.call(this, vnode);
248
+ } else if (typeof rawComp === 'function') {
249
+ content = rawComp.call(this, vnode);
250
+ } else {
251
+ content = rawComp;
252
+ }
253
+ if (content && (content.tag === Layout || (content.tag && content.tag.name === 'Layout'))) {
254
+ return content;
255
+ }
256
+ var breadcrumbs = [{ label: page.title, href: page.path }];
257
+ return m(Layout, [
258
+ m(Breadcrumb, { items: breadcrumbs }),
259
+ content
260
+ ]);
261
+ }
262
+ };
263
+ }
264
+
224
265
  // Dynamic routes for custom pages registered via plugins
225
266
  var config = window.__ADMIN_CONFIG__;
226
267
  if (config && config.pages) {
@@ -234,7 +275,7 @@ if (config && config.pages) {
234
275
  return;
235
276
  }
236
277
  if (window.__customPages && window.__customPages[page.id]) {
237
- return window.__customPages[page.id];
278
+ return wrapCustomPageWithLayout(page, window.__customPages[page.id]);
238
279
  }
239
280
  return createCustomPage(page);
240
281
  }
@@ -245,3 +286,4 @@ if (config && config.pages) {
245
286
 
246
287
  m.route(document.getElementById('app'), '/', routes);
247
288
  `;
289
+
@@ -1,25 +1,118 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Format client component code to ensure it assigns to window.__customPages[pageId]
6
+ */
7
+ function formatClientComponent(pageId, rawCode) {
8
+ let code = (rawCode || '').trim();
9
+ if (!code) return '';
10
+ if (code.includes('window.__customPages')) {
11
+ return code;
12
+ }
13
+ if (code.startsWith('module.exports =')) {
14
+ code = code.replace(/^module\.exports\s*=\s*/, '');
15
+ }
16
+ if (code.endsWith(';')) {
17
+ code = code.slice(0, -1);
18
+ }
19
+ return `window.__customPages = window.__customPages || {};\nwindow.__customPages[${JSON.stringify(pageId)}] = ${code};`;
20
+ }
21
+
1
22
  /**
2
- * Admin Module Registration System
3
- * Declarative API for registering admin pages, menu items, API routes, and widgets
4
- * @module plugins/admin-panel/core/admin-module
23
+ * Load page definitions and components from a directory structure
5
24
  */
25
+ function loadPagesFromDir(dirPath) {
26
+ const resolvedDir = path.resolve(dirPath);
27
+ if (!fs.existsSync(resolvedDir)) {
28
+ throw new Error(`pagesDir directory not found: ${dirPath}`);
29
+ }
30
+ const stat = fs.statSync(resolvedDir);
31
+ if (!stat.isDirectory()) {
32
+ throw new Error(`pagesDir path is not a directory: ${dirPath}`);
33
+ }
34
+
35
+ const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
36
+ const pages = [];
37
+ const menuItems = [];
38
+
39
+ for (const entry of entries) {
40
+ if (entry.name.startsWith('.')) continue;
41
+
42
+ let pageConfig = null;
43
+ let componentFile = null;
44
+
45
+ if (entry.isDirectory()) {
46
+ const folderPath = path.join(resolvedDir, entry.name);
47
+ const jsonCandidate = path.join(folderPath, 'page.json');
48
+ const configJsonCandidate = path.join(folderPath, 'config.json');
49
+
50
+ if (fs.existsSync(jsonCandidate)) {
51
+ pageConfig = JSON.parse(fs.readFileSync(jsonCandidate, 'utf8'));
52
+ } else if (fs.existsSync(configJsonCandidate)) {
53
+ pageConfig = JSON.parse(fs.readFileSync(configJsonCandidate, 'utf8'));
54
+ } else {
55
+ pageConfig = { id: entry.name, title: entry.name, path: '/' + entry.name };
56
+ }
57
+
58
+ const jsCandidates = ['component.js', 'index.js', 'page.js', `${entry.name}.js`];
59
+ for (const cand of jsCandidates) {
60
+ const fullCandPath = path.join(folderPath, cand);
61
+ if (fs.existsSync(fullCandPath)) {
62
+ componentFile = fullCandPath;
63
+ break;
64
+ }
65
+ }
66
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
67
+ const jsonPath = path.join(resolvedDir, entry.name);
68
+ pageConfig = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
69
+ const baseName = entry.name.slice(0, -5);
70
+ const jsCandidate = path.join(resolvedDir, baseName + '.js');
71
+ if (fs.existsSync(jsCandidate)) {
72
+ componentFile = jsCandidate;
73
+ }
74
+ }
75
+
76
+ if (pageConfig) {
77
+ if (componentFile && !pageConfig.componentFile) {
78
+ pageConfig.componentFile = componentFile;
79
+ }
80
+ if (pageConfig.menu) {
81
+ if (pageConfig.menu === true) {
82
+ menuItems.push({
83
+ id: pageConfig.id,
84
+ label: pageConfig.title,
85
+ path: pageConfig.path,
86
+ icon: pageConfig.icon,
87
+ });
88
+ } else if (typeof pageConfig.menu === 'object') {
89
+ menuItems.push({
90
+ id: pageConfig.id,
91
+ label: pageConfig.title,
92
+ path: pageConfig.path,
93
+ icon: pageConfig.icon,
94
+ ...pageConfig.menu,
95
+ });
96
+ }
97
+ }
98
+ pages.push(pageConfig);
99
+ }
100
+ }
101
+
102
+ return { pages, menuItems };
103
+ }
6
104
 
7
105
  /**
8
106
  * Register an admin module with all its components in a single declarative call
9
107
  * @param {Object} config - Module configuration
10
108
  * @param {string} config.id - Unique module identifier (required)
11
109
  * @param {Array} [config.pages] - Custom admin pages
110
+ * @param {string} [config.pagesDir] - Directory path containing page folders/configs
12
111
  * @param {Array} [config.menu] - Sidebar menu items
13
112
  * @param {Object} [config.menuGroups] - Menu group definitions
14
113
  * @param {Object} [config.api] - API endpoint definitions
15
114
  * @param {Array} [config.widgets] - Dashboard widgets
16
115
  * @param {Object} deps - Internal dependencies injected by admin-panel
17
- * @param {Object} deps.registry - AdminRegistry instance
18
- * @param {string} deps.adminPath - Admin panel base path
19
- * @param {Object} deps.ctx - Plugin context
20
- * @param {Function} deps.requireAuth - Auth middleware
21
- * @param {Function} deps.optionalAuth - Optional auth middleware
22
- * @param {Function} deps.serveAdminPanel - SPA HTML handler
23
116
  */
24
117
  function registerModule(config, deps) {
25
118
  if (!config || typeof config !== 'object') {
@@ -36,14 +129,36 @@ function registerModule(config, deps) {
36
129
  registerMenuGroups(config.menuGroups, registry);
37
130
  }
38
131
 
39
- if (config.pages) {
40
- registerPages(config.id, config.pages, { registry, adminPath, ctx, optionalAuth, serveAdminPanel });
132
+ if (config.pages !== undefined) {
133
+ if (!Array.isArray(config.pages)) {
134
+ throw new Error(`Module "${config.id}": pages must be an array`);
135
+ }
136
+ }
137
+
138
+ if (config.menu !== undefined) {
139
+ if (!Array.isArray(config.menu)) {
140
+ throw new Error('menu must be an array');
141
+ }
142
+ }
143
+
144
+ let pagesList = config.pages ? [...config.pages] : [];
145
+ let menuList = config.menu ? [...config.menu] : [];
146
+
147
+ if (config.pagesDir) {
148
+ const { pages: dirPages, menuItems: dirMenuItems } = loadPagesFromDir(config.pagesDir);
149
+ pagesList = pagesList.concat(dirPages);
150
+ menuList = menuList.concat(dirMenuItems);
41
151
  }
42
152
 
43
- if (config.menu) {
44
- registerMenuItems(config.menu, registry);
153
+ if (pagesList.length > 0) {
154
+ registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel });
45
155
  }
46
156
 
157
+ if (menuList.length > 0) {
158
+ registerMenuItems(menuList, registry);
159
+ }
160
+
161
+
47
162
  if (config.api) {
48
163
  registerApiRoutes(config.id, config.api, { adminPath, ctx, requireAuth, optionalAuth });
49
164
  }
@@ -53,6 +168,20 @@ function registerModule(config, deps) {
53
168
  }
54
169
  }
55
170
 
171
+ /**
172
+ * Register custom admin pages from a directory directly
173
+ * @param {string} dirPath - Directory path containing custom page folders/configs
174
+ * @param {Object} deps - Dependencies injected by admin-panel
175
+ */
176
+ function registerPageDir(dirPath, deps) {
177
+ const { pages, menuItems } = loadPagesFromDir(dirPath);
178
+ const moduleId = path.basename(dirPath);
179
+ registerPages(moduleId, pages, deps);
180
+ if (menuItems.length > 0) {
181
+ registerMenuItems(menuItems, deps.registry);
182
+ }
183
+ }
184
+
56
185
  function registerPages(moduleId, pages, deps) {
57
186
  if (!Array.isArray(pages)) {
58
187
  throw new Error(`Module "${moduleId}": pages must be an array`);
@@ -73,10 +202,20 @@ function registerPages(moduleId, pages, deps) {
73
202
  icon: page.icon,
74
203
  description: page.description,
75
204
  permission: page.permission,
205
+ layout: page.layout !== undefined ? page.layout : true,
76
206
  });
77
207
 
78
- if (page.component) {
79
- registry.registerClientComponent(pageId, page.component);
208
+ if (page.componentFile) {
209
+ const resolvedPath = path.resolve(page.componentFile);
210
+ if (!fs.existsSync(resolvedPath)) {
211
+ throw new Error(`Module "${moduleId}", page "${pageId}": componentFile not found at ${page.componentFile}`);
212
+ }
213
+ const rawCode = fs.readFileSync(resolvedPath, 'utf8');
214
+ const formattedCode = formatClientComponent(pageId, rawCode);
215
+ registry.registerClientComponent(pageId, formattedCode);
216
+ } else if (page.component) {
217
+ const formattedCode = formatClientComponent(pageId, page.component);
218
+ registry.registerClientComponent(pageId, formattedCode);
80
219
  }
81
220
 
82
221
  ctx.addRoute('get', `${adminPath}${page.path}`, optionalAuth, serveAdminPanel);
@@ -143,4 +282,5 @@ function registerWidgets(widgets, registry) {
143
282
  }
144
283
  }
145
284
 
146
- module.exports = { registerModule };
285
+ module.exports = { registerModule, registerPageDir, loadPagesFromDir, formatClientComponent };
286
+
@@ -412,9 +412,12 @@ class AdminRegistry {
412
412
  title: p.title,
413
413
  path: p.path,
414
414
  icon: p.icon,
415
+ description: p.description,
415
416
  permission: p.permission,
417
+ layout: p.layout !== undefined ? p.layout : true,
416
418
  hasClientComponent: this.clientComponents.has(p.id),
417
419
  })),
420
+
418
421
  widgets: this.getWidgets().map(w => ({
419
422
  id: w.id,
420
423
  title: w.title,
@@ -223,6 +223,8 @@ module.exports = {
223
223
  FilesUploadField: {
224
224
  oninit: (vnode) => {
225
225
  vnode.state.uploading = false;
226
+ vnode.state.draggedIndex = null;
227
+ vnode.state.dragOverIndex = null;
226
228
  },
227
229
  oncreate: (vnode) => {
228
230
  const { name, meta = {} } = vnode.attrs;
@@ -298,19 +300,63 @@ module.exports = {
298
300
 
299
301
  urls.length > 0
300
302
  ? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
301
- return m('.relative.group.border.border-gray-200.dark:border-slate-700.rounded-lg.p-2.bg-gray-50.dark:bg-slate-900', [
303
+ const isDragOver = state.dragOverIndex === idx;
304
+ const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
305
+ const cursorClass = readonly ? '' : '.cursor-move';
306
+ const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
307
+ const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
308
+
309
+ return m(baseClasses + cursorClass + borderClasses + bgClasses, {
310
+ draggable: !readonly,
311
+ ondragstart: (e) => {
312
+ state.draggedIndex = idx;
313
+ e.dataTransfer.effectAllowed = 'move';
314
+ e.dataTransfer.setData('text/plain', idx);
315
+ },
316
+ ondragover: (e) => {
317
+ e.preventDefault();
318
+ e.dataTransfer.dropEffect = 'move';
319
+ },
320
+ ondragenter: (e) => {
321
+ state.dragOverIndex = idx;
322
+ },
323
+ ondragleave: (e) => {
324
+ if (state.dragOverIndex === idx) {
325
+ state.dragOverIndex = null;
326
+ }
327
+ },
328
+ ondrop: (e) => {
329
+ e.preventDefault();
330
+ const fromIdx = state.draggedIndex;
331
+ const toIdx = idx;
332
+ if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
333
+ const newUrls = [...urls];
334
+ const [movedItem] = newUrls.splice(fromIdx, 1);
335
+ newUrls.splice(toIdx, 0, movedItem);
336
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
337
+ }
338
+ state.draggedIndex = null;
339
+ state.dragOverIndex = null;
340
+ },
341
+ ondragend: (e) => {
342
+ state.draggedIndex = null;
343
+ state.dragOverIndex = null;
344
+ }
345
+ }, [
302
346
  shouldShowImagePreview(url, accept)
303
- ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
347
+ ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
304
348
  m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
305
349
  src: url,
306
350
  alt: `${label} ${idx + 1}`,
307
351
  loading: 'lazy',
352
+ draggable: false,
308
353
  })
309
354
  )
310
355
  : m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
311
356
  href: url,
312
357
  target: '_blank',
313
358
  rel: 'noopener noreferrer',
359
+ draggable: false,
314
360
  }, url.substring(url.lastIndexOf('/') + 1) || url),
315
361
  !readonly
316
362
  ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
@@ -15,7 +15,8 @@ const { registerDashboardWidgets, generateDashboardComponent } = require('./modu
15
15
  const { registerDefaultBulkActions, generateBulkActionsComponent } = require('./modules/bulk-actions');
16
16
  const { registerDefaultPages, createCustomPageApiHandlers, generateCustomPageComponent } = require('./modules/custom-pages');
17
17
  const { registerModelMenuItems, registerSystemMenuItems, generateMenuComponent } = require('./modules/menu');
18
- const { registerModule } = require('./core/admin-module');
18
+ const { registerModule, registerPageDir } = require('./core/admin-module');
19
+
19
20
 
20
21
  /**
21
22
  * Admin Panel Plugin Factory
@@ -105,6 +106,21 @@ function adminPanelPlugin(options = {}) {
105
106
  serveAdminPanel,
106
107
  });
107
108
  },
109
+ registerPageDir(dirPath) {
110
+ const ctx = this.api?._ctx ?? this._ctx;
111
+ if (!ctx) {
112
+ throw new Error('registerPageDir can only be called during or after onRoutesReady');
113
+ }
114
+ return registerPageDir(dirPath, {
115
+ registry,
116
+ adminPath,
117
+ ctx,
118
+ requireAuth,
119
+ optionalAuth,
120
+ serveAdminPanel,
121
+ });
122
+ },
123
+
108
124
  _ctx: null,
109
125
  },
110
126