htmlboard 0.4.0 → 0.5.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/README.md +169 -7
- package/bin/htmlboard.js +152 -0
- package/package.json +1 -1
- package/skills/htmlboard/SKILL.md +41 -7
- package/src/catalog.js +449 -0
- package/src/check.js +173 -0
- package/src/client/check.js +349 -0
- package/src/client/explorer.js +68 -2
- package/src/client/probe.js +41 -0
- package/src/components.js +95 -0
- package/src/designs.js +63 -15
- package/src/index.js +18 -0
- package/src/server.js +167 -11
- package/src/templates/01-screens/01-dashboard.html +112 -0
- package/src/templates/01-screens/02-customers.html +77 -0
- package/src/templates/01-screens/{02-mobile.html → 03-mobile.html} +4 -54
- package/src/templates/{02-components/01-ui-kit.html → 02-ui-kit/01-overview.html} +16 -4
- package/src/templates/components/app-sidebar.html +67 -0
- package/src/templates/components/app-topbar.html +19 -0
- package/src/templates/components/stat-card.html +14 -0
- package/src/templates/components/tx-item.html +33 -0
- package/src/templates/components.js +180 -0
- package/src/templates/styles.css +24 -2
- package/src/templates/01-screens/01-desktop.html +0 -281
package/src/designs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { COMPONENTS_FOLDER, PREVIEW_WIDTH, isPage, isTagName } from './components.js';
|
|
3
4
|
|
|
4
5
|
// Which designs a folder holds, and how the canvas shows them.
|
|
5
6
|
//
|
|
@@ -16,6 +17,9 @@ import path from 'node:path';
|
|
|
16
17
|
// A designs.json beside them is optional and only curates: sections with their titles, the order,
|
|
17
18
|
// overrides for a design's values. Designs it doesn't list come after the ones it does (unless it
|
|
18
19
|
// says "unlisted": "hide").
|
|
20
|
+
//
|
|
21
|
+
// The components in components/ (pieces of pages, see components.js) aren't designs. A designs.json
|
|
22
|
+
// can show them anyway, each in a frame of its own: "components": "show", or listing them.
|
|
19
23
|
|
|
20
24
|
/** Folders discovery leaves alone (hidden ones too) */
|
|
21
25
|
const IGNORED_DIRS = new Set(['node_modules', 'dist', 'build']);
|
|
@@ -93,7 +97,8 @@ function decode(text) {
|
|
|
93
97
|
});
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
/** The attributes of an HTML tag (`<meta name="x" content="y">`), names in lowercase, values decoded */
|
|
101
|
+
export function attributes(tag) {
|
|
97
102
|
const attrs = {};
|
|
98
103
|
for (const m of tag.matchAll(/([^\s"'<>/=]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) {
|
|
99
104
|
attrs[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? '');
|
|
@@ -127,7 +132,7 @@ function parseVariant(content) {
|
|
|
127
132
|
export function parseDesignMeta(html) {
|
|
128
133
|
const end = html.search(/<\/head\s*>|<body[\s>]/i);
|
|
129
134
|
const head = end >= 0 ? html.slice(0, end) : html.slice(0, 65536);
|
|
130
|
-
const meta = { variants: [] };
|
|
135
|
+
const meta = { variants: [], page: isPage(html) };
|
|
131
136
|
const title = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
132
137
|
if (title) {
|
|
133
138
|
const text = decode(title[1]).replace(/\s+/g, ' ').trim();
|
|
@@ -203,7 +208,8 @@ function variantDesign(base, { query, label }) {
|
|
|
203
208
|
|
|
204
209
|
/**
|
|
205
210
|
* Every design in the folder, in order: the files of a folder by name (number prefixes order
|
|
206
|
-
* them), each followed by its variants, then its subfolders by name.
|
|
211
|
+
* them), each followed by its variants, then its subfolders by name. The components in
|
|
212
|
+
* components/ are left out (a whole page there is still a design).
|
|
207
213
|
*/
|
|
208
214
|
export function scanDesigns(baseDir) {
|
|
209
215
|
const designs = [];
|
|
@@ -219,7 +225,7 @@ export function scanDesigns(baseDir) {
|
|
|
219
225
|
if (!entry.isFile() || !/\.html?$/i.test(entry.name)) continue;
|
|
220
226
|
const file = folder ? `${folder}/${entry.name}` : entry.name;
|
|
221
227
|
const meta = readDesignMeta(path.join(dir, entry.name));
|
|
222
|
-
if (!meta || meta.skip) continue;
|
|
228
|
+
if (!meta || meta.skip || (folder === COMPONENTS_FOLDER && !meta.page)) continue;
|
|
223
229
|
const base = fileDesign(file, folder, meta);
|
|
224
230
|
designs.push(base);
|
|
225
231
|
for (const variant of meta.variants) designs.push(variantDesign(base, variant));
|
|
@@ -232,6 +238,43 @@ export function scanDesigns(baseDir) {
|
|
|
232
238
|
return designs;
|
|
233
239
|
}
|
|
234
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The components in the folder's components/, by name, as designs that show each one alone (its
|
|
243
|
+
* file's example, at components/<tag>.html?preview). For designs.json, which can show them.
|
|
244
|
+
* A component's file can give its preview's size with the same tag as a design.
|
|
245
|
+
*/
|
|
246
|
+
export function scanComponents(baseDir) {
|
|
247
|
+
const dir = path.join(baseDir, COMPONENTS_FOLDER);
|
|
248
|
+
let entries;
|
|
249
|
+
try {
|
|
250
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
return entries
|
|
255
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.html') && isTagName(entry.name.slice(0, -5)))
|
|
256
|
+
.sort((a, b) => byName(a.name, b.name))
|
|
257
|
+
.flatMap((entry) => {
|
|
258
|
+
const meta = readDesignMeta(path.join(dir, entry.name));
|
|
259
|
+
if (!meta || meta.skip || meta.page) return [];
|
|
260
|
+
const tag = entry.name.slice(0, -5);
|
|
261
|
+
const file = `${COMPONENTS_FOLDER}/${entry.name}`;
|
|
262
|
+
return [compact({
|
|
263
|
+
key: file,
|
|
264
|
+
file: `${file}?preview`,
|
|
265
|
+
folder: COMPONENTS_FOLDER,
|
|
266
|
+
id: designId(file),
|
|
267
|
+
title: `<${tag}>`,
|
|
268
|
+
description: meta.description,
|
|
269
|
+
width: meta.width || PREVIEW_WIDTH,
|
|
270
|
+
height: meta.height,
|
|
271
|
+
fit: !meta.height || undefined, // the frame is as tall as what it shows, however short
|
|
272
|
+
category: 'components',
|
|
273
|
+
component: true
|
|
274
|
+
})];
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
235
278
|
/** Designs grouped into one section per folder, in order (`rootName` for the files at the top) */
|
|
236
279
|
function groupByFolder(designs, rootName = '') {
|
|
237
280
|
const groups = new Map();
|
|
@@ -274,8 +317,13 @@ function resolveEntry(entry, byKey) {
|
|
|
274
317
|
return design;
|
|
275
318
|
}
|
|
276
319
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
320
|
+
/**
|
|
321
|
+
* designs.json's sections filled from the files, then the designs it doesn't list. Components
|
|
322
|
+
* (see scanComponents) show where it lists them, and all of them with "components": "show".
|
|
323
|
+
*/
|
|
324
|
+
function curate(config, designs, components = []) {
|
|
325
|
+
const showComponents = config.components === 'show' || config.components === true;
|
|
326
|
+
const found = [...designs, ...components];
|
|
279
327
|
const byKey = new Map(found.map((d) => [d.key, d]));
|
|
280
328
|
const sources = (Array.isArray(config.sections) ? config.sections : []).map((section) => ({
|
|
281
329
|
section,
|
|
@@ -313,14 +361,16 @@ function curate(config, found) {
|
|
|
313
361
|
owners.set(folders[0], owners.has(folders[0]) && owners.get(folders[0]) !== i ? -1 : i);
|
|
314
362
|
});
|
|
315
363
|
|
|
316
|
-
const unlisted = found.filter((d) => !used.has(d.key));
|
|
364
|
+
const unlisted = found.filter((d) => !used.has(d.key) && (!d.component || showComponents));
|
|
317
365
|
const rest = [];
|
|
318
366
|
for (const design of unlisted) {
|
|
319
367
|
const owner = owners.get(design.folder ?? '');
|
|
320
368
|
if (owner !== undefined && owner >= 0) sections[owner].artboards.push(design);
|
|
321
|
-
else rest.push(design);
|
|
369
|
+
else if (design.component || config.unlisted !== 'hide') rest.push(design);
|
|
322
370
|
}
|
|
323
|
-
|
|
371
|
+
// Without sections of its own, designs.json only sets things: the files at the top keep a first
|
|
372
|
+
// row without a title, as without designs.json.
|
|
373
|
+
sections.push(...groupByFolder(rest, sources.length ? 'Unlisted' : ''));
|
|
324
374
|
|
|
325
375
|
const { sections: _sections, ...top } = config;
|
|
326
376
|
return { ...top, sections };
|
|
@@ -343,7 +393,7 @@ function finalize(manifest, prefix = '') {
|
|
|
343
393
|
id: section.id ?? (slugify(name) || (folder ? slugify(folder.split('/').map(stripPrefix).join('-')) : `section-${i + 1}`)),
|
|
344
394
|
name,
|
|
345
395
|
artboards: artboards.map((design) => {
|
|
346
|
-
const { key, folder: _folder, baseTitle, variantOf, ...board } = design;
|
|
396
|
+
const { key, folder: _folder, baseTitle, variantOf, component, ...board } = design;
|
|
347
397
|
const category = board.category ?? inferCategory(design.file.split('?')[0], board.width, board.height);
|
|
348
398
|
return compact({
|
|
349
399
|
...board,
|
|
@@ -381,18 +431,16 @@ export function normalizeManifest(manifest, prefix = '') {
|
|
|
381
431
|
*/
|
|
382
432
|
export function loadDesignsManifest(baseDir, rootDir = baseDir) {
|
|
383
433
|
const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
|
|
434
|
+
const title = path.basename(path.resolve(baseDir)) || 'Designs';
|
|
384
435
|
const found = scanDesigns(baseDir);
|
|
385
436
|
const configPath = path.join(baseDir, 'designs.json');
|
|
386
437
|
if (fs.existsSync(configPath)) {
|
|
387
438
|
try {
|
|
388
439
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
389
|
-
return { ...finalize(curate(config, found), prefix), autoDiscovered: false };
|
|
440
|
+
return { ...finalize(curate({ title, ...config }, found, scanComponents(baseDir)), prefix), autoDiscovered: false };
|
|
390
441
|
} catch (err) {
|
|
391
442
|
console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Showing the files without it.`);
|
|
392
443
|
}
|
|
393
444
|
}
|
|
394
|
-
return {
|
|
395
|
-
...finalize({ title: path.basename(path.resolve(baseDir)) || 'Designs', sections: groupByFolder(found) }, prefix),
|
|
396
|
-
autoDiscovered: true
|
|
397
|
-
};
|
|
445
|
+
return { ...finalize({ title, sections: groupByFolder(found) }, prefix), autoDiscovered: true };
|
|
398
446
|
}
|
package/src/index.js
CHANGED
|
@@ -15,9 +15,27 @@ export {
|
|
|
15
15
|
normalizeManifest,
|
|
16
16
|
inferCategory,
|
|
17
17
|
parseDesignMeta,
|
|
18
|
+
scanComponents,
|
|
18
19
|
scanDesigns
|
|
19
20
|
} from './designs.js';
|
|
20
21
|
|
|
22
|
+
export {
|
|
23
|
+
installComponents,
|
|
24
|
+
componentPreview,
|
|
25
|
+
COMPONENTS_FOLDER,
|
|
26
|
+
COMPONENTS_SCRIPT
|
|
27
|
+
} from './components.js';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
buildCatalog,
|
|
31
|
+
formatCatalog
|
|
32
|
+
} from './catalog.js';
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
runCheck,
|
|
36
|
+
formatReport
|
|
37
|
+
} from './check.js';
|
|
38
|
+
|
|
21
39
|
export {
|
|
22
40
|
installSkills,
|
|
23
41
|
uninstallSkills,
|
package/src/server.js
CHANGED
|
@@ -2,8 +2,10 @@ import http from 'node:http';
|
|
|
2
2
|
import net from 'node:net';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { loadDesignsManifest } from './designs.js';
|
|
8
|
+
import { componentPreview } from './components.js';
|
|
7
9
|
|
|
8
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
11
|
const __dirname = path.dirname(__filename);
|
|
@@ -32,6 +34,50 @@ const MIME_TYPES = {
|
|
|
32
34
|
'.mp4': 'video/mp4'
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
// The probe (client/probe.js) notes what goes wrong while a page builds, for `htmlboard check`. It
|
|
38
|
+
// goes first in the pages the canvas shows in frames, before anything that could go wrong.
|
|
39
|
+
const PROBE = Buffer.from('<script src="/__explorer/probe.js"></script>');
|
|
40
|
+
|
|
41
|
+
/** A page with the probe first in it: in its <head>, else after <html> or <!DOCTYPE>, else first */
|
|
42
|
+
export function withProbe(page) {
|
|
43
|
+
const text = page.toString('latin1'); // a byte per character: positions in it are positions in page
|
|
44
|
+
for (const tag of [/<head\b[^>]*>/i, /<html\b[^>]*>/i, /<!doctype\b[^>]*>/i]) {
|
|
45
|
+
const match = tag.exec(text);
|
|
46
|
+
if (match) {
|
|
47
|
+
const at = match.index + match[0].length;
|
|
48
|
+
return Buffer.concat([page.subarray(0, at), PROBE, page.subarray(at)]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return Buffer.concat([PROBE, page]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function json(res, status, body) {
|
|
55
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
56
|
+
res.end(JSON.stringify(body));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBody(req, limit = 16 * 1024 * 1024) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
req.on('data', (chunk) => {
|
|
64
|
+
size += chunk.length;
|
|
65
|
+
if (size > limit) {
|
|
66
|
+
reject(new Error('Too large'));
|
|
67
|
+
req.destroy();
|
|
68
|
+
} else {
|
|
69
|
+
chunks.push(chunk);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
73
|
+
req.on('error', reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How long a canvas has to take a check (htmlboard check), then to do it */
|
|
78
|
+
const CLAIM_MS = 15000;
|
|
79
|
+
const CHECK_MS = 180000;
|
|
80
|
+
|
|
35
81
|
/** True when target is base itself or inside it */
|
|
36
82
|
function isInside(base, target) {
|
|
37
83
|
const rel = path.relative(base, target);
|
|
@@ -152,13 +198,106 @@ export function createServer(options = {}) {
|
|
|
152
198
|
if (!isInside(rootDir, targetDir)) {
|
|
153
199
|
throw new Error(`The designs folder (${targetDir}) must be inside the root (${rootDir})`);
|
|
154
200
|
}
|
|
155
|
-
const
|
|
201
|
+
const designsPath = path.relative(rootDir, targetDir).split(path.sep).join('/'); // '' when they're the same
|
|
202
|
+
const manifestFile = path.posix.join(designsPath, 'designs.json');
|
|
156
203
|
const clients = new Set();
|
|
157
204
|
|
|
205
|
+
const send = (event, data) => {
|
|
206
|
+
for (const client of clients) client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Checks (htmlboard check). GET /api/check asks the canvases open in a browser (over the live
|
|
210
|
+
// reload events) to check the designs; the first to claim the check does it, and what it posts
|
|
211
|
+
// back answers the request.
|
|
212
|
+
const checks = new Map(); // id → { res, claimed, timer }
|
|
213
|
+
|
|
214
|
+
function endCheck(id, status, body) {
|
|
215
|
+
const check = checks.get(id);
|
|
216
|
+
if (!check) return;
|
|
217
|
+
clearTimeout(check.timer);
|
|
218
|
+
checks.delete(id);
|
|
219
|
+
json(check.res, status, body);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// only: files or folders (from the designs folder) to check; all the designs when there's none
|
|
223
|
+
function startCheck(res, only) {
|
|
224
|
+
const wanted = only.map((item) => item.replace(/\\/g, '/').replace(/^\.?\/+|\/+$/g, ''));
|
|
225
|
+
const designs = loadDesignsManifest(targetDir, rootDir).sections
|
|
226
|
+
.flatMap((section) => section.artboards)
|
|
227
|
+
.map(({ file, title, width, height, fit }) => ({ file, title, width, height, fit }))
|
|
228
|
+
.filter(({ file }) => {
|
|
229
|
+
const own = (designsPath ? file.slice(designsPath.length + 1) : file).split('?')[0];
|
|
230
|
+
return !wanted.length || wanted.some((item) => !item || own === item || own.startsWith(`${item}/`));
|
|
231
|
+
});
|
|
232
|
+
if (!designs.length) {
|
|
233
|
+
json(res, 404, { error: wanted.some(Boolean) ? `No design on the canvas is in ${only.join(', ')}` : 'There are no designs to check' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!clients.size) {
|
|
237
|
+
json(res, 409, { error: "The canvas isn't open in a browser" });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const id = randomUUID();
|
|
241
|
+
const check = {
|
|
242
|
+
res,
|
|
243
|
+
claimed: false,
|
|
244
|
+
timer: setTimeout(() => endCheck(id, 504, { error: "The canvas didn't take the check (reload it in the browser)" }), CLAIM_MS)
|
|
245
|
+
};
|
|
246
|
+
checks.set(id, check);
|
|
247
|
+
res.on('close', () => {
|
|
248
|
+
if (checks.get(id) !== check) return; // answered
|
|
249
|
+
clearTimeout(check.timer);
|
|
250
|
+
checks.delete(id);
|
|
251
|
+
});
|
|
252
|
+
send('check', { id, designs });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function claimCheck(id, res) {
|
|
256
|
+
const check = checks.get(id);
|
|
257
|
+
if (!check || check.claimed) {
|
|
258
|
+
json(res, 409, { error: 'Taken' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
check.claimed = true;
|
|
262
|
+
clearTimeout(check.timer);
|
|
263
|
+
check.timer = setTimeout(() => endCheck(id, 504, { error: `The canvas didn't finish the check in ${CHECK_MS / 1000} s` }), CHECK_MS);
|
|
264
|
+
json(res, 200, { ok: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function finishCheck(id, req, res) {
|
|
268
|
+
let report;
|
|
269
|
+
try {
|
|
270
|
+
report = JSON.parse(await readBody(req));
|
|
271
|
+
} catch {
|
|
272
|
+
json(res, 400, { error: 'Not a report' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!checks.get(id)?.claimed) {
|
|
276
|
+
json(res, 404, { error: 'No such check' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (report.error) endCheck(id, 500, { error: `The canvas couldn't check the designs: ${report.error}` });
|
|
280
|
+
else endCheck(id, 200, { dir: targetDir, root: rootDir, designs: Array.isArray(report.designs) ? report.designs : [] });
|
|
281
|
+
res.writeHead(204).end();
|
|
282
|
+
}
|
|
283
|
+
|
|
158
284
|
const server = http.createServer((req, res) => {
|
|
159
285
|
const parsedUrl = new URL(req.url, 'http://localhost');
|
|
160
286
|
let pathname = decodeURIComponent(parsedUrl.pathname);
|
|
161
287
|
|
|
288
|
+
// API: checking the designs (htmlboard check), which the canvas does
|
|
289
|
+
if (pathname === '/api/check' && req.method === 'GET') {
|
|
290
|
+
startCheck(res, parsedUrl.searchParams.getAll('only'));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (pathname.startsWith('/api/check/') && req.method === 'POST') {
|
|
294
|
+
const [id, action, ...rest] = pathname.slice('/api/check/'.length).split('/');
|
|
295
|
+
if (action === 'claim' && !rest.length) claimCheck(id, res);
|
|
296
|
+
else if (action === undefined) finishCheck(id, req, res);
|
|
297
|
+
else json(res, 404, { error: 'Not found' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
162
301
|
// API: Designs Manifest
|
|
163
302
|
if (pathname === '/api/designs') {
|
|
164
303
|
const manifest = loadDesignsManifest(targetDir, rootDir);
|
|
@@ -202,7 +341,7 @@ export function createServer(options = {}) {
|
|
|
202
341
|
|
|
203
342
|
// Serve HtmlBoard Explorer client files
|
|
204
343
|
if (pathname === '/' || pathname === '/index.html') {
|
|
205
|
-
serveStaticFile(path.join(CLIENT_DIR, 'index.html'), res);
|
|
344
|
+
serveStaticFile(path.join(CLIENT_DIR, 'index.html'), req, res);
|
|
206
345
|
return;
|
|
207
346
|
}
|
|
208
347
|
|
|
@@ -214,7 +353,7 @@ export function createServer(options = {}) {
|
|
|
214
353
|
res.writeHead(403).end('Forbidden');
|
|
215
354
|
return;
|
|
216
355
|
}
|
|
217
|
-
serveStaticFile(clientFilePath, res);
|
|
356
|
+
serveStaticFile(clientFilePath, req, res);
|
|
218
357
|
return;
|
|
219
358
|
}
|
|
220
359
|
|
|
@@ -232,24 +371,43 @@ export function createServer(options = {}) {
|
|
|
232
371
|
return;
|
|
233
372
|
}
|
|
234
373
|
|
|
374
|
+
// The pages the canvas shows in frames get the probe (htmlboard check); a page opened on its own doesn't
|
|
375
|
+
const framed = req.headers['sec-fetch-dest'] === 'iframe';
|
|
376
|
+
|
|
377
|
+
// A component alone, as the canvas shows it (components/<tag>.html?preview): its example in a page
|
|
378
|
+
if (parsedUrl.searchParams.has('preview')) {
|
|
379
|
+
const preview = componentPreview(targetDir, filePath);
|
|
380
|
+
if (preview !== null) {
|
|
381
|
+
res.writeHead(200, {
|
|
382
|
+
'Content-Type': MIME_TYPES['.html'],
|
|
383
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
384
|
+
});
|
|
385
|
+
res.end(framed ? withProbe(Buffer.from(preview)) : preview);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
235
390
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
236
|
-
serveStaticFile(filePath, res);
|
|
391
|
+
serveStaticFile(filePath, req, res, { probe: framed });
|
|
237
392
|
} else {
|
|
238
393
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
239
394
|
res.end(`File not found: ${pathname}`);
|
|
240
395
|
}
|
|
241
396
|
});
|
|
242
397
|
|
|
243
|
-
function serveStaticFile(filePath, res) {
|
|
398
|
+
function serveStaticFile(filePath, req, res, { probe = false } = {}) {
|
|
244
399
|
try {
|
|
245
400
|
const ext = path.extname(filePath).toLowerCase();
|
|
246
401
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
247
|
-
|
|
402
|
+
// HEAD (htmlboard check asks which files are there) needs no reading
|
|
403
|
+
let content = req.method === 'HEAD' ? null : fs.readFileSync(filePath);
|
|
404
|
+
if (content && probe && (ext === '.html' || ext === '.htm')) content = withProbe(content);
|
|
248
405
|
res.writeHead(200, {
|
|
249
406
|
'Content-Type': contentType,
|
|
250
407
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
251
408
|
});
|
|
252
|
-
res.end(content);
|
|
409
|
+
if (content) res.end(content);
|
|
410
|
+
else res.end();
|
|
253
411
|
} catch {
|
|
254
412
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
255
413
|
res.end('Error reading file');
|
|
@@ -271,15 +429,12 @@ export function createServer(options = {}) {
|
|
|
271
429
|
const files = [...changed];
|
|
272
430
|
changed.clear();
|
|
273
431
|
for (const [file, isFolder] of files) {
|
|
274
|
-
|
|
432
|
+
send('change', {
|
|
275
433
|
type: file === manifestFile ? 'manifest' : isFolder ? 'folder' : 'file',
|
|
276
434
|
file,
|
|
277
435
|
isHtml: !isFolder && /\.html?$/i.test(file),
|
|
278
436
|
timestamp: Date.now()
|
|
279
437
|
});
|
|
280
|
-
for (const client of clients) {
|
|
281
|
-
client.write(`event: change\ndata: ${eventData}\n\n`);
|
|
282
|
-
}
|
|
283
438
|
}
|
|
284
439
|
}, 100);
|
|
285
440
|
});
|
|
@@ -294,6 +449,7 @@ export function createServer(options = {}) {
|
|
|
294
449
|
close: () => {
|
|
295
450
|
clearTimeout(watchDebounceTimer);
|
|
296
451
|
if (stopWatching) stopWatching();
|
|
452
|
+
for (const id of [...checks.keys()]) endCheck(id, 503, { error: 'htmlboard stopped' });
|
|
297
453
|
for (const client of clients) {
|
|
298
454
|
client.end();
|
|
299
455
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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.0">
|
|
6
|
+
<title>Dashboard</title>
|
|
7
|
+
<meta name="description" content="Analytics dashboard with sidebar, metric cards, and charts">
|
|
8
|
+
<meta name="htmlboard" content="width=1280, height=820">
|
|
9
|
+
<link rel="stylesheet" href="../styles.css">
|
|
10
|
+
<script type="module" src="../components.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
.stats-row {
|
|
13
|
+
display: grid;
|
|
14
|
+
grid-template-columns: repeat(4, 1fr);
|
|
15
|
+
gap: 20px;
|
|
16
|
+
}
|
|
17
|
+
.grid-2 {
|
|
18
|
+
display: grid;
|
|
19
|
+
grid-template-columns: 2fr 1fr;
|
|
20
|
+
gap: 20px;
|
|
21
|
+
}
|
|
22
|
+
.grid-2 .card { padding: 22px; }
|
|
23
|
+
.panel-header {
|
|
24
|
+
display: flex;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
align-items: center;
|
|
27
|
+
margin-bottom: 20px;
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
}
|
|
30
|
+
.panel-title { font-size: 16px; font-weight: 600; }
|
|
31
|
+
.chart-container { width: 100%; height: 180px; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body class="app">
|
|
35
|
+
<app-sidebar active="Dashboard"></app-sidebar>
|
|
36
|
+
|
|
37
|
+
<main class="app-main">
|
|
38
|
+
<app-topbar search="Search resources, clients, reports...">
|
|
39
|
+
<span class="muted">Last sync: Just now</span>
|
|
40
|
+
<button class="btn btn-primary">+ New Deployment</button>
|
|
41
|
+
</app-topbar>
|
|
42
|
+
|
|
43
|
+
<div class="app-content">
|
|
44
|
+
<div>
|
|
45
|
+
<h1 class="page-title">Executive Overview</h1>
|
|
46
|
+
<p class="page-sub">Performance metrics and active client deployments</p>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="stats-row">
|
|
50
|
+
<stat-card label="Monthly Recurring Revenue" value="$184,290" trend="▲ +12.4% vs last mo"></stat-card>
|
|
51
|
+
<stat-card label="Active Organizations" value="1,428" trend="▲ +48 new"></stat-card>
|
|
52
|
+
<stat-card label="API Requests (24h)" value="42.8M" trend="▲ 99.98% uptime"></stat-card>
|
|
53
|
+
<stat-card label="Average Latency" value="34ms" trend="▼ -2ms target" warn></stat-card>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="grid-2">
|
|
57
|
+
<div class="card">
|
|
58
|
+
<div class="panel-header">
|
|
59
|
+
<h2 class="panel-title">Throughput & Workload Trends</h2>
|
|
60
|
+
<span class="muted">Trailing 7 Days</span>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="chart-container">
|
|
63
|
+
<svg viewBox="0 0 500 150" width="100%" height="100%" preserveAspectRatio="none">
|
|
64
|
+
<defs>
|
|
65
|
+
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
|
|
66
|
+
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.5"/>
|
|
67
|
+
<stop offset="100%" stop-color="#6366f1" stop-opacity="0.0"/>
|
|
68
|
+
</linearGradient>
|
|
69
|
+
</defs>
|
|
70
|
+
<path d="M 0 130 Q 80 70, 150 90 T 300 40 T 420 50 T 500 20 L 500 150 L 0 150 Z" fill="url(#chartGrad)"/>
|
|
71
|
+
<path d="M 0 130 Q 80 70, 150 90 T 300 40 T 420 50 T 500 20" fill="none" stroke="#6366f1" stroke-width="3"/>
|
|
72
|
+
<circle cx="500" cy="20" r="4" fill="#a855f7" stroke="#fff" stroke-width="2"/>
|
|
73
|
+
</svg>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="card">
|
|
78
|
+
<div class="panel-header">
|
|
79
|
+
<h2 class="panel-title">Recent Audits</h2>
|
|
80
|
+
</div>
|
|
81
|
+
<table class="table">
|
|
82
|
+
<thead>
|
|
83
|
+
<tr>
|
|
84
|
+
<th>Service</th>
|
|
85
|
+
<th>Status</th>
|
|
86
|
+
</tr>
|
|
87
|
+
</thead>
|
|
88
|
+
<tbody>
|
|
89
|
+
<tr>
|
|
90
|
+
<td>us-east-cluster-01</td>
|
|
91
|
+
<td><span class="badge badge-emerald">Optimal</span></td>
|
|
92
|
+
</tr>
|
|
93
|
+
<tr>
|
|
94
|
+
<td>auth-gateway-proxy</td>
|
|
95
|
+
<td><span class="badge badge-emerald">Optimal</span></td>
|
|
96
|
+
</tr>
|
|
97
|
+
<tr>
|
|
98
|
+
<td>edge-cache-frankfurt</td>
|
|
99
|
+
<td><span class="badge badge-amber">Rebalancing</span></td>
|
|
100
|
+
</tr>
|
|
101
|
+
<tr>
|
|
102
|
+
<td>db-replica-primary</td>
|
|
103
|
+
<td><span class="badge badge-emerald">Optimal</span></td>
|
|
104
|
+
</tr>
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</main>
|
|
111
|
+
</body>
|
|
112
|
+
</html>
|
|
@@ -0,0 +1,77 @@
|
|
|
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.0">
|
|
6
|
+
<title>Customers</title>
|
|
7
|
+
<meta name="description" content="Customer list with plans and status, in the same sidebar and top bar as the dashboard">
|
|
8
|
+
<meta name="htmlboard" content="width=1280, height=820">
|
|
9
|
+
<link rel="stylesheet" href="../styles.css">
|
|
10
|
+
<script type="module" src="../components.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
.header { display: flex; justify-content: space-between; align-items: flex-end; }
|
|
13
|
+
.filters { display: flex; gap: 8px; }
|
|
14
|
+
.table .row { gap: 10px; }
|
|
15
|
+
.table .avatar { width: 30px; height: 30px; font-size: 11px; }
|
|
16
|
+
.org { font-weight: 600; }
|
|
17
|
+
.table td:last-child, .table th:last-child { text-align: right; padding-right: 0; }
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body class="app">
|
|
21
|
+
<app-sidebar active="Customers"></app-sidebar>
|
|
22
|
+
|
|
23
|
+
<main class="app-main">
|
|
24
|
+
<app-topbar search="Search customers...">
|
|
25
|
+
<button class="btn btn-secondary">Export CSV</button>
|
|
26
|
+
<button class="btn btn-primary">+ Add Customer</button>
|
|
27
|
+
</app-topbar>
|
|
28
|
+
|
|
29
|
+
<div class="app-content">
|
|
30
|
+
<div class="header">
|
|
31
|
+
<div>
|
|
32
|
+
<h1 class="page-title">Customers</h1>
|
|
33
|
+
<p class="page-sub">1,428 organizations on 3 plans</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="filters">
|
|
36
|
+
<span class="badge">All plans</span>
|
|
37
|
+
<span class="badge badge-emerald">Active</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="card">
|
|
42
|
+
<table class="table">
|
|
43
|
+
<thead>
|
|
44
|
+
<tr><th>Organization</th><th>Plan</th><th>Seats</th><th>MRR</th><th>Status</th></tr>
|
|
45
|
+
</thead>
|
|
46
|
+
<tbody>
|
|
47
|
+
<tr>
|
|
48
|
+
<td><div class="row"><div class="avatar">AC</div><div><div class="org">Acme Cloud</div><div class="muted">acme.cloud</div></div></div></td>
|
|
49
|
+
<td>Enterprise</td><td>240</td><td>$12,400</td><td><span class="badge badge-emerald">Active</span></td>
|
|
50
|
+
</tr>
|
|
51
|
+
<tr>
|
|
52
|
+
<td><div class="row"><div class="avatar">LX</div><div><div class="org">Lumen Labs</div><div class="muted">lumenlabs.io</div></div></div></td>
|
|
53
|
+
<td>Growth</td><td>58</td><td>$2,320</td><td><span class="badge badge-emerald">Active</span></td>
|
|
54
|
+
</tr>
|
|
55
|
+
<tr>
|
|
56
|
+
<td><div class="row"><div class="avatar">NB</div><div><div class="org">Northbeam</div><div class="muted">northbeam.co</div></div></div></td>
|
|
57
|
+
<td>Growth</td><td>34</td><td>$1,360</td><td><span class="badge badge-amber">Trial ends in 3 days</span></td>
|
|
58
|
+
</tr>
|
|
59
|
+
<tr>
|
|
60
|
+
<td><div class="row"><div class="avatar">QS</div><div><div class="org">Quarry Studio</div><div class="muted">quarry.studio</div></div></div></td>
|
|
61
|
+
<td>Starter</td><td>6</td><td>$90</td><td><span class="badge badge-emerald">Active</span></td>
|
|
62
|
+
</tr>
|
|
63
|
+
<tr>
|
|
64
|
+
<td><div class="row"><div class="avatar">FW</div><div><div class="org">Fieldwise</div><div class="muted">fieldwise.app</div></div></div></td>
|
|
65
|
+
<td>Enterprise</td><td>410</td><td>$19,850</td><td><span class="badge badge-rose">Payment failed</span></td>
|
|
66
|
+
</tr>
|
|
67
|
+
<tr>
|
|
68
|
+
<td><div class="row"><div class="avatar">OT</div><div><div class="org">Orbit Travel</div><div class="muted">orbit.travel</div></div></div></td>
|
|
69
|
+
<td>Growth</td><td>72</td><td>$2,880</td><td><span class="badge badge-emerald">Active</span></td>
|
|
70
|
+
</tr>
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</main>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|