create-middag-ui 0.17.0 → 0.18.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/cli.js +16 -5
- package/lib/scaffold.js +227 -15
- package/lib/templates/shared/moodle-admin-shell.tsx +108 -0
- package/lib/templates/shared/page-resolver-moodle.tsx +86 -0
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
scaffoldDemoFiles,
|
|
30
30
|
scaffoldDevShell,
|
|
31
31
|
scaffoldEslintConfig,
|
|
32
|
+
scaffoldGitignore,
|
|
33
|
+
scaffoldInjectPlaceholders,
|
|
32
34
|
scaffoldFreeAdapters,
|
|
33
35
|
scaffoldFreeApp,
|
|
34
36
|
scaffoldFreeRegister,
|
|
@@ -38,6 +40,8 @@ import {
|
|
|
38
40
|
scaffoldHostViteConfig,
|
|
39
41
|
scaffoldIndexHtml,
|
|
40
42
|
scaffoldMoodleAdapters,
|
|
43
|
+
scaffoldMoodleAdminShell,
|
|
44
|
+
scaffoldMoodlePageResolver,
|
|
41
45
|
scaffoldMoodlePlugin,
|
|
42
46
|
scaffoldMoodleTailwind,
|
|
43
47
|
scaffoldPackageJson,
|
|
@@ -166,11 +170,13 @@ if (!dirCreated) {
|
|
|
166
170
|
|
|
167
171
|
heading(5, TOTAL_STEPS, "Scaffolding config files");
|
|
168
172
|
|
|
173
|
+
const isPro = registryPath === "github";
|
|
169
174
|
scaffoldPackageJson(targetDir, host, cwd, registryPath, hostKey);
|
|
170
175
|
scaffoldTsconfig(targetDir);
|
|
171
176
|
scaffoldViteConfig(targetDir, host, registryPath);
|
|
172
|
-
scaffoldEslintConfig(targetDir);
|
|
177
|
+
scaffoldEslintConfig(targetDir, isPro);
|
|
173
178
|
scaffoldPrettierConfig(targetDir);
|
|
179
|
+
scaffoldGitignore(targetDir, isPro);
|
|
174
180
|
scaffoldIndexHtml(targetDir);
|
|
175
181
|
|
|
176
182
|
// ── Step 6: Scaffold ~/.npmrc (GitHub path only) ─────────────────────────
|
|
@@ -192,13 +198,16 @@ scaffoldDemoFiles(targetDir);
|
|
|
192
198
|
|
|
193
199
|
// ── Step 8: Scaffold app + page examples (PRO vs FREE) ─────────────────
|
|
194
200
|
|
|
195
|
-
const isPro = registryPath === "github";
|
|
196
201
|
heading(8, TOTAL_STEPS, `Creating ${isPro ? "PRO" : "FREE"} UI module`);
|
|
197
202
|
|
|
198
203
|
scaffoldPageExamples(targetDir);
|
|
199
204
|
|
|
200
|
-
//
|
|
201
|
-
|
|
205
|
+
// Page resolver — Moodle gets AMD version (RequireJS + extension globs)
|
|
206
|
+
if (hostKey === "moodle") {
|
|
207
|
+
scaffoldMoodlePageResolver(targetDir);
|
|
208
|
+
} else {
|
|
209
|
+
scaffoldPageResolver(targetDir);
|
|
210
|
+
}
|
|
202
211
|
scaffoldDemoDirectPage(targetDir);
|
|
203
212
|
// Convert frankenstyle "local_middag" → Moodle path "local/middag"
|
|
204
213
|
const moodlePath = moodleComponent ? moodleComponent.replace("_", "/") : null;
|
|
@@ -217,6 +226,7 @@ if (isPro) {
|
|
|
217
226
|
// PRO Inertia adapters — re-export from @middag-io/react/mock so usePage()
|
|
218
227
|
// shares the same React context as MockPageProvider (no context mismatch).
|
|
219
228
|
scaffoldProAdapters(targetDir);
|
|
229
|
+
scaffoldInjectPlaceholders(targetDir);
|
|
220
230
|
success("PRO: using MockProductShell from @middag-io/react/mock");
|
|
221
231
|
} catch {
|
|
222
232
|
// npm version — PRO file excluded, fall back to FREE
|
|
@@ -245,7 +255,8 @@ if (hostKey === "moodle" && moodleComponent) {
|
|
|
245
255
|
scaffoldMoodlePlugin(targetDir, moodleComponent);
|
|
246
256
|
scaffoldMoodleTailwind(targetDir);
|
|
247
257
|
scaffoldMoodleAdapters(targetDir, moodleComponent);
|
|
248
|
-
|
|
258
|
+
scaffoldMoodleAdminShell(targetDir);
|
|
259
|
+
success(`Moodle: AMD plugin + Tailwind + adapters + AdminShell for ${ moodleComponent }`);
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
// ── Step 9: npm install ──────────────────────────────────────────────────
|
package/lib/scaffold.js
CHANGED
|
@@ -127,17 +127,30 @@ export function scaffoldPackageJson(targetDir, host, cwd, registryPath, hostKey)
|
|
|
127
127
|
if (hostKey === "wordpress") {
|
|
128
128
|
scripts["build:wp"] = "vite build --config vite.config.wordpress.ts";
|
|
129
129
|
scripts["watch:wp"] = "vite build --config vite.config.wordpress.ts --watch";
|
|
130
|
+
if (isPro) {
|
|
131
|
+
scripts["build:licensed"] =
|
|
132
|
+
"vite build --config vite.config.wordpress.ts && node scripts/inject-placeholders.mjs";
|
|
133
|
+
}
|
|
130
134
|
} else if (hostKey === "moodle") {
|
|
131
135
|
scripts["build:moodle"] = "vite build --config vite.config.moodle.ts";
|
|
132
136
|
scripts["watch:moodle"] = "vite build --config vite.config.moodle.ts --watch";
|
|
137
|
+
if (isPro) {
|
|
138
|
+
scripts["build:licensed"] =
|
|
139
|
+
"vite build --config vite.config.moodle.ts && node scripts/inject-placeholders.mjs";
|
|
140
|
+
}
|
|
133
141
|
} else if (hostKey === "custom") {
|
|
134
142
|
scripts["build:host"] = "vite build --config vite.config.custom.ts";
|
|
135
143
|
scripts["watch:host"] = "vite build --config vite.config.custom.ts --watch";
|
|
144
|
+
if (isPro) {
|
|
145
|
+
scripts["build:licensed"] =
|
|
146
|
+
"vite build --config vite.config.custom.ts && node scripts/inject-placeholders.mjs";
|
|
147
|
+
}
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
const pkg = {
|
|
139
151
|
name: `${projectName}-ui`,
|
|
140
152
|
private: true,
|
|
153
|
+
version: "0.1.0",
|
|
141
154
|
type: "module",
|
|
142
155
|
scripts,
|
|
143
156
|
dependencies: deps,
|
|
@@ -190,6 +203,11 @@ export function scaffoldTsconfig(targetDir) {
|
|
|
190
203
|
};
|
|
191
204
|
|
|
192
205
|
writeFile(filePath, JSON.stringify(tsconfig, null, 2) + "\n", "tsconfig.json");
|
|
206
|
+
|
|
207
|
+
const envFilePath = join(targetDir, "src", "vite-env.d.ts");
|
|
208
|
+
if (!skipIfExists(envFilePath, "src/vite-env.d.ts")) {
|
|
209
|
+
writeFile(envFilePath, `/// <reference types="vite/client" />\n`, "src/vite-env.d.ts");
|
|
210
|
+
}
|
|
193
211
|
}
|
|
194
212
|
|
|
195
213
|
/**
|
|
@@ -241,10 +259,13 @@ export default defineConfig({
|
|
|
241
259
|
/**
|
|
242
260
|
* Scaffold eslint.config.js.
|
|
243
261
|
*/
|
|
244
|
-
export function scaffoldEslintConfig(targetDir) {
|
|
262
|
+
export function scaffoldEslintConfig(targetDir, isPro = false) {
|
|
245
263
|
const filePath = join(targetDir, "eslint.config.js");
|
|
246
264
|
if (skipIfExists(filePath, "eslint.config.js")) return;
|
|
247
265
|
|
|
266
|
+
const ignores = ["dist/", "node_modules/", "scripts/", ...(isPro ? ["dist-licensed/"] : [])];
|
|
267
|
+
const ignoresStr = ignores.map((i) => `"${i}"`).join(", ");
|
|
268
|
+
|
|
248
269
|
writeFile(
|
|
249
270
|
filePath,
|
|
250
271
|
`import js from "@eslint/js";
|
|
@@ -254,7 +275,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
|
|
|
254
275
|
import prettierConfig from "eslint-config-prettier";
|
|
255
276
|
|
|
256
277
|
export default tseslint.config(
|
|
257
|
-
{ ignores: [
|
|
278
|
+
{ ignores: [${ignoresStr}] },
|
|
258
279
|
js.configs.recommended,
|
|
259
280
|
...tseslint.configs.recommended,
|
|
260
281
|
{
|
|
@@ -264,7 +285,8 @@ export default tseslint.config(
|
|
|
264
285
|
"react-refresh": reactRefresh,
|
|
265
286
|
},
|
|
266
287
|
rules: {
|
|
267
|
-
|
|
288
|
+
"react-hooks/rules-of-hooks": "error",
|
|
289
|
+
"react-hooks/exhaustive-deps": "warn",
|
|
268
290
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
|
269
291
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
|
270
292
|
"@typescript-eslint/no-explicit-any": "warn",
|
|
@@ -294,6 +316,7 @@ export function scaffoldPrettierConfig(targetDir) {
|
|
|
294
316
|
trailingComma: "all",
|
|
295
317
|
printWidth: 100,
|
|
296
318
|
plugins: ["prettier-plugin-tailwindcss"],
|
|
319
|
+
tailwindFunctions: ["cn", "cva"],
|
|
297
320
|
},
|
|
298
321
|
null,
|
|
299
322
|
2,
|
|
@@ -302,6 +325,99 @@ export function scaffoldPrettierConfig(targetDir) {
|
|
|
302
325
|
);
|
|
303
326
|
}
|
|
304
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Scaffold .gitignore with UI-specific dist patterns.
|
|
330
|
+
*/
|
|
331
|
+
export function scaffoldGitignore(targetDir, isPro = false) {
|
|
332
|
+
const filePath = join(targetDir, ".gitignore");
|
|
333
|
+
if (skipIfExists(filePath, ".gitignore")) return;
|
|
334
|
+
|
|
335
|
+
const lines = [
|
|
336
|
+
"node_modules/",
|
|
337
|
+
"dist/",
|
|
338
|
+
...(isPro ? ["dist-licensed/"] : []),
|
|
339
|
+
"dist-master/",
|
|
340
|
+
"dist-mock/",
|
|
341
|
+
"dist-lib/",
|
|
342
|
+
"*.log",
|
|
343
|
+
".vite/",
|
|
344
|
+
];
|
|
345
|
+
writeFile(filePath, lines.join("\n") + "\n", ".gitignore");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Scaffold scripts/inject-placeholders.mjs (PRO only).
|
|
350
|
+
* Injects opaque placeholder variables into middag-* bundles for CDN delivery.
|
|
351
|
+
* The Cloudflare Worker replaces placeholders with per-installation values.
|
|
352
|
+
*/
|
|
353
|
+
export function scaffoldInjectPlaceholders(targetDir) {
|
|
354
|
+
const scriptsDir = join(targetDir, "scripts");
|
|
355
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
356
|
+
const filePath = join(scriptsDir, "inject-placeholders.mjs");
|
|
357
|
+
if (skipIfExists(filePath, "scripts/inject-placeholders.mjs")) return;
|
|
358
|
+
|
|
359
|
+
writeFile(
|
|
360
|
+
filePath,
|
|
361
|
+
`/**
|
|
362
|
+
* inject-placeholders — generate licensed bundles for CDN delivery.
|
|
363
|
+
*
|
|
364
|
+
* Takes compiled Vite output from dist/ and produces dist-licensed/ with
|
|
365
|
+
* placeholder variables injected into middag-* bundles. The Cloudflare
|
|
366
|
+
* Worker replaces these placeholders with real values per installation.
|
|
367
|
+
*
|
|
368
|
+
* Usage: node scripts/inject-placeholders.mjs
|
|
369
|
+
* Called by: npm run build:licensed
|
|
370
|
+
*/
|
|
371
|
+
import fs from "node:fs";
|
|
372
|
+
import path from "node:path";
|
|
373
|
+
|
|
374
|
+
const DIST_DIR = path.resolve(import.meta.dirname, "../dist");
|
|
375
|
+
const LICENSED_DIR = path.resolve(import.meta.dirname, "../dist-licensed");
|
|
376
|
+
|
|
377
|
+
// Variable names are intentionally opaque to discourage reverse engineering.
|
|
378
|
+
// The mapping is documented only here and in the Worker source:
|
|
379
|
+
// _0x7a3f → wwwroot (replaced by Worker with installation URL)
|
|
380
|
+
// _0x9b1e → expiry (replaced by Worker with unix timestamp)
|
|
381
|
+
// _0x4d2c → tier (replaced by Worker with license tier)
|
|
382
|
+
// _0x6e8a → domain hash (replaced by Worker with HMAC of wwwroot)
|
|
383
|
+
const PLACEHOLDER_HEADER = [
|
|
384
|
+
'var _0x7a3f="__PH_0x7a3f__";',
|
|
385
|
+
"var _0x9b1e=0;",
|
|
386
|
+
'var _0x4d2c="__PH_0x4d2c__";',
|
|
387
|
+
'var _0x6e8a="__PH_0x6e8a__";',
|
|
388
|
+
].join("") + "\\n";
|
|
389
|
+
|
|
390
|
+
if (fs.existsSync(LICENSED_DIR)) fs.rmSync(LICENSED_DIR, { recursive: true });
|
|
391
|
+
fs.mkdirSync(LICENSED_DIR, { recursive: true });
|
|
392
|
+
|
|
393
|
+
const files = fs.readdirSync(DIST_DIR).filter((f) => f.endsWith(".js"));
|
|
394
|
+
let injected = 0;
|
|
395
|
+
let copied = 0;
|
|
396
|
+
|
|
397
|
+
for (const file of files) {
|
|
398
|
+
const srcPath = path.join(DIST_DIR, file);
|
|
399
|
+
const destPath = path.join(LICENSED_DIR, file);
|
|
400
|
+
const content = fs.readFileSync(srcPath, "utf-8");
|
|
401
|
+
|
|
402
|
+
if (file.startsWith("middag-")) {
|
|
403
|
+
fs.writeFileSync(destPath, PLACEHOLDER_HEADER + content);
|
|
404
|
+
injected++;
|
|
405
|
+
console.log(\`[licensed] \${file} — placeholders injected\`);
|
|
406
|
+
} else {
|
|
407
|
+
fs.copyFileSync(srcPath, destPath);
|
|
408
|
+
copied++;
|
|
409
|
+
console.log(\`[licensed] \${file} — copied (vendor/shared)\`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
console.log(\`\\nLicensed bundles ready in \${LICENSED_DIR}/\`);
|
|
414
|
+
console.log(\` \${injected} bundle(s) with placeholders\`);
|
|
415
|
+
console.log(\` \${copied} bundle(s) copied as-is\`);
|
|
416
|
+
`,
|
|
417
|
+
"scripts/inject-placeholders.mjs",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
305
421
|
/**
|
|
306
422
|
* Scaffold index.html at project root.
|
|
307
423
|
*/
|
|
@@ -1375,26 +1491,91 @@ export function scaffoldHostEntry(targetDir, hostKey) {
|
|
|
1375
1491
|
});
|
|
1376
1492
|
observer.observe(document.body, { childList: true, subtree: true });`;
|
|
1377
1493
|
} else if (hostKey === "moodle") {
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1494
|
+
// Moodle uses a dedicated content block — see below.
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Moodle: fully separate entry (asyncResolver, full provider stack, theme init, AdminShell)
|
|
1498
|
+
if (hostKey === "moodle") {
|
|
1499
|
+
const moodleContent = `/**
|
|
1500
|
+
* Production entry point for Moodle.
|
|
1501
|
+
*
|
|
1502
|
+
* AMD module loaded by Moodle's RequireJS via js_call_amd().
|
|
1503
|
+
* Uses real createInertiaApp — Moodle serves the HTML and page props.
|
|
1504
|
+
* This is the build target for \`npm run build\`.
|
|
1505
|
+
*
|
|
1506
|
+
* NOT used by \`npm run dev\` — that uses src/main.tsx with mock adapters.
|
|
1507
|
+
*/
|
|
1508
|
+
import "./tailwind.css";
|
|
1509
|
+
import "@fontsource-variable/figtree";
|
|
1510
|
+
|
|
1511
|
+
import { createRoot } from "react-dom/client";
|
|
1512
|
+
import { createInertiaApp } from "@inertiajs/react";
|
|
1513
|
+
import {
|
|
1514
|
+
I18nProvider,
|
|
1515
|
+
ProgressProvider,
|
|
1516
|
+
AuthProvider,
|
|
1517
|
+
FlashProvider,
|
|
1518
|
+
ScopeProvider,
|
|
1519
|
+
setAppearance,
|
|
1520
|
+
getStoredAppearance,
|
|
1521
|
+
getEffectiveTheme,
|
|
1522
|
+
onSystemThemeChange,
|
|
1523
|
+
} from "@middag-io/react";
|
|
1524
|
+
import "@middag-io/react/style.css";
|
|
1525
|
+
import { getString } from "./lib/moodle/strings";
|
|
1526
|
+
import { registerDefaults } from "./app/register";
|
|
1527
|
+
import { resolvePageComponent } from "./app/page-resolver";
|
|
1528
|
+
|
|
1529
|
+
registerDefaults();
|
|
1530
|
+
setAppearance(getStoredAppearance());
|
|
1531
|
+
onSystemThemeChange();
|
|
1532
|
+
|
|
1533
|
+
createInertiaApp({
|
|
1534
|
+
id: "middag-app",
|
|
1535
|
+
resolve: (name) => resolvePageComponent(name),
|
|
1536
|
+
setup({ el, App, props }) {
|
|
1537
|
+
el.classList.add("middag-root");
|
|
1538
|
+
el.classList.add("middag-active");
|
|
1539
|
+
el.setAttribute("data-theme", getEffectiveTheme(getStoredAppearance()));
|
|
1381
1540
|
|
|
1382
1541
|
// Portal container for Radix UI (modals, popovers, toasts).
|
|
1383
1542
|
// Radix portals default to document.body which is outside .middag-root,
|
|
1384
|
-
//
|
|
1385
|
-
// container with .middag-root so portal content inherits design tokens.
|
|
1543
|
+
// so this sibling container ensures portal content inherits design tokens.
|
|
1386
1544
|
if (!document.getElementById("middag-portals")) {
|
|
1387
|
-
const
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
document.body.appendChild(
|
|
1391
|
-
}
|
|
1545
|
+
const portals = document.createElement("div");
|
|
1546
|
+
portals.id = "middag-portals";
|
|
1547
|
+
portals.classList.add("middag-root");
|
|
1548
|
+
document.body.appendChild(portals);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
createRoot(el).render(
|
|
1552
|
+
<ProgressProvider>
|
|
1553
|
+
<App {...props}>
|
|
1554
|
+
{({ Component, props: pageProps, key }) => (
|
|
1555
|
+
<AuthProvider>
|
|
1556
|
+
<ScopeProvider>
|
|
1557
|
+
<FlashProvider>
|
|
1558
|
+
<I18nProvider asyncResolver={getString}>
|
|
1559
|
+
<Component key={key} {...pageProps} />
|
|
1560
|
+
</I18nProvider>
|
|
1561
|
+
</FlashProvider>
|
|
1562
|
+
</ScopeProvider>
|
|
1563
|
+
</AuthProvider>
|
|
1564
|
+
)}
|
|
1565
|
+
</App>
|
|
1566
|
+
</ProgressProvider>,
|
|
1567
|
+
);
|
|
1568
|
+
},
|
|
1569
|
+
});
|
|
1570
|
+
`;
|
|
1571
|
+
writeFile(filePath, moodleContent, label);
|
|
1572
|
+
return;
|
|
1392
1573
|
}
|
|
1393
1574
|
|
|
1394
1575
|
const content = `/**
|
|
1395
|
-
* Production entry point for ${hostKey === "wordpress" ? "WordPress" :
|
|
1576
|
+
* Production entry point for ${hostKey === "wordpress" ? "WordPress" : "custom host"}.
|
|
1396
1577
|
*
|
|
1397
|
-
* Uses real createInertiaApp — the host platform (${hostKey === "wordpress" ? "WP" :
|
|
1578
|
+
* Uses real createInertiaApp — the host platform (${hostKey === "wordpress" ? "WP" : "your backend"})
|
|
1398
1579
|
* serves the HTML and Inertia page props. This file is the build target
|
|
1399
1580
|
* for \`npm run build:${hostKey === "custom" ? "host" : hostKey}\`.
|
|
1400
1581
|
*
|
|
@@ -1996,6 +2177,37 @@ export function scaffoldPageResolver(targetDir) {
|
|
|
1996
2177
|
writeFile(filePath, readTemplate("templates/shared/page-resolver.tsx"), "src/app/page-resolver.tsx");
|
|
1997
2178
|
}
|
|
1998
2179
|
|
|
2180
|
+
/**
|
|
2181
|
+
* Scaffold Moodle AMD page resolver: src/app/page-resolver.tsx.
|
|
2182
|
+
*
|
|
2183
|
+
* Overwrites the generic resolver with a Moodle-specific version that supports:
|
|
2184
|
+
* - Eager core pages (../extensions/core/pages/**)
|
|
2185
|
+
* - Lazy non-core extension pages (separate AMD chunks)
|
|
2186
|
+
* - External plugin pages via RequireJS (frankenstyle prefix)
|
|
2187
|
+
*
|
|
2188
|
+
* @param {string} targetDir - Absolute path to UI project root
|
|
2189
|
+
*/
|
|
2190
|
+
export function scaffoldMoodlePageResolver(targetDir) {
|
|
2191
|
+
ensureDir(join(targetDir, "src", "app"));
|
|
2192
|
+
const filePath = join(targetDir, "src", "app", "page-resolver.tsx");
|
|
2193
|
+
writeFile(filePath, readTemplate("templates/shared/page-resolver-moodle.tsx"), "src/app/page-resolver.tsx (Moodle AMD)");
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Scaffold Moodle admin shell: src/shells/AdminShell.tsx.
|
|
2198
|
+
*
|
|
2199
|
+
* Moodle-specific shell that supports the admin_tabs shared prop.
|
|
2200
|
+
* Registered as 'admin' shell in src/entry-moodle.tsx.
|
|
2201
|
+
*
|
|
2202
|
+
* @param {string} targetDir - Absolute path to UI project root
|
|
2203
|
+
*/
|
|
2204
|
+
export function scaffoldMoodleAdminShell(targetDir) {
|
|
2205
|
+
ensureDir(join(targetDir, "src", "shells"));
|
|
2206
|
+
const filePath = join(targetDir, "src", "shells", "AdminShell.tsx");
|
|
2207
|
+
if (skipIfExists(filePath, "src/shells/AdminShell.tsx")) return;
|
|
2208
|
+
writeFile(filePath, readTemplate("templates/shared/moodle-admin-shell.tsx"), "src/shells/AdminShell.tsx");
|
|
2209
|
+
}
|
|
2210
|
+
|
|
1999
2211
|
/**
|
|
2000
2212
|
* Scaffold route helper: src/lib/routes.ts.
|
|
2001
2213
|
* Abstracts host admin URL vs dev mock path.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminShell — Moodle-specific admin shell.
|
|
3
|
+
*
|
|
4
|
+
* Extends ProductShell architecture with an optional admin tab bar
|
|
5
|
+
* rendered when the `admin_tabs` Inertia shared prop is present.
|
|
6
|
+
*
|
|
7
|
+
* Registered as 'admin' shell in src/entry-moodle.tsx.
|
|
8
|
+
* Edit this file to customise the admin sidebar header, tab bar, or layout.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, type ReactElement } from "react";
|
|
11
|
+
import { Link, usePage } from "@inertiajs/react";
|
|
12
|
+
import {
|
|
13
|
+
Toaster,
|
|
14
|
+
Sidebar,
|
|
15
|
+
SidebarHeader,
|
|
16
|
+
SidebarProvider,
|
|
17
|
+
useSidebar,
|
|
18
|
+
SidebarNav,
|
|
19
|
+
PageHeader,
|
|
20
|
+
NavErrorBoundary,
|
|
21
|
+
type ShellProps,
|
|
22
|
+
type SharedProps,
|
|
23
|
+
type PageMeta,
|
|
24
|
+
type AdminTabsProps,
|
|
25
|
+
} from "@middag-io/react";
|
|
26
|
+
import { Tabs, TabsList, TabsTrigger } from "@middag-io/react/reui/tabs";
|
|
27
|
+
|
|
28
|
+
// ── Inner shell (must be inside SidebarProvider) ──────────────────────────────
|
|
29
|
+
|
|
30
|
+
function AdminShellInner({ children }: ShellProps): ReactElement {
|
|
31
|
+
const { setOpenMobile } = useSidebar();
|
|
32
|
+
const { props } = usePage<SharedProps>();
|
|
33
|
+
|
|
34
|
+
const page: PageMeta = (props as SharedProps & { contract?: { page?: PageMeta } }).contract
|
|
35
|
+
?.page ?? { key: "unknown", title: "", breadcrumbs: [], actions: [] };
|
|
36
|
+
|
|
37
|
+
const adminTabs = (props as SharedProps & { admin_tabs?: AdminTabsProps }).admin_tabs;
|
|
38
|
+
|
|
39
|
+
const handleMobileMenuClick = useCallback(() => {
|
|
40
|
+
setOpenMobile(true);
|
|
41
|
+
}, [setOpenMobile]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<Sidebar
|
|
46
|
+
aria-label="Navegação"
|
|
47
|
+
collapsible="icon"
|
|
48
|
+
className="border-sidebar-border bg-sidebar border-r"
|
|
49
|
+
>
|
|
50
|
+
<SidebarHeader className="border-sidebar-border border-b px-4 py-3">
|
|
51
|
+
{/* Replace with your plugin name or logo */}
|
|
52
|
+
<p className="text-sidebar-foreground text-sm font-semibold">Admin</p>
|
|
53
|
+
</SidebarHeader>
|
|
54
|
+
|
|
55
|
+
<NavErrorBoundary>
|
|
56
|
+
<SidebarNav />
|
|
57
|
+
</NavErrorBoundary>
|
|
58
|
+
</Sidebar>
|
|
59
|
+
|
|
60
|
+
<div className="admin-shell__content flex min-h-screen flex-1 flex-col overflow-hidden">
|
|
61
|
+
<NavErrorBoundary>
|
|
62
|
+
<PageHeader page={page} onMobileMenuClick={handleMobileMenuClick} />
|
|
63
|
+
</NavErrorBoundary>
|
|
64
|
+
|
|
65
|
+
{adminTabs && (
|
|
66
|
+
<div className="border-b px-6">
|
|
67
|
+
<Tabs value={adminTabs.active}>
|
|
68
|
+
<TabsList variant="line" className="w-full justify-start">
|
|
69
|
+
{adminTabs.items.map((tab) => (
|
|
70
|
+
<TabsTrigger key={tab.key} value={tab.key} asChild>
|
|
71
|
+
<Link href={tab.href} preserveState>
|
|
72
|
+
{tab.label}
|
|
73
|
+
</Link>
|
|
74
|
+
</TabsTrigger>
|
|
75
|
+
))}
|
|
76
|
+
</TabsList>
|
|
77
|
+
</Tabs>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<main className="flex-1 overflow-auto p-6" aria-live="polite" aria-busy="false">
|
|
82
|
+
{children}
|
|
83
|
+
</main>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<Toaster position="bottom-right" richColors />
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── AdminShell — public export ──────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export function AdminShell({ children }: ShellProps): ReactElement {
|
|
94
|
+
return (
|
|
95
|
+
<SidebarProvider
|
|
96
|
+
defaultOpen={true}
|
|
97
|
+
style={
|
|
98
|
+
{
|
|
99
|
+
"--sidebar-width": "var(--sidebar-width)",
|
|
100
|
+
"--sidebar-width-icon": "var(--sidebar-width-collapsed)",
|
|
101
|
+
} as React.CSSProperties
|
|
102
|
+
}
|
|
103
|
+
className="bg-background text-foreground flex min-h-screen"
|
|
104
|
+
>
|
|
105
|
+
<AdminShellInner>{children}</AdminShellInner>
|
|
106
|
+
</SidebarProvider>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* page-resolver — Moodle AMD page resolver.
|
|
3
|
+
*
|
|
4
|
+
* Three resolution modes:
|
|
5
|
+
*
|
|
6
|
+
* 1. Core pages (no prefix): "admin/Dashboard" resolves to
|
|
7
|
+
* "../extensions/core/pages/admin/Dashboard.tsx" via eager glob.
|
|
8
|
+
* Bundled into middag-app.js.
|
|
9
|
+
*
|
|
10
|
+
* 2. Extension pages (extension prefix): "ecommerce/pages/Index" resolves
|
|
11
|
+
* to "../extensions/ecommerce/pages/Index.tsx" via lazy glob.
|
|
12
|
+
* Each non-core extension gets its own AMD chunk.
|
|
13
|
+
*
|
|
14
|
+
* 3. External plugin pages (frankenstyle prefix): "local_yourplugin/admin/Index"
|
|
15
|
+
* loads the AMD module via RequireJS. Any Moodle plugin can register
|
|
16
|
+
* React pages in the MIDDAG shell using this mechanism.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Core pages — eager (bundled into middag-app.js)
|
|
20
|
+
const corePages = import.meta.glob("../extensions/core/pages/**/*.tsx", { eager: true }) as Record<
|
|
21
|
+
string,
|
|
22
|
+
Record<string, unknown>
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
// Non-core extension pages — lazy (separate AMD chunks)
|
|
26
|
+
const extensionPages = import.meta.glob(
|
|
27
|
+
["../extensions/*/pages/**/*.tsx", "!../extensions/core/**"],
|
|
28
|
+
{ eager: false },
|
|
29
|
+
) as Record<string, () => Promise<Record<string, unknown>>>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Frankenstyle pattern: type_name (e.g. local_yourplugin, mod_assign).
|
|
33
|
+
* A name like "local_yourplugin/admin/Index" is external.
|
|
34
|
+
* A name like "admin/Dashboard" is local (no underscore in first segment).
|
|
35
|
+
*/
|
|
36
|
+
function isExternalPage(name: string): boolean {
|
|
37
|
+
const firstSegment = name.split("/")[0];
|
|
38
|
+
return /^[a-z]+_[a-z]/.test(firstSegment);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load an external plugin page via RequireJS (Moodle AMD).
|
|
43
|
+
*
|
|
44
|
+
* The plugin must build its page as an AMD module that exports a
|
|
45
|
+
* default React component. The module ID matches the page name directly
|
|
46
|
+
* (e.g. "local_yourplugin/admin/Index").
|
|
47
|
+
*/
|
|
48
|
+
function loadExternalPage(name: string): Promise<Record<string, unknown>> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- RequireJS global
|
|
51
|
+
const req = (window as any).require;
|
|
52
|
+
if (!req) {
|
|
53
|
+
reject(new Error(`RequireJS not available. Cannot load external page: ${name}`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
req(
|
|
57
|
+
[name],
|
|
58
|
+
(mod: Record<string, unknown>) => resolve(mod),
|
|
59
|
+
(err: Error) => reject(new Error(`Failed to load external page "${name}": ${err.message}`)),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolvePageComponent(name: string) {
|
|
65
|
+
if (isExternalPage(name)) {
|
|
66
|
+
return loadExternalPage(name).then((mod) => mod.default ?? mod);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Try core pages first (eager/sync)
|
|
70
|
+
const corePath = `../extensions/core/pages/${name}.tsx`;
|
|
71
|
+
const coreMod = corePages[corePath];
|
|
72
|
+
if (coreMod) {
|
|
73
|
+
return coreMod.default ?? coreMod;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Try non-core extension pages (lazy/async)
|
|
77
|
+
const extPath = `../extensions/${name}.tsx`;
|
|
78
|
+
const extLoader = extensionPages[extPath];
|
|
79
|
+
if (extLoader) {
|
|
80
|
+
return extLoader().then((mod) => mod.default ?? mod);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Page not found: ${name}. Available: ${[...Object.keys(corePages), ...Object.keys(extensionPages)].join(", ")}`,
|
|
85
|
+
);
|
|
86
|
+
}
|