@aaqu/fromcubes-react 0.1.0 → 0.2.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 CHANGED
@@ -6,20 +6,19 @@
6
6
  [![Node-RED](https://img.shields.io/badge/Node--RED-%E2%89%A5%204.0-8f0000.svg)](https://nodered.org)
7
7
  [![license](https://img.shields.io/npm/l/%40aaqu%2Ffromcubes-react.svg)](./LICENSE)
8
8
  [![Discord](https://img.shields.io/badge/Discord-join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/KYzsfKkYxX)
9
+ [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Aaqu)
9
10
 
10
11
  **📖 Documentation — step-by-step guide with screenshots:** [aaqu.github.io/fromcubes-modules](https://aaqu.github.io/fromcubes-modules/) — install → import → edit JSX → live dashboard, on one example.
11
12
 
12
13
  [![Documentation walkthrough](https://aaqu.github.io/fromcubes-modules/img/docs-scroll.gif)](https://aaqu.github.io/fromcubes-modules/)
13
14
 
14
- > **⚠️ Alpha Module** — This project is in early development. Expect breaking changes. Test on a clean Node-RED instance.
15
-
16
15
  A Node-RED node that turns any `/fromcubes/<sub-path>` URL into a React page. Write JSX in the editor, deploy, open the URL — your component talks to the flow over WebSocket. No build step, no browser compiler. All portal pages are served under the hardcoded `/fromcubes/` prefix so every node cleanly coexists under one URL tree.
17
16
 
18
17
  For internals, plugin authoring, and the deploy pipeline see [README-DEV.md](./README-DEV.md).
19
18
 
20
19
  **💬 Questions, feedback, showcase?** Join the [fromcubes Discord](https://discord.gg/KYzsfKkYxX).
21
20
 
22
- [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L01UOFRG)
21
+ **❤️ Support the project:** [![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Aaqu)
23
22
 
24
23
  ## Prerequisites
25
24
 
@@ -41,12 +40,17 @@ npm install @aaqu/fromcubes-react
41
40
  # restart Node-RED
42
41
  ```
43
42
 
44
- That's it. No build step. Any npm packages you list in the node config (e.g. `d3, three`) are installed automatically on deploy.
43
+ That's it — this is the whole core: the nodes, deploy-time bundling, the WebSocket bridge and the React Editor. No build step. Any npm packages you list in the node config (e.g. `d3, three`) are installed automatically on deploy.
45
44
 
46
45
  ### Add-ons
47
46
 
48
- Three parts ship as their own packages, and for now installing the core
49
- installs them too:
47
+ Everything else is an add-on you install only if you need it:
48
+
49
+ ```bash
50
+ cd ~/.node-red
51
+ npm install @aaqu/fromcubes-tailwind # and/or -assets, -auth-headers
52
+ # restart Node-RED
53
+ ```
50
54
 
51
55
  | Package | What it adds |
52
56
  |---|---|
@@ -54,10 +58,29 @@ installs them too:
54
58
  | [`@aaqu/fromcubes-auth-headers`](https://www.npmjs.com/package/@aaqu/fromcubes-auth-headers) | Users from `x-portal-user-*` headers set by a reverse proxy (Portal Auth) |
55
59
  | [`@aaqu/fromcubes-assets`](https://www.npmjs.com/package/@aaqu/fromcubes-assets) | Static files for portals, managed from a sidebar tab |
56
60
 
57
- Each is a Node-RED plugin built on the same API anyone can use — see
58
- [Plugins](#plugins-identity-providers-and-css-engines). Coming from
59
- `@aaqu/fromcubes-portal-react`? Uninstall it first, then install this
60
- package: both register the same nodes. Your flows keep working unchanged.
61
+ Without an add-on its feature is simply off, and the editor says so once in
62
+ the log: no Tailwind means portals get no generated stylesheet (style them
63
+ with a `<link>`/`<style>` in **Head HTML**, or inline), no auth-headers means
64
+ Portal Auth sees every session as anonymous, no assets means no sidebar and no
65
+ `/fromcubes/public/`. Install an add-on later and it switches on by itself —
66
+ **Menu → Settings → Fromcubes** shows what is in use.
67
+
68
+ The shipped examples are styled with Tailwind; install
69
+ `@aaqu/fromcubes-tailwind` to see them as intended.
70
+
71
+ Each add-on is a Node-RED plugin built on the same API anyone can use — see
72
+ [Plugins](#plugins-identity-providers-and-css-engines).
73
+
74
+ **Coming from `@aaqu/fromcubes-portal-react`?** It contained all of the above.
75
+ Uninstall it first (both register the same nodes), then install the core
76
+ **and the add-ons you used** — for the same features as before:
77
+
78
+ ```bash
79
+ npm uninstall @aaqu/fromcubes-portal-react
80
+ npm install @aaqu/fromcubes-react @aaqu/fromcubes-tailwind @aaqu/fromcubes-assets @aaqu/fromcubes-auth-headers
81
+ ```
82
+
83
+ Your flows keep working unchanged.
61
84
 
62
85
  ## Your first portal
63
86
 
@@ -401,6 +424,13 @@ behind a reverse proxy. Highlights:
401
424
  inbound identity headers before injecting verified ones, and Node-RED must
402
425
  not be reachable except through that proxy.
403
426
 
427
+ ## Support
428
+
429
+ fromcubes is built and maintained by one person. If it saves you time,
430
+ consider sponsoring its development:
431
+
432
+ [![Sponsor on GitHub](https://img.shields.io/badge/Sponsor_on-GitHub-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Aaqu)
433
+
404
434
  ## License
405
435
 
406
436
  Apache-2.0
@@ -29,8 +29,8 @@
29
29
  "id": "fc-pixi-comp-statusbar",
30
30
  "type": "fc-portal-component",
31
31
  "z": "fc-pixi-flow",
32
- "compName": "StatusBar",
33
- "compCode": "function StatusBar({ spriteCount, totalClicks, lastClicked }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Sprites\">\n <span className=\"text-sm font-mono text-cyan-400\">{spriteCount}</span>\n </Stat>\n <Stat label=\"Total clicks\">\n <span className=\"text-sm font-mono text-emerald-400\">{totalClicks}</span>\n </Stat>\n <Stat label=\"Last clicked\">\n <span className=\"text-sm font-mono text-zinc-300\">{lastClicked || '\\u2014'}</span>\n </Stat>\n </div>\n );\n}",
32
+ "compName": "PixiStatusBar",
33
+ "compCode": "function PixiStatusBar({ spriteCount, totalClicks, lastClicked }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Sprites\">\n <span className=\"text-sm font-mono text-cyan-400\">{spriteCount}</span>\n </Stat>\n <Stat label=\"Total clicks\">\n <span className=\"text-sm font-mono text-emerald-400\">{totalClicks}</span>\n </Stat>\n <Stat label=\"Last clicked\">\n <span className=\"text-sm font-mono text-zinc-300\">{lastClicked || '\\u2014'}</span>\n </Stat>\n </div>\n );\n}",
34
34
  "x": 360,
35
35
  "y": 300,
36
36
  "wires": []
@@ -91,7 +91,7 @@
91
91
  "var": "PixiReact"
92
92
  }
93
93
  ],
94
- "componentCode": "import { Sprite } from 'pixi.js';\nimport { Application, extend } from '@pixi/react';\nextend({ Sprite });\n\nfunction App() {\n const { data, send } = useNodeRed();\n const [sprites, setSprites] = useState([]);\n const [totalClicks, setTotalClicks] = useState(0);\n const [lastClicked, setLastClicked] = useState(null);\n const idRef = useRef(0);\n\n useEffect(() => {\n if (!data) return;\n setSprites(prev => {\n const next = [...prev, {\n id: idRef.current++,\n x: 40 + Math.random() * 520,\n y: 40 + Math.random() * 320,\n tint: data.tint || 0x22d3ee\n }];\n return next.length > 12 ? next.slice(-12) : next;\n });\n }, [data]);\n\n const handleClick = useCallback((info) => {\n setTotalClicks(c => c + 1);\n setLastClicked('#' + info.id);\n send({\n action: 'clicked',\n spriteId: info.id,\n x: Math.round(info.x),\n y: Math.round(info.y),\n spriteClicks: info.clicks,\n timestamp: Date.now()\n }, 'pixi/click');\n }, [send]);\n\n return (\n <Page>\n <Header title=\"fromcubes Pixi\" subtitle=\"click the bunnies\" />\n <div className=\"flex-1 flex items-center justify-center p-6\">\n <div className=\"rounded-xl overflow-hidden border border-zinc-800\">\n <Application width={600} height={400} background={0x18181b} antialias>\n {sprites.map(s => (\n <ClickableSprite key={s.id} {...s} onClick={handleClick} />\n ))}\n </Application>\n </div>\n </div>\n <StatusBar spriteCount={sprites.length} totalClicks={totalClicks} lastClicked={lastClicked} />\n </Page>\n );\n}",
94
+ "componentCode": "import { Sprite } from 'pixi.js';\nimport { Application, extend } from '@pixi/react';\nextend({ Sprite });\n\nfunction App() {\n const { data, send } = useNodeRed();\n const [sprites, setSprites] = useState([]);\n const [totalClicks, setTotalClicks] = useState(0);\n const [lastClicked, setLastClicked] = useState(null);\n const idRef = useRef(0);\n\n useEffect(() => {\n if (!data) return;\n setSprites(prev => {\n const next = [...prev, {\n id: idRef.current++,\n x: 40 + Math.random() * 520,\n y: 40 + Math.random() * 320,\n tint: data.tint || 0x22d3ee\n }];\n return next.length > 12 ? next.slice(-12) : next;\n });\n }, [data]);\n\n const handleClick = useCallback((info) => {\n setTotalClicks(c => c + 1);\n setLastClicked('#' + info.id);\n send({\n action: 'clicked',\n spriteId: info.id,\n x: Math.round(info.x),\n y: Math.round(info.y),\n spriteClicks: info.clicks,\n timestamp: Date.now()\n }, 'pixi/click');\n }, [send]);\n\n return (\n <Page>\n <Header title=\"fromcubes Pixi\" subtitle=\"click the bunnies\" />\n <div className=\"flex-1 flex items-center justify-center p-6\">\n <div className=\"rounded-xl overflow-hidden border border-zinc-800\">\n <Application width={600} height={400} background={0x18181b} antialias>\n {sprites.map(s => (\n <ClickableSprite key={s.id} {...s} onClick={handleClick} />\n ))}\n </Application>\n </div>\n </div>\n <PixiStatusBar spriteCount={sprites.length} totalClicks={totalClicks} lastClicked={lastClicked} />\n </Page>\n );\n}",
95
95
  "x": 560,
96
96
  "y": 160,
97
97
  "wires": [
@@ -29,8 +29,8 @@
29
29
  "id": "fc-three-comp-statusbar",
30
30
  "type": "fc-portal-component",
31
31
  "z": "fc-three-flow",
32
- "compName": "StatusBar",
33
- "compCode": "function StatusBar({ leftColor, rightColor, clickCount }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Left box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: leftColor }} />\n </Stat>\n <Stat label=\"Right box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: rightColor }} />\n </Stat>\n <Stat label=\"Clicks\">\n <span className=\"text-sm font-mono text-cyan-400\">{clickCount}</span>\n </Stat>\n </div>\n );\n}",
32
+ "compName": "ThreeStatusBar",
33
+ "compCode": "function ThreeStatusBar({ leftColor, rightColor, clickCount }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Left box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: leftColor }} />\n </Stat>\n <Stat label=\"Right box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: rightColor }} />\n </Stat>\n <Stat label=\"Clicks\">\n <span className=\"text-sm font-mono text-cyan-400\">{clickCount}</span>\n </Stat>\n </div>\n );\n}",
34
34
  "x": 360,
35
35
  "y": 300,
36
36
  "wires": []
@@ -95,7 +95,7 @@
95
95
  "var": "Drei"
96
96
  }
97
97
  ],
98
- "componentCode": "import { Canvas } from '@react-three/fiber';\nimport { OrbitControls } from '@react-three/drei';\n\nfunction App() {\n const { data, send } = useNodeRed();\n const colors = data || { leftColor: 'orange', rightColor: 'cyan' };\n const [clickCount, setClickCount] = useState(0);\n\n const handleToggle = useCallback((clicked) => {\n setClickCount(prev => prev + 1);\n send({ action: clicked ? 'clicked' : 'unclicked', clicks: clickCount + 1 }, '3d/interact');\n }, [send, clickCount]);\n\n return (\n <div className=\"h-screen bg-zinc-950 text-zinc-100 flex flex-col\">\n <Header title=\"fromcubes 3D\" subtitle=\"React Three Fiber + live Node-RED data\" />\n <div className=\"flex-1 overflow-hidden\">\n <Canvas style={{ width: '100%', height: '100%' }}>\n <ambientLight intensity={Math.PI / 2} />\n <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} decay={0} intensity={Math.PI} />\n <pointLight position={[-10, -10, -10]} decay={0} intensity={Math.PI} />\n <Box position={[-1.2, 0, 0]} color={colors.leftColor} onToggle={handleToggle} />\n <Box position={[1.2, 0, 0]} color={colors.rightColor} onToggle={handleToggle} />\n <OrbitControls />\n </Canvas>\n </div>\n <StatusBar leftColor={colors.leftColor} rightColor={colors.rightColor} clickCount={clickCount} />\n </div>\n );\n}",
98
+ "componentCode": "import { Canvas } from '@react-three/fiber';\nimport { OrbitControls } from '@react-three/drei';\n\nfunction App() {\n const { data, send } = useNodeRed();\n const colors = data || { leftColor: 'orange', rightColor: 'cyan' };\n const [clickCount, setClickCount] = useState(0);\n\n const handleToggle = useCallback((clicked) => {\n setClickCount(prev => prev + 1);\n send({ action: clicked ? 'clicked' : 'unclicked', clicks: clickCount + 1 }, '3d/interact');\n }, [send, clickCount]);\n\n return (\n <div className=\"h-screen bg-zinc-950 text-zinc-100 flex flex-col\">\n <Header title=\"fromcubes 3D\" subtitle=\"React Three Fiber + live Node-RED data\" />\n <div className=\"flex-1 overflow-hidden\">\n <Canvas style={{ width: '100%', height: '100%' }}>\n <ambientLight intensity={Math.PI / 2} />\n <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} decay={0} intensity={Math.PI} />\n <pointLight position={[-10, -10, -10]} decay={0} intensity={Math.PI} />\n <Box position={[-1.2, 0, 0]} color={colors.leftColor} onToggle={handleToggle} />\n <Box position={[1.2, 0, 0]} color={colors.rightColor} onToggle={handleToggle} />\n <OrbitControls />\n </Canvas>\n </div>\n <ThreeStatusBar leftColor={colors.leftColor} rightColor={colors.rightColor} clickCount={clickCount} />\n </div>\n );\n}",
99
99
  "x": 560,
100
100
  "y": 160,
101
101
  "wires": [
@@ -630,6 +630,13 @@ body.fc-ws-open #red-ui-notifications {
630
630
  /* ── Settings → Fromcubes (provider-settings.js) ─────────────────
631
631
  Lives in Node-RED's own Settings tray, not in the overlay, so it uses
632
632
  Node-RED's theme variables instead of the vs-dark palette above. */
633
+ /* Same box as Node-RED's own View pane (#red-ui-settings-tab-view): fills
634
+ the content area, scrolls on its own, 20px side padding. */
635
+ .fc-ps {
636
+ position: absolute; top: 0; right: 0; bottom: 0; left: 0;
637
+ padding: 8px 20px 20px;
638
+ overflow-y: auto;
639
+ }
633
640
  .fc-ps .fc-ps-section { margin-bottom: 24px; }
634
641
  .fc-ps .fc-ps-hint { color: var(--red-ui-secondary-text-color); margin: 0 0 8px; }
635
642
  .fc-ps .fc-ps-note,
@@ -30,9 +30,13 @@
30
30
  * — the administrator's word; the editor shows it locked.
31
31
  * 2. The choice made in the editor (Settings → Fromcubes), persisted in
32
32
  * `<userDir>/fromcubes/providers.json`.
33
- * 3. The defaults, `headers` and `tailwind` — names, not code: when their
34
- * add-on is not installed the default is simply missing (identity:
35
- * anonymous; css: unstyled), exactly like any other missing provider.
33
+ * 3. The default: the official add-on when it is installed — `headers`
34
+ * from @aaqu/fromcubes-auth-headers, `tailwind` from
35
+ * @aaqu/fromcubes-tailwind — and `none` when it is not. The core works
36
+ * on its own; installing the add-on is what turns the feature on. Only
37
+ * the official package counts here: another package registering a
38
+ * plugin called `headers` never becomes the default identity provider
39
+ * (it can still be chosen explicitly, in 1 or 2).
36
40
  * Installing a plugin never switches anything on its own — for identity that
37
41
  * would let a package change who counts as logged in.
38
42
  *
@@ -50,6 +54,7 @@ const API_VERSION = 1;
50
54
 
51
55
  const DEFAULT_IDENTITY = "headers";
52
56
  const DEFAULT_CSS = "tailwind";
57
+ const NONE = "none";
53
58
 
54
59
  // Bounds on what a provider may hand us. Generous enough that anything the
55
60
  // built-in header parser produces passes untouched (its groups header is
@@ -68,8 +73,18 @@ const MAX_EDITOR_CLASS_LEN = 200;
68
73
  const USER_STRING_FIELDS = ["login", "displayName", "email", "role"];
69
74
 
70
75
  const KINDS = {
71
- identity: { type: IDENTITY_TYPE, setting: "identityProvider", fallback: DEFAULT_IDENTITY },
72
- css: { type: CSS_TYPE, setting: "cssEngine", fallback: DEFAULT_CSS },
76
+ identity: {
77
+ type: IDENTITY_TYPE,
78
+ setting: "identityProvider",
79
+ fallback: DEFAULT_IDENTITY,
80
+ fallbackModule: "@aaqu/fromcubes-auth-headers",
81
+ },
82
+ css: {
83
+ type: CSS_TYPE,
84
+ setting: "cssEngine",
85
+ fallback: DEFAULT_CSS,
86
+ fallbackModule: "@aaqu/fromcubes-tailwind",
87
+ },
73
88
  };
74
89
 
75
90
  // Provider ids travel through the settings file, the admin API and the
@@ -261,7 +276,24 @@ function createProviders(RED, opts = {}) {
261
276
  return { id: fromSettings, source: "settings" };
262
277
  }
263
278
  if (chosen[kind]) return { id: chosen[kind], source: "editor" };
264
- return { id: k.fallback, source: "default" };
279
+ const official = pluginsOf(k.type).some(
280
+ (p) => p.id === k.fallback && p.module === k.fallbackModule,
281
+ );
282
+ return { id: official ? k.fallback : NONE, source: "default", addonMissing: !official };
283
+ }
284
+
285
+ /**
286
+ * Log once, at info level, why a kind runs on `none` by default — so a
287
+ * core installed on its own explains itself instead of failing quietly.
288
+ * @param {string} kind
289
+ * @param {string} msg
290
+ * @returns {void}
291
+ */
292
+ function explainNone(kind, msg) {
293
+ const key = "none:" + kind;
294
+ if (logged.has(key)) return;
295
+ logged.add(key);
296
+ if (RED.log.info) RED.log.info("[portal-react] " + msg);
265
297
  }
266
298
 
267
299
  /**
@@ -428,7 +460,15 @@ function createProviders(RED, opts = {}) {
428
460
  * @returns {Promise<Object|null>}
429
461
  */
430
462
  async function resolveUser(req) {
431
- const id = identityId();
463
+ const cur = current("identity");
464
+ const id = cur.id;
465
+ // Called only for portals with Portal Auth on — so this is someone who
466
+ // expects users and gets none.
467
+ if (cur.addonMissing) {
468
+ explainNone("identity", "Portal Auth is on but no identity provider is installed, so every " +
469
+ "session is anonymous. For users from x-portal-user-* headers install " +
470
+ "@aaqu/fromcubes-auth-headers, or choose a provider in Settings → Fromcubes.");
471
+ }
432
472
  const { provider, error } = lookup(IDENTITY_TYPE, builtins.identity, id);
433
473
  if (!provider) {
434
474
  logOnce(error + " — every session is anonymous");
@@ -466,7 +506,12 @@ function createProviders(RED, opts = {}) {
466
506
  * @returns {Promise<{css: string}>}
467
507
  */
468
508
  async function generateCss(source) {
469
- const id = cssEngineId();
509
+ const cur = current("css");
510
+ const id = cur.id;
511
+ if (cur.addonMissing) {
512
+ explainNone("css", "No CSS engine is installed, so portals are served without a stylesheet. " +
513
+ "For Tailwind CSS install @aaqu/fromcubes-tailwind.");
514
+ }
470
515
  const { provider, error } = lookup(CSS_TYPE, builtins.css, id);
471
516
  if (!provider) throw new Error(error);
472
517
  if (typeof provider.generate !== "function") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-react",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Fromcubes React - React portals for Node-RED, built at deploy, with live WebSocket data binding",
5
5
  "type": "commonjs",
6
6
  "keywords": [
@@ -41,10 +41,7 @@
41
41
  "monaco-editor": "^0.56.0",
42
42
  "prettier": "^3.9.8",
43
43
  "react": "^19.3.0",
44
- "react-dom": "^19.3.0",
45
- "@aaqu/fromcubes-assets": "^0.1.0",
46
- "@aaqu/fromcubes-auth-headers": "^0.1.0",
47
- "@aaqu/fromcubes-tailwind": "^0.1.0"
44
+ "react-dom": "^19.3.0"
48
45
  },
49
46
  "peerDependencies": {
50
47
  "ws": "^8.21.0"
@@ -64,8 +61,8 @@
64
61
  },
65
62
  "homepage": "https://aaqu.github.io/fromcubes-modules/",
66
63
  "funding": {
67
- "type": "ko-fi",
68
- "url": "https://ko-fi.com/L4L01UOFRG"
64
+ "type": "github",
65
+ "url": "https://github.com/sponsors/Aaqu"
69
66
  },
70
67
  "license": "Apache-2.0",
71
68
  "author": {