@aaqu/fromcubes-portal-react 0.1.0-alpha.13 → 0.1.0-alpha.15
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/examples/004-d3-poland-flow.json +1 -1
- package/examples/007-webgpu-tsl-flow.json +1 -1
- package/nodes/lib/assets.js +212 -0
- package/nodes/lib/helpers.js +270 -0
- package/nodes/lib/page-builder.js +210 -0
- package/nodes/portal-react.html +23 -34
- package/nodes/portal-react.js +426 -578
- package/package.json +7 -3
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper functions for portal-react.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const esbuild = require("esbuild");
|
|
9
|
+
|
|
10
|
+
function hash(str) {
|
|
11
|
+
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const twCompile = require("tailwindcss").compile;
|
|
15
|
+
const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
|
|
16
|
+
|
|
17
|
+
let twCompiled = null;
|
|
18
|
+
async function getTwCompiled() {
|
|
19
|
+
if (twCompiled) return twCompiled;
|
|
20
|
+
twCompiled = await twCompile(`@import 'tailwindcss';`, {
|
|
21
|
+
loadStylesheet: async (id, base) => {
|
|
22
|
+
let resolved;
|
|
23
|
+
if (id === "tailwindcss") {
|
|
24
|
+
resolved = require.resolve("tailwindcss/index.css");
|
|
25
|
+
} else {
|
|
26
|
+
resolved = require.resolve(id, { paths: [base || __dirname] });
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
content: fs.readFileSync(resolved, "utf8"),
|
|
30
|
+
base: path.dirname(resolved),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
return twCompiled;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function generateCSS(source) {
|
|
38
|
+
const cssHash = hash(source);
|
|
39
|
+
const compiled = await getTwCompiled();
|
|
40
|
+
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
41
|
+
const css = compiled.build(candidates);
|
|
42
|
+
return { css, cssHash };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
46
|
+
|
|
47
|
+
function isSafeName(name) {
|
|
48
|
+
return (
|
|
49
|
+
typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractPortalUser(headers) {
|
|
54
|
+
const user = {};
|
|
55
|
+
if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
|
|
56
|
+
if (headers["x-portal-user-name"])
|
|
57
|
+
user.userName = headers["x-portal-user-name"];
|
|
58
|
+
if (headers["x-portal-user-username"])
|
|
59
|
+
user.username = headers["x-portal-user-username"];
|
|
60
|
+
if (headers["x-portal-user-email"])
|
|
61
|
+
user.email = headers["x-portal-user-email"];
|
|
62
|
+
if (headers["x-portal-user-role"]) user.role = headers["x-portal-user-role"];
|
|
63
|
+
if (headers["x-portal-user-groups"]) {
|
|
64
|
+
try {
|
|
65
|
+
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
66
|
+
} catch (_) {
|
|
67
|
+
user.groups = headers["x-portal-user-groups"];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return Object.keys(user).length > 0 ? user : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeRoute(router, path) {
|
|
74
|
+
if (!router || !router.stack) return;
|
|
75
|
+
router.stack = router.stack.filter(
|
|
76
|
+
(layer) => !(layer.route && layer.route.path === path),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function esc(s) {
|
|
81
|
+
return String(s)
|
|
82
|
+
.replace(/&/g, "&")
|
|
83
|
+
.replace(/</g, "<")
|
|
84
|
+
.replace(/>/g, ">")
|
|
85
|
+
.replace(/"/g, """);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function escScript(s) {
|
|
89
|
+
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = function (RED) {
|
|
93
|
+
// Package root — where react/react-dom live (this package's own node_modules)
|
|
94
|
+
const pkgRoot = path.join(__dirname, "../..");
|
|
95
|
+
// userDir — where dynamicModuleList installs user packages
|
|
96
|
+
const userDir = RED.settings.userDir || path.join(__dirname, "../../../..");
|
|
97
|
+
|
|
98
|
+
// Skip npm install for packages already present in node_modules (offline/Docker)
|
|
99
|
+
RED.hooks.add("preInstall.fcPortal", (event) => {
|
|
100
|
+
try {
|
|
101
|
+
const modDir = path.join(event.dir, "node_modules", event.module);
|
|
102
|
+
if (fs.existsSync(modDir)) {
|
|
103
|
+
RED.log.info(
|
|
104
|
+
`[portal-react] ${event.module} already in node_modules, skipping install`,
|
|
105
|
+
);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
} catch (_) {}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Disk cache for JS bundles and CSS ────────────────────────
|
|
112
|
+
const cacheDir = path.join(userDir, "fromcubes", "cache");
|
|
113
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
114
|
+
|
|
115
|
+
function readCachedJS(jsxHash) {
|
|
116
|
+
try {
|
|
117
|
+
const js = fs.readFileSync(path.join(cacheDir, jsxHash + ".js"), "utf8");
|
|
118
|
+
let metafile = null;
|
|
119
|
+
try {
|
|
120
|
+
metafile = JSON.parse(
|
|
121
|
+
fs.readFileSync(path.join(cacheDir, jsxHash + ".meta.json"), "utf8"),
|
|
122
|
+
);
|
|
123
|
+
} catch (_) {}
|
|
124
|
+
return { js, metafile, error: null };
|
|
125
|
+
} catch (_) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeCachedJS(jsxHash, js, metafile) {
|
|
131
|
+
try {
|
|
132
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".js"), js, "utf8");
|
|
133
|
+
if (metafile) {
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
path.join(cacheDir, jsxHash + ".meta.json"),
|
|
136
|
+
JSON.stringify(metafile),
|
|
137
|
+
"utf8",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
RED.log.warn("[portal-react] cache write failed: " + e.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readCachedCSS(jsxHash) {
|
|
146
|
+
try {
|
|
147
|
+
const css = fs.readFileSync(
|
|
148
|
+
path.join(cacheDir, jsxHash + ".css"),
|
|
149
|
+
"utf8",
|
|
150
|
+
);
|
|
151
|
+
return { css, cssHash: jsxHash };
|
|
152
|
+
} catch (_) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeCachedCSS(jsxHash, css) {
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".css"), css, "utf8");
|
|
160
|
+
} catch (e) {
|
|
161
|
+
RED.log.warn("[portal-react] CSS cache write failed: " + e.message);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function deleteCacheFiles(jsxHash) {
|
|
166
|
+
if (!jsxHash) return;
|
|
167
|
+
for (const ext of [".js", ".css", ".meta.json"]) {
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(path.join(cacheDir, jsxHash + ext));
|
|
170
|
+
} catch (_) {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isHashInUse(jsxHash, pageState, excludeEndpoint) {
|
|
175
|
+
for (const ep in pageState) {
|
|
176
|
+
if (ep !== excludeEndpoint && pageState[ep]?.jsxHash === jsxHash)
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function transpile(jsx) {
|
|
183
|
+
// Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
|
|
184
|
+
try {
|
|
185
|
+
esbuild.transformSync(jsx, {
|
|
186
|
+
loader: "jsx",
|
|
187
|
+
jsx: "transform",
|
|
188
|
+
jsxFactory: "React.createElement",
|
|
189
|
+
jsxFragment: "React.Fragment",
|
|
190
|
+
logLevel: "silent",
|
|
191
|
+
});
|
|
192
|
+
} catch (e) {
|
|
193
|
+
const msg = e.errors?.length
|
|
194
|
+
? e.errors
|
|
195
|
+
.map(
|
|
196
|
+
(err) =>
|
|
197
|
+
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
198
|
+
)
|
|
199
|
+
.join("\n")
|
|
200
|
+
: e.message;
|
|
201
|
+
return { js: null, error: msg };
|
|
202
|
+
}
|
|
203
|
+
// Syntax OK — bundle with full resolution
|
|
204
|
+
try {
|
|
205
|
+
const buildResult = esbuild.buildSync({
|
|
206
|
+
stdin: {
|
|
207
|
+
contents: jsx,
|
|
208
|
+
resolveDir: pkgRoot,
|
|
209
|
+
loader: "jsx",
|
|
210
|
+
},
|
|
211
|
+
bundle: true,
|
|
212
|
+
format: "iife",
|
|
213
|
+
minify: true,
|
|
214
|
+
write: false,
|
|
215
|
+
target: ["es2020"],
|
|
216
|
+
jsx: "transform",
|
|
217
|
+
jsxFactory: "React.createElement",
|
|
218
|
+
jsxFragment: "React.Fragment",
|
|
219
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
220
|
+
metafile: true,
|
|
221
|
+
logLevel: "silent",
|
|
222
|
+
logOverride: { "import-is-undefined": "silent" },
|
|
223
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
224
|
+
alias: {
|
|
225
|
+
react: path.dirname(
|
|
226
|
+
require.resolve("react/package.json", { paths: [pkgRoot] }),
|
|
227
|
+
),
|
|
228
|
+
"react-dom": path.dirname(
|
|
229
|
+
require.resolve("react-dom/package.json", { paths: [pkgRoot] }),
|
|
230
|
+
),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
js: buildResult.outputFiles[0].text,
|
|
235
|
+
metafile: buildResult.metafile,
|
|
236
|
+
error: null,
|
|
237
|
+
};
|
|
238
|
+
} catch (e) {
|
|
239
|
+
const msg = e.errors?.length
|
|
240
|
+
? e.errors
|
|
241
|
+
.map(
|
|
242
|
+
(err) =>
|
|
243
|
+
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
244
|
+
)
|
|
245
|
+
.join("\n")
|
|
246
|
+
: e.message;
|
|
247
|
+
return { js: null, error: msg };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
hash,
|
|
253
|
+
transpile,
|
|
254
|
+
generateCSS,
|
|
255
|
+
extractPortalUser,
|
|
256
|
+
removeRoute,
|
|
257
|
+
isSafeName,
|
|
258
|
+
esc,
|
|
259
|
+
escScript,
|
|
260
|
+
pkgRoot,
|
|
261
|
+
userDir,
|
|
262
|
+
cacheDir,
|
|
263
|
+
readCachedJS,
|
|
264
|
+
writeCachedJS,
|
|
265
|
+
readCachedCSS,
|
|
266
|
+
writeCachedCSS,
|
|
267
|
+
deleteCacheFiles,
|
|
268
|
+
isHashInUse,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML page builders for portal-react.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function esc(s) {
|
|
6
|
+
return String(s)
|
|
7
|
+
.replace(/&/g, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/"/g, """);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escScript(s) {
|
|
14
|
+
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
|
|
18
|
+
return `<!DOCTYPE html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="UTF-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
23
|
+
<title>${esc(title)}</title>
|
|
24
|
+
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
25
|
+
${escScript(customHead)}
|
|
26
|
+
${showWsStatus ? `<style>
|
|
27
|
+
#__cs {
|
|
28
|
+
position: fixed; bottom: 6px; right: 6px;
|
|
29
|
+
padding: 3px 8px; font-size: 10px; border-radius: 3px;
|
|
30
|
+
z-index: 99999; background: #111; border: 1px solid #333;
|
|
31
|
+
opacity: .7; transition: opacity .2s;
|
|
32
|
+
}
|
|
33
|
+
#__cs:hover { opacity: 1 }
|
|
34
|
+
#__cs.ok { color: #4ade80 }
|
|
35
|
+
#__cs.err { color: #f87171 }
|
|
36
|
+
</style>` : ""}
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<div id="root"></div>
|
|
40
|
+
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
41
|
+
<script>
|
|
42
|
+
window.__NR = {
|
|
43
|
+
_ws: null,
|
|
44
|
+
_listeners: new Set(),
|
|
45
|
+
_lastData: null,
|
|
46
|
+
_retries: 0,
|
|
47
|
+
_wasConnected: false,
|
|
48
|
+
_version: null,
|
|
49
|
+
_portalClient: null,
|
|
50
|
+
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
51
|
+
|
|
52
|
+
connect() {
|
|
53
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
54
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
55
|
+
this._ws = ws;
|
|
56
|
+
const s = document.getElementById('__cs');
|
|
57
|
+
|
|
58
|
+
ws.onopen = () => {
|
|
59
|
+
if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
|
|
60
|
+
this._retries = 0;
|
|
61
|
+
this._wasConnected = true;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ws.onmessage = (e) => {
|
|
65
|
+
try {
|
|
66
|
+
const m = JSON.parse(e.data);
|
|
67
|
+
if (m.type === 'hello') {
|
|
68
|
+
this._portalClient = m.portalClient;
|
|
69
|
+
}
|
|
70
|
+
if (m.type === 'version') {
|
|
71
|
+
var hasOverlay = document.getElementById('__building_overlay') || document.getElementById('__error_overlay');
|
|
72
|
+
if (hasOverlay || (this._version && this._version !== m.hash)) { location.reload(); return; }
|
|
73
|
+
this._version = m.hash;
|
|
74
|
+
}
|
|
75
|
+
if (m.type === 'building') {
|
|
76
|
+
document.getElementById('root').style.display = 'none';
|
|
77
|
+
var eo = document.getElementById('__error_overlay');
|
|
78
|
+
if (eo) eo.remove();
|
|
79
|
+
if (!document.getElementById('__building_overlay')) {
|
|
80
|
+
var ov = document.createElement('div');
|
|
81
|
+
ov.id = '__building_overlay';
|
|
82
|
+
ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#111;color:#888;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace';
|
|
83
|
+
ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
|
|
84
|
+
+ '<div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div>'
|
|
85
|
+
+ '<style>@keyframes __sp{to{transform:rotate(360deg)}}</style>';
|
|
86
|
+
document.body.appendChild(ov);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (m.type === 'error') {
|
|
90
|
+
document.getElementById('root').style.display = 'none';
|
|
91
|
+
var bo = document.getElementById('__building_overlay');
|
|
92
|
+
if (bo) bo.remove();
|
|
93
|
+
var ov = document.getElementById('__error_overlay');
|
|
94
|
+
if (!ov) {
|
|
95
|
+
ov = document.createElement('div');
|
|
96
|
+
ov.id = '__error_overlay';
|
|
97
|
+
ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#1a0000;color:#f87171;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace;padding:40px';
|
|
98
|
+
document.body.appendChild(ov);
|
|
99
|
+
}
|
|
100
|
+
ov.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px;font-size:24px">JSX Transpile Error</h1>'
|
|
101
|
+
+ '<p style="color:#888;margin-bottom:16px">Fix the component code in Node-RED and deploy again.</p>'
|
|
102
|
+
+ '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;max-width:90vw;max-height:60vh;overflow:auto;white-space:pre-wrap">'
|
|
103
|
+
+ m.message.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
104
|
+
+ '</pre>'
|
|
105
|
+
+ '<p style="color:#4ade80;font-size:12px;margin-top:24px">Connected \\u2014 will reload on redeploy</p>';
|
|
106
|
+
}
|
|
107
|
+
if (m.type === 'data') {
|
|
108
|
+
this._lastData = m.payload;
|
|
109
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
110
|
+
}
|
|
111
|
+
} catch (err) { console.error('WS parse', err); }
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
ws.onclose = () => {
|
|
115
|
+
if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
|
|
116
|
+
this._ws = null;
|
|
117
|
+
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
118
|
+
this._retries++;
|
|
119
|
+
setTimeout(() => this.connect(), delay);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
ws.onerror = () => ws.close();
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
subscribe(fn) {
|
|
126
|
+
this._listeners.add(fn);
|
|
127
|
+
if (this._lastData !== null) fn(this._lastData);
|
|
128
|
+
return () => this._listeners.delete(fn);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
send(payload, topic) {
|
|
132
|
+
if (this._ws && this._ws.readyState === 1)
|
|
133
|
+
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
window.__NR.connect();
|
|
137
|
+
<\/script>
|
|
138
|
+
<script>
|
|
139
|
+
try { ${escScript(transpiledJs)}
|
|
140
|
+
} catch(__e) {
|
|
141
|
+
var __r = document.getElementById('root');
|
|
142
|
+
__r.style.cssText = 'font-family:monospace;background:#1a0000;color:#f87171;padding:40px;min-height:100vh;margin:0';
|
|
143
|
+
__r.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px">Runtime Error</h1>'
|
|
144
|
+
+ '<p style="color:#888">Fix the component code in Node-RED and deploy again.</p>'
|
|
145
|
+
+ '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;white-space:pre-wrap">'
|
|
146
|
+
+ (__e.message || __e).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
|
147
|
+
}
|
|
148
|
+
<\/script>
|
|
149
|
+
</body>
|
|
150
|
+
</html>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildErrorPage(title, error, wsPath) {
|
|
154
|
+
return `<!DOCTYPE html>
|
|
155
|
+
<html lang="en">
|
|
156
|
+
<head>
|
|
157
|
+
<meta charset="UTF-8">
|
|
158
|
+
<title>${esc(title)} — Error</title>
|
|
159
|
+
<style>
|
|
160
|
+
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
161
|
+
h1 { color: #ff4444; margin-bottom: 16px }
|
|
162
|
+
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
163
|
+
.status { color: #888; font-size: 12px; margin-top: 24px }
|
|
164
|
+
.status.ok { color: #4ade80 }
|
|
165
|
+
</style>
|
|
166
|
+
</head>
|
|
167
|
+
<body>
|
|
168
|
+
<h1>JSX Transpile Error</h1>
|
|
169
|
+
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
170
|
+
<pre>${esc(error)}</pre>
|
|
171
|
+
<p class="status" id="st">Waiting for redeploy…</p>
|
|
172
|
+
<script>
|
|
173
|
+
(function() {
|
|
174
|
+
var st = document.getElementById('st');
|
|
175
|
+
var retries = 0;
|
|
176
|
+
function connect() {
|
|
177
|
+
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
178
|
+
var ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
179
|
+
ws.onopen = function() {
|
|
180
|
+
retries = 0;
|
|
181
|
+
if (st) { st.textContent = 'Connected \\u2014 will reload on redeploy'; st.className = 'status ok'; }
|
|
182
|
+
};
|
|
183
|
+
ws.onmessage = function(e) {
|
|
184
|
+
try {
|
|
185
|
+
var m = JSON.parse(e.data);
|
|
186
|
+
if (m.type === 'version' && m.hash) location.reload();
|
|
187
|
+
if (m.type === 'error') { var pre = document.querySelector('pre'); if (pre) pre.textContent = m.message; }
|
|
188
|
+
} catch(_) {}
|
|
189
|
+
};
|
|
190
|
+
ws.onclose = function() {
|
|
191
|
+
if (st) { st.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; st.className = 'status'; }
|
|
192
|
+
var delay = Math.min(500 * Math.pow(2, retries), 8000);
|
|
193
|
+
retries++;
|
|
194
|
+
setTimeout(connect, delay);
|
|
195
|
+
};
|
|
196
|
+
ws.onerror = function() { ws.close(); };
|
|
197
|
+
}
|
|
198
|
+
${wsPath ? "connect();" : ""}
|
|
199
|
+
setInterval(function() {
|
|
200
|
+
fetch(location.href, { method: 'HEAD', cache: 'no-store' })
|
|
201
|
+
.then(function(r) { if (r.ok) location.reload(); })
|
|
202
|
+
.catch(function() {});
|
|
203
|
+
}, 3000);
|
|
204
|
+
})();
|
|
205
|
+
<\/script>
|
|
206
|
+
</body>
|
|
207
|
+
</html>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { buildPage, buildErrorPage };
|
package/nodes/portal-react.html
CHANGED
|
@@ -567,26 +567,6 @@
|
|
|
567
567
|
<label for="node-input-compName"><i class="fa fa-cube"></i> JSX Tag</label>
|
|
568
568
|
<input type="text" id="node-input-compName" placeholder="MyComponent" />
|
|
569
569
|
</div>
|
|
570
|
-
<div class="form-row" style="display:flex;gap:8px;">
|
|
571
|
-
<div style="flex:1">
|
|
572
|
-
<label><i class="fa fa-sign-in"></i> Input fields</label>
|
|
573
|
-
<input
|
|
574
|
-
type="text"
|
|
575
|
-
id="node-input-compInputs"
|
|
576
|
-
placeholder="payload,topic"
|
|
577
|
-
style="width:100%"
|
|
578
|
-
/>
|
|
579
|
-
</div>
|
|
580
|
-
<div style="flex:1">
|
|
581
|
-
<label><i class="fa fa-sign-out"></i> Output fields</label>
|
|
582
|
-
<input
|
|
583
|
-
type="text"
|
|
584
|
-
id="node-input-compOutputs"
|
|
585
|
-
placeholder="payload,topic"
|
|
586
|
-
style="width:100%"
|
|
587
|
-
/>
|
|
588
|
-
</div>
|
|
589
|
-
</div>
|
|
590
570
|
<div class="form-row" style="margin-bottom:4px;">
|
|
591
571
|
<label><i class="fa fa-code"></i> JSX Code</label>
|
|
592
572
|
</div>
|
|
@@ -629,8 +609,6 @@
|
|
|
629
609
|
name: { value: "" },
|
|
630
610
|
compName: { value: "StatusCard", required: true },
|
|
631
611
|
compCode: { value: COMP_STARTER },
|
|
632
|
-
compInputs: { value: "label,value,unit" },
|
|
633
|
-
compOutputs: { value: "" },
|
|
634
612
|
},
|
|
635
613
|
inputs: 0,
|
|
636
614
|
outputs: 0,
|
|
@@ -1176,12 +1154,6 @@
|
|
|
1176
1154
|
return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
|
|
1177
1155
|
}).join("") + '</div>');
|
|
1178
1156
|
}
|
|
1179
|
-
var io = [];
|
|
1180
|
-
if ((c.inputs || []).length) io.push("in: " + c.inputs.join(", "));
|
|
1181
|
-
if ((c.outputs || []).length) io.push("out: " + c.outputs.join(", "));
|
|
1182
|
-
if (io.length) {
|
|
1183
|
-
detailParts.push('<div style="font-size:10px;opacity:.4;margin-top:2px;">' + io.join(" • ") + '</div>');
|
|
1184
|
-
}
|
|
1185
1157
|
var hasDetail = detailParts.length > 0;
|
|
1186
1158
|
|
|
1187
1159
|
html +=
|
|
@@ -1479,12 +1451,12 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1479
1451
|
var fileList = $('<div style="padding:0;"></div>').appendTo(content);
|
|
1480
1452
|
|
|
1481
1453
|
// ── Toolbar ──
|
|
1482
|
-
var toolbar = $('<div style="display:flex;align-items:center;gap:
|
|
1454
|
+
var toolbar = $('<div style="display:flex;align-items:center;gap:6px;margin:0 6px;padding:2px 0 0;"></div>');
|
|
1483
1455
|
var fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
|
|
1484
|
-
$('<
|
|
1456
|
+
$('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-upload"></i> Upload</button>')
|
|
1485
1457
|
.on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
|
|
1486
1458
|
.appendTo(toolbar);
|
|
1487
|
-
$('<
|
|
1459
|
+
$('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-folder-open"></i> New folder</button>')
|
|
1488
1460
|
.on("click", function (e) { e.preventDefault(); showNewFolderInput(""); })
|
|
1489
1461
|
.appendTo(toolbar);
|
|
1490
1462
|
|
|
@@ -1758,14 +1730,31 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1758
1730
|
return root;
|
|
1759
1731
|
}
|
|
1760
1732
|
|
|
1733
|
+
var ROOT_KEY = "__root__";
|
|
1734
|
+
|
|
1761
1735
|
function renderTree() {
|
|
1762
1736
|
fileList.empty();
|
|
1737
|
+
var isOpen = !collapsed[ROOT_KEY];
|
|
1738
|
+
|
|
1739
|
+
// Root folder row — always visible
|
|
1740
|
+
var rootRow = $('<div style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
|
|
1741
|
+
var rootArrow = $('<i class="fa ' + (isOpen ? 'fa-caret-down' : 'fa-caret-right') + '" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:10px;text-align:center;cursor:pointer;"></i>');
|
|
1742
|
+
rootArrow.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); });
|
|
1743
|
+
rootRow.append(rootArrow);
|
|
1744
|
+
$('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(rootRow);
|
|
1745
|
+
$('<span style="flex:1;font-size:12px;cursor:pointer;opacity:0.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>').text(publicBase.replace(/\/$/, ""))
|
|
1746
|
+
.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); })
|
|
1747
|
+
.appendTo(rootRow);
|
|
1748
|
+
fileList.append(rootRow);
|
|
1749
|
+
|
|
1750
|
+
if (!isOpen) return;
|
|
1751
|
+
|
|
1763
1752
|
if (allEntries.length === 0) {
|
|
1764
1753
|
fileList.append($('<div style="padding:16px;color:var(--red-ui-secondary-text-color);text-align:center;">No files uploaded.<br><span style="font-size:11px;">Drop files here or click Upload.</span></div>'));
|
|
1765
1754
|
return;
|
|
1766
1755
|
}
|
|
1767
1756
|
var tree = buildTree(allEntries);
|
|
1768
|
-
renderNode(tree, "",
|
|
1757
|
+
renderNode(tree, "", 1);
|
|
1769
1758
|
}
|
|
1770
1759
|
|
|
1771
1760
|
function renderNode(node, parentPath, depth) {
|
|
@@ -1800,7 +1789,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1800
1789
|
});
|
|
1801
1790
|
row.append(arrow);
|
|
1802
1791
|
$('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(row);
|
|
1803
|
-
$('<span style="flex:1;font-size:12px;cursor:pointer;"></span>').text(name)
|
|
1792
|
+
$('<span style="flex:1;font-size:12px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name)
|
|
1804
1793
|
.on("click", function () { collapsed[fullPath] = isOpen; renderTree(); })
|
|
1805
1794
|
.appendTo(row);
|
|
1806
1795
|
|
|
@@ -1862,7 +1851,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1862
1851
|
row.attr("draggable", "true").css("cursor", "grab");
|
|
1863
1852
|
$('<span style="width:10px;"></span>').appendTo(row); // spacer for arrow alignment
|
|
1864
1853
|
$('<i class="fa fa-file-o" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:16px;text-align:center;"></i>').appendTo(row);
|
|
1865
|
-
$('<span style="flex:1;font-size:12px;
|
|
1854
|
+
$('<span style="flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name).appendTo(row);
|
|
1866
1855
|
$('<span style="font-size:10px;color:var(--red-ui-tertiary-text-color);white-space:nowrap;"></span>').text(formatSize(e.size)).appendTo(row);
|
|
1867
1856
|
|
|
1868
1857
|
row.on("dragstart", function (ev) {
|