@madojs/mado 0.5.0 → 0.6.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/AGENTS.md +49 -1
- package/CHANGELOG.md +188 -0
- package/MADO_V1_PLAN.md +179 -0
- package/README.md +53 -14
- package/ROADMAP.md +36 -5
- package/TODO.md +72 -0
- package/dist/src/forms.d.ts +41 -7
- package/dist/src/forms.js +334 -59
- package/dist/src/forms.js.map +1 -1
- package/dist/src/html/bindings.d.ts +41 -0
- package/dist/src/html/bindings.js +163 -6
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html.d.ts +2 -0
- package/dist/src/html.js +1 -0
- package/dist/src/html.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +56 -0
- package/dist/src/page.js +17 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/router/manifest.d.ts +16 -1
- package/dist/src/router/manifest.js +181 -38
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +7 -2
- package/dist/src/router/match.js +14 -4
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.d.ts +10 -0
- package/dist/src/router/navigation.js +73 -12
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/signal.d.ts +15 -1
- package/dist/src/signal.js +112 -16
- package/dist/src/signal.js.map +1 -1
- package/docs/en/02-project-layout.md +99 -40
- package/docs/en/05-why-mado.md +1 -1
- package/docs/en/06-for-backenders.md +1 -1
- package/docs/en/07-llm-pitfalls.md +1 -1
- package/docs/en/09-shadow-vs-light-dom.md +60 -0
- package/docs/en/10-app-architecture.md +141 -0
- package/docs/en/11-layouts.md +115 -0
- package/docs/en/12-auth-and-api.md +217 -0
- package/docs/en/13-deployment.md +192 -0
- package/docs/en/14-testing.md +82 -0
- package/docs/en/15-error-handling.md +100 -0
- package/docs/en/16-bake-cookbook.md +93 -0
- package/docs/en/README.md +7 -0
- package/docs/fr/05-why-mado.md +1 -1
- package/docs/fr/06-for-backenders.md +1 -1
- package/docs/fr/07-llm-pitfalls.md +1 -1
- package/docs/fr/09-shadow-vs-light-dom.md +63 -0
- package/docs/fr/10-app-architecture.md +61 -0
- package/docs/fr/11-layouts.md +35 -0
- package/docs/fr/12-auth-and-api.md +35 -0
- package/docs/fr/13-deployment.md +39 -0
- package/docs/fr/14-testing.md +41 -0
- package/docs/fr/15-error-handling.md +50 -0
- package/docs/fr/16-bake-cookbook.md +35 -0
- package/docs/fr/README.md +7 -0
- package/docs/ru/05-why-mado.md +2 -2
- package/docs/ru/06-for-backenders.md +1 -1
- package/docs/ru/09-shadow-vs-light-dom.md +60 -0
- package/docs/ru/10-app-architecture.md +100 -0
- package/docs/ru/11-layouts.md +47 -0
- package/docs/ru/12-auth-and-api.md +53 -0
- package/docs/ru/13-deployment.md +60 -0
- package/docs/ru/14-testing.md +50 -0
- package/docs/ru/15-error-handling.md +56 -0
- package/docs/ru/16-bake-cookbook.md +55 -0
- package/docs/ru/README.md +7 -0
- package/docs/uk/06-for-backenders.md +2 -2
- package/docs/uk/09-shadow-vs-light-dom.md +91 -24
- package/docs/uk/10-app-architecture.md +56 -0
- package/docs/uk/11-layouts.md +34 -0
- package/docs/uk/12-auth-and-api.md +34 -0
- package/docs/uk/13-deployment.md +39 -0
- package/docs/uk/14-testing.md +34 -0
- package/docs/uk/15-error-handling.md +32 -0
- package/docs/uk/16-bake-cookbook.md +36 -0
- package/docs/uk/README.md +7 -0
- package/llms.txt +24 -1
- package/package.json +3 -1
- package/scripts/_config.mjs +224 -0
- package/scripts/bake.mjs +217 -120
- package/scripts/bundle.mjs +110 -67
- package/scripts/cli.mjs +127 -16
- package/scripts/preview.mjs +22 -12
- package/server/serve.mjs +101 -11
- package/starters/admin/README.md +63 -0
- package/starters/admin/index.html +21 -0
- package/starters/admin/mado.config.json +22 -0
- package/starters/admin/package.json +22 -0
- package/starters/admin/public/favicon.svg +4 -0
- package/starters/admin/src/components/x-button.ts +55 -0
- package/starters/admin/src/components/x-input.ts +74 -0
- package/starters/admin/src/layouts/app.ts +101 -0
- package/starters/admin/src/layouts/auth.ts +41 -0
- package/starters/admin/src/lib/api.ts +133 -0
- package/starters/admin/src/lib/auth.ts +83 -0
- package/starters/admin/src/main.ts +15 -0
- package/starters/admin/src/pages/admin/dashboard.ts +48 -0
- package/starters/admin/src/pages/admin/order-detail.ts +78 -0
- package/starters/admin/src/pages/admin/orders.ts +117 -0
- package/starters/admin/src/pages/home.ts +25 -0
- package/starters/admin/src/pages/login.ts +70 -0
- package/starters/admin/src/pages/not-found.ts +12 -0
- package/starters/admin/src/routes.ts +40 -0
- package/starters/admin/src/styles/global.ts +86 -0
- package/starters/admin/tsconfig.json +15 -0
- package/starters/crud/README.md +14 -2
- package/starters/crud/mado.config.json +20 -0
- package/starters/crud/package.json +9 -4
- package/starters/crud/src/components/app-shell.ts +13 -8
- package/starters/crud/src/main.ts +1 -4
- package/starters/crud/src/pages/ticket-detail.ts +1 -0
- package/starters/crud/src/pages/ticket-new.ts +1 -0
- package/starters/crud/src/pages/tickets.ts +1 -0
- package/starters/crud/src/routes.ts +4 -2
- package/starters/minimal/README.md +4 -2
- package/starters/minimal/mado.config.json +20 -0
- package/starters/minimal/package.json +8 -3
- package/starters/minimal/src/components/app-counter.ts +1 -1
- package/starters/minimal/src/routes.ts +4 -2
package/server/serve.mjs
CHANGED
|
@@ -6,19 +6,36 @@
|
|
|
6
6
|
// node server/serve.mjs basic # mount examples/basic/ at /
|
|
7
7
|
// EXAMPLE=showcase node server/serve.mjs # mount examples/showcase/ at /
|
|
8
8
|
//
|
|
9
|
-
// Without EXAMPLE, / serves examples/index.html
|
|
9
|
+
// Without EXAMPLE, / serves examples/index.html when running inside the Mado
|
|
10
|
+
// repository, or ./index.html when running inside a generated app.
|
|
10
11
|
// With EXAMPLE, all extensionless and /index.html requests fall back to
|
|
11
12
|
// examples/<EXAMPLE>/index.html so the client router works from root, just
|
|
12
13
|
// like a production SPA deploy.
|
|
13
14
|
|
|
14
|
-
import { createServer } from "node:http";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
16
|
+
import { request as httpsRequest } from "node:https";
|
|
17
|
+
import { readFile, readdir, readFile as readFileAsync, stat } from "node:fs/promises";
|
|
18
|
+
import { watch, existsSync, readFileSync } from "node:fs";
|
|
17
19
|
import { extname, join, resolve, sep } from "node:path";
|
|
18
20
|
import { createHash } from "node:crypto";
|
|
19
21
|
|
|
20
22
|
const ROOT = resolve(process.cwd());
|
|
21
|
-
|
|
23
|
+
|
|
24
|
+
// Optional mado.config.json — used for dev.proxy and dev.port. Read with a
|
|
25
|
+
// hand-rolled JSON parse to avoid a circular dep with scripts/_config.mjs
|
|
26
|
+
// (this server is launched from cli.mjs and runs in its own Node process).
|
|
27
|
+
const CONFIG = (() => {
|
|
28
|
+
try {
|
|
29
|
+
const file = join(ROOT, "mado.config.json");
|
|
30
|
+
if (!existsSync(file)) return {};
|
|
31
|
+
return JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
const PROXY_RULES = Object.entries(CONFIG.dev?.proxy ?? {}); // [["/api", "http://localhost:3000"], ...]
|
|
37
|
+
|
|
38
|
+
const PORT = Number(process.env.PORT ?? CONFIG.dev?.port ?? 5173);
|
|
22
39
|
const HMR = process.env.NO_HMR !== "1";
|
|
23
40
|
|
|
24
41
|
const EXAMPLE = process.argv[2] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
|
|
@@ -26,6 +43,9 @@ const EXAMPLE_DIR = EXAMPLE
|
|
|
26
43
|
? resolve(join(ROOT, "examples", EXAMPLE))
|
|
27
44
|
: "";
|
|
28
45
|
const EXAMPLE_INDEX = EXAMPLE ? join(EXAMPLE_DIR, "index.html") : "";
|
|
46
|
+
const EXAMPLES_INDEX = join(ROOT, "examples", "index.html");
|
|
47
|
+
const APP_INDEX = join(ROOT, "index.html");
|
|
48
|
+
const DEFAULT_INDEX = existsSync(EXAMPLES_INDEX) ? EXAMPLES_INDEX : APP_INDEX;
|
|
29
49
|
|
|
30
50
|
if (EXAMPLE) {
|
|
31
51
|
if (!existsSync(EXAMPLE_INDEX)) {
|
|
@@ -114,6 +134,16 @@ const server = createServer(async (req, res) => {
|
|
|
114
134
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
115
135
|
pathname = decodeURIComponent(url.pathname);
|
|
116
136
|
|
|
137
|
+
// Dev proxy: forward matching prefixes to an upstream backend, so the
|
|
138
|
+
// browser can reach the SPA and the API on a single origin without CORS.
|
|
139
|
+
const proxyRule = PROXY_RULES.find(([prefix]) => pathname.startsWith(prefix));
|
|
140
|
+
if (proxyRule) {
|
|
141
|
+
const [prefix, upstream] = proxyRule;
|
|
142
|
+
await proxyForward({ req, res, prefix, upstream, pathname, search: url.search });
|
|
143
|
+
reason = `proxy → ${upstream}`;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
117
147
|
// SSE endpoint for HMR.
|
|
118
148
|
if (pathname === "/__hmr") {
|
|
119
149
|
res.writeHead(200, {
|
|
@@ -133,9 +163,7 @@ const server = createServer(async (req, res) => {
|
|
|
133
163
|
|
|
134
164
|
// A mounted example owns root and SPA fallback. Otherwise serve the
|
|
135
165
|
// examples index page.
|
|
136
|
-
const fallbackIndex = EXAMPLE
|
|
137
|
-
? EXAMPLE_INDEX
|
|
138
|
-
: join(ROOT, "examples", "index.html");
|
|
166
|
+
const fallbackIndex = EXAMPLE ? EXAMPLE_INDEX : DEFAULT_INDEX;
|
|
139
167
|
|
|
140
168
|
if (pathname === "/") {
|
|
141
169
|
// Resolved through fallback below.
|
|
@@ -260,6 +288,10 @@ async function buildPreloadHints() {
|
|
|
260
288
|
}
|
|
261
289
|
}
|
|
262
290
|
}
|
|
291
|
+
} else if (!existsSync(EXAMPLES_INDEX)) {
|
|
292
|
+
if (existsSync(join(ROOT, "dist/main.js"))) {
|
|
293
|
+
hrefs.push("/dist/main.js");
|
|
294
|
+
}
|
|
263
295
|
}
|
|
264
296
|
cachedPreloadHints = hrefs
|
|
265
297
|
.map((h) => ` <link rel="modulepreload" href="${h}">`)
|
|
@@ -273,17 +305,75 @@ server.on("error", (err) => {
|
|
|
273
305
|
process.exit(1);
|
|
274
306
|
});
|
|
275
307
|
|
|
308
|
+
async function proxyForward({ req, res, prefix, upstream, pathname, search }) {
|
|
309
|
+
// Strip the prefix only if the upstream URL itself ends with `/`; otherwise
|
|
310
|
+
// forward the full pathname so the backend sees /api/...
|
|
311
|
+
let upstreamUrl;
|
|
312
|
+
try {
|
|
313
|
+
upstreamUrl = new URL(upstream);
|
|
314
|
+
} catch {
|
|
315
|
+
res.writeHead(502).end(`bad upstream: ${upstream}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const target = new URL(upstream);
|
|
319
|
+
// Compose path: <upstream.pathname rstrip "/"> + <pathname> + <search>
|
|
320
|
+
const tail = pathname; // keep the original /api/... so backends route normally
|
|
321
|
+
target.pathname = (target.pathname.replace(/\/$/, "")) + tail;
|
|
322
|
+
target.search = search;
|
|
323
|
+
|
|
324
|
+
const lib = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
325
|
+
const upstreamReq = lib(
|
|
326
|
+
target,
|
|
327
|
+
{
|
|
328
|
+
method: req.method,
|
|
329
|
+
headers: {
|
|
330
|
+
...req.headers,
|
|
331
|
+
host: target.host,
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
(upstreamRes) => {
|
|
335
|
+
// Forward status and headers, then pipe the body.
|
|
336
|
+
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
337
|
+
upstreamRes.pipe(res);
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
upstreamReq.on("error", (err) => {
|
|
341
|
+
console.error(`[serve] proxy error for ${pathname} → ${target.href}:`, err.message);
|
|
342
|
+
if (!res.headersSent) {
|
|
343
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
344
|
+
res.end(`proxy upstream unavailable: ${target.host}\n${err.message}`);
|
|
345
|
+
} else {
|
|
346
|
+
res.end();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
req.pipe(upstreamReq);
|
|
350
|
+
// Reference unused arg so lint is happy.
|
|
351
|
+
void prefix;
|
|
352
|
+
}
|
|
353
|
+
|
|
276
354
|
server.listen(PORT, () => {
|
|
277
|
-
const distReady = existsSync(join(ROOT, "dist/src/index.js"))
|
|
355
|
+
const distReady = existsSync(join(ROOT, "dist/src/index.js"))
|
|
356
|
+
|| existsSync(join(ROOT, "dist/main.js"));
|
|
357
|
+
const mount = EXAMPLE
|
|
358
|
+
? `examples/${EXAMPLE}/ -> /`
|
|
359
|
+
: existsSync(EXAMPLES_INDEX)
|
|
360
|
+
? "examples/index.html landing"
|
|
361
|
+
: "index.html app";
|
|
278
362
|
console.log("");
|
|
279
363
|
console.log("Mado dev server");
|
|
280
364
|
console.log(` url: http://localhost:${PORT}/`);
|
|
281
365
|
console.log(` root: ${ROOT}`);
|
|
282
|
-
console.log(`
|
|
366
|
+
console.log(` mount: ${mount}`);
|
|
283
367
|
console.log(` hmr: ${HMR ? "on" : "off"}`);
|
|
284
368
|
console.log(` preload: ${PRELOAD}`);
|
|
285
369
|
console.log(` dist: ${distReady ? "ready" : "missing (run mado build)"}`);
|
|
286
|
-
if (
|
|
370
|
+
if (PROXY_RULES.length > 0) {
|
|
371
|
+
console.log(" proxy:");
|
|
372
|
+
for (const [prefix, upstream] of PROXY_RULES) {
|
|
373
|
+
console.log(` ${prefix.padEnd(10)} → ${upstream}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!EXAMPLE && existsSync(EXAMPLES_INDEX)) {
|
|
287
377
|
console.log(" try: mado serve basic");
|
|
288
378
|
console.log(" mado serve showcase");
|
|
289
379
|
console.log(" mado serve tickets");
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A starter Mado admin app: nested routes, a guarded admin shell, a blessed API
|
|
4
|
+
client, and a one-shot release pipeline.
|
|
5
|
+
|
|
6
|
+
## What you get
|
|
7
|
+
|
|
8
|
+
- `src/main.ts` — 8 lines: mount the router into `#app`. Layouts are NOT
|
|
9
|
+
declared here, only in `src/routes.ts`.
|
|
10
|
+
- `src/routes.ts` — nested manifest with three groups:
|
|
11
|
+
- `/` → public landing (bakeable),
|
|
12
|
+
- `/login` → centered `auth` layout,
|
|
13
|
+
- `/admin` → `app` layout, **guarded** by `requireAuth`.
|
|
14
|
+
- `src/layouts/app.ts` — admin shell (top bar + sidebar + content slot).
|
|
15
|
+
- `src/layouts/auth.ts` — centered card for sign-in.
|
|
16
|
+
- `src/lib/api.ts` — `createApiClient(baseUrl)` with bearer token, 401-refresh
|
|
17
|
+
retry, JSON in/out and a typed `ApiError`.
|
|
18
|
+
- `src/lib/auth.ts` — memory-only `accessToken`, `restoreSession()` from an
|
|
19
|
+
HttpOnly refresh cookie, and the `requireAuth` guard.
|
|
20
|
+
- `src/components/` — tiny `x-button` and `x-input` Web Components.
|
|
21
|
+
- `mado.config.json` — one config file. Includes a `dev.proxy` for `/api`.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run dev # tsc -w + dev server on http://localhost:5173, HMR on
|
|
27
|
+
npm run build # tsc → dist/
|
|
28
|
+
npm run typecheck # tsc --noEmit
|
|
29
|
+
npm run bundle # esbuild → out/assets/
|
|
30
|
+
npm run bake # prerender baked routes → out/baked/
|
|
31
|
+
npm run release # typecheck + build + bundle + bake + copy public/ → out/
|
|
32
|
+
npm run preview # serve out/ locally (production rehearsal)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To deploy, run `npm run release` and upload the entire `out/` directory
|
|
36
|
+
anywhere static (nginx, Cloudflare Pages, S3, Netlify, GitHub Pages, …).
|
|
37
|
+
|
|
38
|
+
## Backend expectations
|
|
39
|
+
|
|
40
|
+
The blessed `api` client speaks JSON. The auth recipe expects:
|
|
41
|
+
|
|
42
|
+
- `POST /api/auth/login` → `{ accessToken: string }` (sets refresh cookie)
|
|
43
|
+
- `POST /api/auth/refresh` → `{ accessToken: string }` (reads refresh cookie)
|
|
44
|
+
- `POST /api/auth/logout` → 204 (clears refresh cookie)
|
|
45
|
+
|
|
46
|
+
Change `mado.config.json#dev.proxy` to point at your backend in development.
|
|
47
|
+
|
|
48
|
+
## Where things live
|
|
49
|
+
|
|
50
|
+
| What | Where |
|
|
51
|
+
|---------------------|--------------------------------------|
|
|
52
|
+
| New URL | `src/pages/*.ts` + add to `routes.ts`|
|
|
53
|
+
| New protected URL | inside the `/admin` layout block |
|
|
54
|
+
| New layout | `src/layouts/*.ts` |
|
|
55
|
+
| New reusable widget | `src/components/x-*.ts` |
|
|
56
|
+
| New API call | `src/lib/api.ts` (add a method) |
|
|
57
|
+
| New global signal | `src/lib/<name>.ts` |
|
|
58
|
+
| Static image | `public/<file>` |
|
|
59
|
+
|
|
60
|
+
See the framework docs:
|
|
61
|
+
[`docs/en/11-layouts.md`](https://github.com/madojs/mado/blob/main/docs/en/11-layouts.md),
|
|
62
|
+
[`docs/en/12-auth-and-api.md`](https://github.com/madojs/mado/blob/main/docs/en/12-auth-and-api.md),
|
|
63
|
+
[`docs/en/13-deployment.md`](https://github.com/madojs/mado/blob/main/docs/en/13-deployment.md).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>__APP_NAME__</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
8
|
+
<script type="importmap">
|
|
9
|
+
{
|
|
10
|
+
"imports": {
|
|
11
|
+
"@madojs/mado": "./node_modules/@madojs/mado/dist/src/index.js",
|
|
12
|
+
"@madojs/mado/": "./node_modules/@madojs/mado/dist/src/"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div id="app"></div>
|
|
19
|
+
<script type="module" src="./dist/main.js"></script>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dev": {
|
|
3
|
+
"port": 5173,
|
|
4
|
+
"proxy": {
|
|
5
|
+
"/api": "http://localhost:3000"
|
|
6
|
+
}
|
|
7
|
+
},
|
|
8
|
+
"build": {
|
|
9
|
+
"out": "out",
|
|
10
|
+
"dist": "dist",
|
|
11
|
+
"publicDir": "public"
|
|
12
|
+
},
|
|
13
|
+
"bake": {
|
|
14
|
+
"entry": "src/routes.ts",
|
|
15
|
+
"template": "index.html",
|
|
16
|
+
"baseUrl": "https://example.com"
|
|
17
|
+
},
|
|
18
|
+
"bundle": {
|
|
19
|
+
"splitting": true,
|
|
20
|
+
"compress": ["gz", "br"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PACKAGE_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "mado build",
|
|
8
|
+
"typecheck": "mado typecheck",
|
|
9
|
+
"dev": "mado dev",
|
|
10
|
+
"serve": "mado serve",
|
|
11
|
+
"bundle": "mado bundle",
|
|
12
|
+
"bake": "mado bake",
|
|
13
|
+
"release": "mado release",
|
|
14
|
+
"preview": "mado preview"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@madojs/mado": "__MADOJS_VERSION__"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^6.0.3"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// <x-button variant="primary|ghost|danger" ?disabled>
|
|
2
|
+
//
|
|
3
|
+
// Wraps a native <button> so it can be slotted with text/icon and styled
|
|
4
|
+
// consistently across the app. Click events bubble naturally because Shadow
|
|
5
|
+
// DOM is `mode: open` and composed: true is the default for `click`.
|
|
6
|
+
|
|
7
|
+
import { component, css, html } from "@madojs/mado";
|
|
8
|
+
|
|
9
|
+
component(
|
|
10
|
+
"x-button",
|
|
11
|
+
({ host }) => () => {
|
|
12
|
+
const variant = host.getAttribute("variant") ?? "primary";
|
|
13
|
+
const disabled = host.hasAttribute("disabled");
|
|
14
|
+
return html`
|
|
15
|
+
<button data-variant=${variant} ?disabled=${disabled}>
|
|
16
|
+
<slot></slot>
|
|
17
|
+
</button>
|
|
18
|
+
`;
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
styles: css`
|
|
22
|
+
:host { display: inline-flex; }
|
|
23
|
+
button {
|
|
24
|
+
display: inline-flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: var(--space-2);
|
|
27
|
+
padding: 8px 14px;
|
|
28
|
+
border-radius: var(--radius-sm);
|
|
29
|
+
border: 1px solid transparent;
|
|
30
|
+
font: inherit;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
background: var(--accent);
|
|
33
|
+
color: var(--accent-fg);
|
|
34
|
+
transition: filter .12s ease;
|
|
35
|
+
}
|
|
36
|
+
button:hover:not(:disabled) { filter: brightness(1.07); }
|
|
37
|
+
button:active:not(:disabled) { filter: brightness(.95); }
|
|
38
|
+
button:disabled { opacity: .55; cursor: not-allowed; }
|
|
39
|
+
|
|
40
|
+
button[data-variant="ghost"] {
|
|
41
|
+
background: transparent;
|
|
42
|
+
color: var(--fg);
|
|
43
|
+
border-color: var(--border);
|
|
44
|
+
}
|
|
45
|
+
button[data-variant="ghost"]:hover:not(:disabled) {
|
|
46
|
+
background: var(--bg-elevated);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
button[data-variant="danger"] {
|
|
50
|
+
background: var(--danger);
|
|
51
|
+
color: white;
|
|
52
|
+
}
|
|
53
|
+
`,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// <x-input label name type placeholder required value @input @blur>
|
|
2
|
+
//
|
|
3
|
+
// Labeled input that proxies its events. Use inside `useForm()`:
|
|
4
|
+
//
|
|
5
|
+
// <x-input name="email" type="email" required
|
|
6
|
+
// @input=${form.onInput} @blur=${form.onBlur}></x-input>
|
|
7
|
+
|
|
8
|
+
import { component, css, html } from "@madojs/mado";
|
|
9
|
+
|
|
10
|
+
component(
|
|
11
|
+
"x-input",
|
|
12
|
+
({ host }) => () => {
|
|
13
|
+
const label = host.getAttribute("label") ?? "";
|
|
14
|
+
const name = host.getAttribute("name") ?? "";
|
|
15
|
+
const type = host.getAttribute("type") ?? "text";
|
|
16
|
+
const placeholder = host.getAttribute("placeholder") ?? "";
|
|
17
|
+
const required = host.hasAttribute("required");
|
|
18
|
+
const value = host.getAttribute("value") ?? "";
|
|
19
|
+
const error = host.getAttribute("error");
|
|
20
|
+
|
|
21
|
+
return html`
|
|
22
|
+
<label>
|
|
23
|
+
${label
|
|
24
|
+
? html`<span class="lbl">${label}${required ? html`<em>*</em>` : null}</span>`
|
|
25
|
+
: null}
|
|
26
|
+
<input
|
|
27
|
+
name=${name}
|
|
28
|
+
type=${type}
|
|
29
|
+
placeholder=${placeholder}
|
|
30
|
+
?required=${required}
|
|
31
|
+
.value=${value}
|
|
32
|
+
>
|
|
33
|
+
${error ? html`<small class="err">${error}</small>` : null}
|
|
34
|
+
</label>
|
|
35
|
+
`;
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
styles: css`
|
|
39
|
+
:host { display: block; }
|
|
40
|
+
label { display: block; }
|
|
41
|
+
.lbl {
|
|
42
|
+
display: block;
|
|
43
|
+
font-size: 12px;
|
|
44
|
+
color: var(--fg-muted);
|
|
45
|
+
margin-bottom: var(--space-1);
|
|
46
|
+
}
|
|
47
|
+
.lbl em {
|
|
48
|
+
color: var(--danger);
|
|
49
|
+
font-style: normal;
|
|
50
|
+
margin-left: 2px;
|
|
51
|
+
}
|
|
52
|
+
input {
|
|
53
|
+
width: 100%;
|
|
54
|
+
padding: 8px 10px;
|
|
55
|
+
font: inherit;
|
|
56
|
+
background: var(--bg);
|
|
57
|
+
color: var(--fg);
|
|
58
|
+
border: 1px solid var(--border);
|
|
59
|
+
border-radius: var(--radius-sm);
|
|
60
|
+
}
|
|
61
|
+
input:focus {
|
|
62
|
+
outline: none;
|
|
63
|
+
border-color: var(--accent);
|
|
64
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
|
65
|
+
}
|
|
66
|
+
.err {
|
|
67
|
+
display: block;
|
|
68
|
+
color: var(--danger);
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
margin-top: var(--space-1);
|
|
71
|
+
}
|
|
72
|
+
`,
|
|
73
|
+
},
|
|
74
|
+
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Admin shell layout: top bar + sidebar + content slot.
|
|
2
|
+
//
|
|
3
|
+
// A layout is just a `page({ view })` whose view renders `${ctx.child}`. The
|
|
4
|
+
// router wraps every page in the enclosing group with this template, so the
|
|
5
|
+
// shell is ALWAYS the outer frame, never below the page content.
|
|
6
|
+
//
|
|
7
|
+
// Keep the shell stateless if you can. Auth/user signals belong in
|
|
8
|
+
// src/lib/auth.ts; the layout reads them.
|
|
9
|
+
|
|
10
|
+
import { component, css, html, navigate, page } from "@madojs/mado";
|
|
11
|
+
import { accessToken } from "../lib/api.js";
|
|
12
|
+
import { logout } from "../lib/auth.js";
|
|
13
|
+
import "../components/x-button.js";
|
|
14
|
+
|
|
15
|
+
component(
|
|
16
|
+
"x-admin-shell",
|
|
17
|
+
() => () => html`
|
|
18
|
+
<header>
|
|
19
|
+
<a href="/admin" data-link class="brand">__APP_NAME__</a>
|
|
20
|
+
<nav>
|
|
21
|
+
<a href="/admin" data-link>Dashboard</a>
|
|
22
|
+
<a href="/admin/orders" data-link>Orders</a>
|
|
23
|
+
</nav>
|
|
24
|
+
<span class="spacer"></span>
|
|
25
|
+
<span class="user">${() => maskToken(accessToken())}</span>
|
|
26
|
+
<x-button
|
|
27
|
+
variant="ghost"
|
|
28
|
+
@click=${async () => {
|
|
29
|
+
await logout();
|
|
30
|
+
navigate("/login", { replace: true });
|
|
31
|
+
}}
|
|
32
|
+
>Sign out</x-button>
|
|
33
|
+
</header>
|
|
34
|
+
<aside>
|
|
35
|
+
<nav>
|
|
36
|
+
<a href="/admin" data-link>Overview</a>
|
|
37
|
+
<a href="/admin/orders" data-link>Orders</a>
|
|
38
|
+
</nav>
|
|
39
|
+
</aside>
|
|
40
|
+
<main>
|
|
41
|
+
<slot></slot>
|
|
42
|
+
</main>
|
|
43
|
+
`,
|
|
44
|
+
{
|
|
45
|
+
styles: css`
|
|
46
|
+
:host {
|
|
47
|
+
display: grid;
|
|
48
|
+
grid-template-columns: 220px 1fr;
|
|
49
|
+
grid-template-rows: 56px 1fr;
|
|
50
|
+
grid-template-areas:
|
|
51
|
+
"topbar topbar"
|
|
52
|
+
"side main";
|
|
53
|
+
min-height: 100vh;
|
|
54
|
+
}
|
|
55
|
+
header {
|
|
56
|
+
grid-area: topbar;
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: var(--space-4);
|
|
60
|
+
padding: 0 var(--space-5);
|
|
61
|
+
background: var(--bg-elevated);
|
|
62
|
+
border-bottom: 1px solid var(--border);
|
|
63
|
+
}
|
|
64
|
+
header .brand { font-weight: 700; color: var(--fg); }
|
|
65
|
+
header nav { display: flex; gap: var(--space-3); }
|
|
66
|
+
header nav a { color: var(--fg-muted); }
|
|
67
|
+
header nav a:hover { color: var(--fg); text-decoration: none; }
|
|
68
|
+
header .user { color: var(--fg-muted); font-variant-numeric: tabular-nums; }
|
|
69
|
+
.spacer { flex: 1; }
|
|
70
|
+
aside {
|
|
71
|
+
grid-area: side;
|
|
72
|
+
border-right: 1px solid var(--border);
|
|
73
|
+
background: var(--bg-elevated);
|
|
74
|
+
padding: var(--space-4);
|
|
75
|
+
}
|
|
76
|
+
aside nav { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
77
|
+
aside nav a {
|
|
78
|
+
padding: var(--space-2) var(--space-3);
|
|
79
|
+
border-radius: var(--radius-sm);
|
|
80
|
+
color: var(--fg-muted);
|
|
81
|
+
}
|
|
82
|
+
aside nav a:hover {
|
|
83
|
+
background: var(--bg);
|
|
84
|
+
color: var(--fg);
|
|
85
|
+
text-decoration: none;
|
|
86
|
+
}
|
|
87
|
+
main { grid-area: main; padding: var(--space-5); }
|
|
88
|
+
`,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
function maskToken(token: string | null): string {
|
|
93
|
+
if (!token) return "—";
|
|
94
|
+
return `signed in · …${token.slice(-4)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default page({
|
|
98
|
+
view: ({ child }) => html`
|
|
99
|
+
<x-admin-shell>${child}</x-admin-shell>
|
|
100
|
+
`,
|
|
101
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Centered card layout for auth pages (login, signup, password reset).
|
|
2
|
+
// Identical shape to layouts/app.ts but a different shell.
|
|
3
|
+
|
|
4
|
+
import { component, css, html, page } from "@madojs/mado";
|
|
5
|
+
|
|
6
|
+
component(
|
|
7
|
+
"x-auth-shell",
|
|
8
|
+
() => () => html`
|
|
9
|
+
<main>
|
|
10
|
+
<div class="card">
|
|
11
|
+
<slot></slot>
|
|
12
|
+
</div>
|
|
13
|
+
</main>
|
|
14
|
+
`,
|
|
15
|
+
{
|
|
16
|
+
styles: css`
|
|
17
|
+
:host {
|
|
18
|
+
display: block;
|
|
19
|
+
min-height: 100vh;
|
|
20
|
+
}
|
|
21
|
+
main {
|
|
22
|
+
min-height: 100vh;
|
|
23
|
+
display: grid;
|
|
24
|
+
place-items: center;
|
|
25
|
+
padding: var(--space-5);
|
|
26
|
+
}
|
|
27
|
+
.card {
|
|
28
|
+
background: var(--bg-elevated);
|
|
29
|
+
border: 1px solid var(--border);
|
|
30
|
+
border-radius: var(--radius);
|
|
31
|
+
box-shadow: var(--shadow-1);
|
|
32
|
+
padding: var(--space-6);
|
|
33
|
+
width: min(360px, 100%);
|
|
34
|
+
}
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export default page({
|
|
40
|
+
view: ({ child }) => html`<x-auth-shell>${child}</x-auth-shell>`,
|
|
41
|
+
});
|