@aaqu/fromcubes-portal-react 0.1.0-alpha.12 → 0.1.0-alpha.13
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 +48 -25
- package/examples/001-shared-components-flow.json +68 -0
- package/examples/{sensor-portal-flow.json → 002-sensor-portal-flow.json} +2 -28
- package/examples/003-chart-portal-flow.json +93 -0
- package/examples/004-d3-poland-flow.json +80 -0
- package/examples/005-threejs-portal-flow.json +87 -0
- package/examples/006-pixi-portal-flow.json +86 -0
- package/examples/007-webgpu-tsl-flow.json +85 -0
- package/nodes/portal-react.html +508 -9
- package/nodes/portal-react.js +288 -172
- package/package.json +1 -1
- package/examples/chart-portal-flow.json +0 -74
- package/examples/d3-poland-flow.json +0 -80
- package/examples/threejs-portal-flow.json +0 -61
package/nodes/portal-react.html
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
var libContent = [
|
|
57
57
|
"declare var React: any;",
|
|
58
58
|
"declare var ReactDOM: any;",
|
|
59
|
-
"declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null };",
|
|
59
|
+
"declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null; portalClient: string | null };",
|
|
60
60
|
].join("\n");
|
|
61
61
|
console.log(PREFIX, "addExtraLib globals.d.ts");
|
|
62
62
|
jsDef.addExtraLib(libContent, "file:///globals.d.ts");
|
|
@@ -920,7 +920,7 @@
|
|
|
920
920
|
var headEditorInstance = null;
|
|
921
921
|
|
|
922
922
|
var STARTER = [
|
|
923
|
-
"// useNodeRed() \u2192 { data, send }",
|
|
923
|
+
"// useNodeRed() \u2192 { data, send, portalClient }",
|
|
924
924
|
"// data = last msg.payload from input wire",
|
|
925
925
|
"// send(payload, topic?) = push msg to output wire",
|
|
926
926
|
"// Components from fc-portal-component nodes are available by name.",
|
|
@@ -1347,7 +1347,7 @@
|
|
|
1347
1347
|
<script type="text/html" data-help-name="portal-react">
|
|
1348
1348
|
<h3>Quick Reference</h3>
|
|
1349
1349
|
<ul>
|
|
1350
|
-
<li><code>useNodeRed()</code> → <code>{ data, send, user }</code></li>
|
|
1350
|
+
<li><code>useNodeRed()</code> → <code>{ data, send, user, portalClient }</code></li>
|
|
1351
1351
|
<li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
|
|
1352
1352
|
<li>Must export <code><App /></code></li>
|
|
1353
1353
|
<li>JSX is transpiled server-side at deploy</li>
|
|
@@ -1363,13 +1363,15 @@
|
|
|
1363
1363
|
|
|
1364
1364
|
<h3>Inputs</h3>
|
|
1365
1365
|
<p>
|
|
1366
|
-
<code>msg.payload</code> is pushed
|
|
1366
|
+
<code>msg.payload</code> is pushed via WebSocket. Set <code>msg._client</code>
|
|
1367
|
+
to target specific clients, or omit it to broadcast to all.
|
|
1367
1368
|
</p>
|
|
1368
1369
|
<pre>
|
|
1369
|
-
const { data, send, user } = useNodeRed();
|
|
1370
|
-
// data
|
|
1370
|
+
const { data, send, user, portalClient } = useNodeRed();
|
|
1371
|
+
// data = last msg.payload (reactive)
|
|
1371
1372
|
// send(payload, topic?) — emit msg on output wire
|
|
1372
|
-
// user
|
|
1373
|
+
// user = portal auth data or null
|
|
1374
|
+
// portalClient = unique session/tab ID (assigned by server)</pre
|
|
1373
1375
|
>
|
|
1374
1376
|
|
|
1375
1377
|
<h3>Outputs</h3>
|
|
@@ -1422,9 +1424,21 @@ const { data, send, user } = useNodeRed();
|
|
|
1422
1424
|
a reverse proxy (e.g. Nginx) and exposes user data:
|
|
1423
1425
|
</p>
|
|
1424
1426
|
<ul>
|
|
1425
|
-
<li><code>useNodeRed()</code> returns <code>{ data, send, user }</code></li>
|
|
1427
|
+
<li><code>useNodeRed()</code> returns <code>{ data, send, user, portalClient }</code></li>
|
|
1426
1428
|
<li>
|
|
1427
|
-
|
|
1429
|
+
All WebSocket messages include <code>msg._client</code> with
|
|
1430
|
+
<code>{ portalClient, ...userFields }</code>
|
|
1431
|
+
</li>
|
|
1432
|
+
<li>
|
|
1433
|
+
To target a specific tab: keep <code>msg._client</code> (or set
|
|
1434
|
+
<code>msg._client = { portalClient: "..." }</code>)
|
|
1435
|
+
</li>
|
|
1436
|
+
<li>
|
|
1437
|
+
To target all tabs of a user: set
|
|
1438
|
+
<code>msg._client = { userId: "..." }</code> (no portalClient)
|
|
1439
|
+
</li>
|
|
1440
|
+
<li>
|
|
1441
|
+
To broadcast to all: remove <code>msg._client</code>
|
|
1428
1442
|
</li>
|
|
1429
1443
|
</ul>
|
|
1430
1444
|
|
|
@@ -1444,3 +1458,488 @@ const { data, send, user } = useNodeRed();
|
|
|
1444
1458
|
indicator</strong> checkbox (off by default).
|
|
1445
1459
|
</p>
|
|
1446
1460
|
</script>
|
|
1461
|
+
|
|
1462
|
+
<!-- ── Assets sidebar tab ──────────────────────────────────────── -->
|
|
1463
|
+
<script type="text/javascript">
|
|
1464
|
+
(function () {
|
|
1465
|
+
// Resolve httpNodeRoot for public URL prefix
|
|
1466
|
+
var nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1467
|
+
var publicBase = nodeRoot + "/fromcubes/public/";
|
|
1468
|
+
|
|
1469
|
+
function formatSize(bytes) {
|
|
1470
|
+
if (bytes < 1024) return bytes + " B";
|
|
1471
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
1472
|
+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
var allEntries = [];
|
|
1476
|
+
var collapsed = {};
|
|
1477
|
+
|
|
1478
|
+
var content = $('<div class="red-ui-sidebar-info" style="height:100%;overflow:auto;padding:0;"></div>');
|
|
1479
|
+
var fileList = $('<div style="padding:0;"></div>').appendTo(content);
|
|
1480
|
+
|
|
1481
|
+
// ── Toolbar ──
|
|
1482
|
+
var toolbar = $('<div style="display:flex;align-items:center;gap:4px;"></div>');
|
|
1483
|
+
var fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
|
|
1484
|
+
$('<a class="red-ui-sidebar-header-button" href="#"><i class="fa fa-upload"></i> Upload</a>')
|
|
1485
|
+
.on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
|
|
1486
|
+
.appendTo(toolbar);
|
|
1487
|
+
$('<a class="red-ui-sidebar-header-button" href="#"><i class="fa fa-folder-open"></i> New folder</a>')
|
|
1488
|
+
.on("click", function (e) { e.preventDefault(); showNewFolderInput(""); })
|
|
1489
|
+
.appendTo(toolbar);
|
|
1490
|
+
|
|
1491
|
+
// ── Helpers ──
|
|
1492
|
+
function pathExists(p) {
|
|
1493
|
+
for (var i = 0; i < allEntries.length; i++) {
|
|
1494
|
+
if (allEntries[i].name === p) return true;
|
|
1495
|
+
}
|
|
1496
|
+
return false;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function uploadFiles(files, targetDir) {
|
|
1500
|
+
if (!files || files.length === 0) return;
|
|
1501
|
+
var toUpload = [];
|
|
1502
|
+
var duplicates = [];
|
|
1503
|
+
for (var i = 0; i < files.length; i++) {
|
|
1504
|
+
var uploadPath = targetDir ? targetDir + "/" + files[i].name : files[i].name;
|
|
1505
|
+
if (pathExists(uploadPath)) {
|
|
1506
|
+
duplicates.push({ file: files[i], path: uploadPath });
|
|
1507
|
+
} else {
|
|
1508
|
+
toUpload.push({ file: files[i], path: uploadPath });
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
if (duplicates.length > 0) {
|
|
1512
|
+
var names = duplicates.map(function (d) { return d.file.name; }).join(", ");
|
|
1513
|
+
if (confirm("These files already exist: " + names + "\nOverwrite?")) {
|
|
1514
|
+
toUpload = toUpload.concat(duplicates);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (toUpload.length === 0) return;
|
|
1518
|
+
var pending = toUpload.length;
|
|
1519
|
+
toUpload.forEach(function (item) {
|
|
1520
|
+
var reader = new FileReader();
|
|
1521
|
+
reader.onload = function () {
|
|
1522
|
+
$.ajax({
|
|
1523
|
+
type: "POST",
|
|
1524
|
+
url: "portal-react/assets/upload/" + item.path.split("/").map(encodeURIComponent).join("/"),
|
|
1525
|
+
data: new Uint8Array(reader.result),
|
|
1526
|
+
contentType: "application/octet-stream",
|
|
1527
|
+
processData: false,
|
|
1528
|
+
success: function () { if (--pending === 0) refreshList(); },
|
|
1529
|
+
error: function () {
|
|
1530
|
+
RED.notify("Upload failed: " + item.file.name, "error");
|
|
1531
|
+
if (--pending === 0) refreshList();
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
};
|
|
1535
|
+
reader.readAsArrayBuffer(item.file);
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
fileInput.on("change", function () {
|
|
1540
|
+
uploadFiles(this.files, "");
|
|
1541
|
+
fileInput.val("");
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// External file drag & drop on content area (root upload)
|
|
1545
|
+
content.on("dragover", function (e) {
|
|
1546
|
+
if (e.originalEvent.dataTransfer.types.indexOf("Files") >= 0) {
|
|
1547
|
+
e.preventDefault();
|
|
1548
|
+
e.stopPropagation();
|
|
1549
|
+
content.css("background", "rgba(34,211,238,0.05)");
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
content.on("dragleave", function (e) {
|
|
1553
|
+
e.preventDefault();
|
|
1554
|
+
content.css("background", "");
|
|
1555
|
+
});
|
|
1556
|
+
content.on("drop", function (e) {
|
|
1557
|
+
e.preventDefault();
|
|
1558
|
+
e.stopPropagation();
|
|
1559
|
+
content.css("background", "");
|
|
1560
|
+
var dt = e.originalEvent.dataTransfer;
|
|
1561
|
+
if (dt.files && dt.files.length > 0) {
|
|
1562
|
+
uploadFiles(dt.files, "");
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
function moveItem(fromPath, toDir) {
|
|
1567
|
+
var filename = fromPath.split("/").pop();
|
|
1568
|
+
var newPath = toDir ? toDir + "/" + filename : filename;
|
|
1569
|
+
if (fromPath === newPath) return;
|
|
1570
|
+
if (pathExists(newPath)) {
|
|
1571
|
+
if (!confirm("'" + filename + "' already exists in this folder. Overwrite?")) return;
|
|
1572
|
+
}
|
|
1573
|
+
$.ajax({
|
|
1574
|
+
type: "POST", url: "portal-react/assets/move",
|
|
1575
|
+
contentType: "application/json",
|
|
1576
|
+
data: JSON.stringify({ from: fromPath, to: newPath }),
|
|
1577
|
+
success: function () { refreshList(); },
|
|
1578
|
+
error: function (xhr) {
|
|
1579
|
+
var msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
|
|
1580
|
+
RED.notify("Move failed: " + msg, "error");
|
|
1581
|
+
},
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// ── Inline new-folder input ──
|
|
1586
|
+
function showNewFolderInput(parentDir) {
|
|
1587
|
+
var existingInput = fileList.find(".fc-new-folder-row");
|
|
1588
|
+
if (existingInput.length) existingInput.remove();
|
|
1589
|
+
|
|
1590
|
+
var depth = parentDir ? parentDir.split("/").length : 0;
|
|
1591
|
+
var indent = 8 + depth * 18;
|
|
1592
|
+
var row = $('<div class="fc-new-folder-row" style="display:flex;align-items:center;gap:6px;padding:8px;border-bottom:1px solid var(--red-ui-secondary-border-color);background:var(--red-ui-tertiary-background);"></div>');
|
|
1593
|
+
row.css("padding-left", indent + "px");
|
|
1594
|
+
$('<i class="fa fa-folder" style="color:#fbbf24;font-size:13px;width:16px;text-align:center;"></i>').appendTo(row);
|
|
1595
|
+
var inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
|
|
1596
|
+
inp.appendTo(row);
|
|
1597
|
+
var okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Create"><i class="fa fa-check"></i></button>');
|
|
1598
|
+
var cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
|
|
1599
|
+
function submit() {
|
|
1600
|
+
var name = inp.val().trim();
|
|
1601
|
+
if (!name) { row.remove(); return; }
|
|
1602
|
+
var p = parentDir ? parentDir + "/" + name : name;
|
|
1603
|
+
if (pathExists(p)) {
|
|
1604
|
+
RED.notify("Folder '" + name + "' already exists", "warning");
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
$.ajax({ type: "POST", url: "portal-react/assets/mkdir", contentType: "application/json", data: JSON.stringify({ path: p }),
|
|
1608
|
+
success: function () { row.remove(); refreshList(); },
|
|
1609
|
+
error: function () { RED.notify("Failed to create folder", "error"); },
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
okBtn.on("click", function (e) { e.preventDefault(); submit(); });
|
|
1613
|
+
cancelBtn.on("click", function (e) { e.preventDefault(); row.remove(); });
|
|
1614
|
+
inp.on("keydown", function (e) {
|
|
1615
|
+
if (e.key === "Enter") submit();
|
|
1616
|
+
if (e.key === "Escape") row.remove();
|
|
1617
|
+
});
|
|
1618
|
+
okBtn.appendTo(row);
|
|
1619
|
+
cancelBtn.appendTo(row);
|
|
1620
|
+
|
|
1621
|
+
// Insert after the parent folder row, or at top
|
|
1622
|
+
if (parentDir) {
|
|
1623
|
+
var parentRow = fileList.find('[data-path="' + parentDir + '"]');
|
|
1624
|
+
if (parentRow.length) { row.insertAfter(parentRow); } else { fileList.prepend(row); }
|
|
1625
|
+
} else {
|
|
1626
|
+
fileList.prepend(row);
|
|
1627
|
+
}
|
|
1628
|
+
inp.focus();
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// ── Context menu (native Node-RED style) ──
|
|
1632
|
+
var activeMenu = null;
|
|
1633
|
+
function closeMenu() {
|
|
1634
|
+
if (activeMenu) { activeMenu.remove(); activeMenu = null; }
|
|
1635
|
+
}
|
|
1636
|
+
$(document).on("click", closeMenu);
|
|
1637
|
+
|
|
1638
|
+
function showMenu(anchor, items) {
|
|
1639
|
+
closeMenu();
|
|
1640
|
+
var menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
|
|
1641
|
+
items.forEach(function (item) {
|
|
1642
|
+
if (item.divider) {
|
|
1643
|
+
menu.append('<li class="red-ui-menu-divider"></li>');
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
var li = $('<li></li>');
|
|
1647
|
+
var a = $('<a href="#"></a>');
|
|
1648
|
+
if (item.danger) a.css("color", "var(--red-ui-text-color-error)");
|
|
1649
|
+
a.append('<i class="fa ' + item.icon + '" style="width:18px;text-align:center;"></i> ');
|
|
1650
|
+
a.append($('<span class="red-ui-menu-label"></span>').text(item.label));
|
|
1651
|
+
a.on("click", function (e) {
|
|
1652
|
+
e.preventDefault();
|
|
1653
|
+
e.stopPropagation();
|
|
1654
|
+
closeMenu();
|
|
1655
|
+
item.action();
|
|
1656
|
+
});
|
|
1657
|
+
li.append(a);
|
|
1658
|
+
menu.append(li);
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
$("body").append(menu);
|
|
1662
|
+
activeMenu = menu;
|
|
1663
|
+
|
|
1664
|
+
// Position near the anchor
|
|
1665
|
+
var off = anchor.offset();
|
|
1666
|
+
var top = off.top + anchor.outerHeight() + 2;
|
|
1667
|
+
var left = off.left - menu.outerWidth() + anchor.outerWidth();
|
|
1668
|
+
if (left < 0) left = off.left;
|
|
1669
|
+
if (top + menu.outerHeight() > $(window).height()) top = off.top - menu.outerHeight() - 2;
|
|
1670
|
+
menu.css({ top: top, left: left });
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
var adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
|
|
1674
|
+
|
|
1675
|
+
function showRenameInput(rowEl, fullPath, currentName) {
|
|
1676
|
+
var nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
|
|
1677
|
+
if (!nameSpan.length) return;
|
|
1678
|
+
var origText = nameSpan.text();
|
|
1679
|
+
var inp = $('<input type="text" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">').val(origText);
|
|
1680
|
+
var okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Save"><i class="fa fa-check"></i></button>');
|
|
1681
|
+
var cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
|
|
1682
|
+
var dotIdx = origText.lastIndexOf(".");
|
|
1683
|
+
var hasExt = dotIdx > 0; // has extension (not hidden file)
|
|
1684
|
+
var origExt = hasExt ? origText.slice(dotIdx) : "";
|
|
1685
|
+
|
|
1686
|
+
nameSpan.replaceWith(inp);
|
|
1687
|
+
inp.after(cancelBtn).after(okBtn);
|
|
1688
|
+
inp.focus();
|
|
1689
|
+
// Select only the name part before extension
|
|
1690
|
+
var el = inp[0];
|
|
1691
|
+
if (hasExt && el.setSelectionRange) {
|
|
1692
|
+
el.setSelectionRange(0, dotIdx);
|
|
1693
|
+
} else {
|
|
1694
|
+
inp.select();
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function restore() {
|
|
1698
|
+
okBtn.remove();
|
|
1699
|
+
cancelBtn.remove();
|
|
1700
|
+
inp.replaceWith($('<span style="flex:1;font-size:12px;word-break:break-all;"></span>').text(origText));
|
|
1701
|
+
}
|
|
1702
|
+
function submit() {
|
|
1703
|
+
var newName = inp.val().trim();
|
|
1704
|
+
if (!newName) {
|
|
1705
|
+
RED.notify("Name cannot be empty", "warning");
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
// Warn if extension changed or removed
|
|
1709
|
+
if (hasExt) {
|
|
1710
|
+
var newDot = newName.lastIndexOf(".");
|
|
1711
|
+
var newExt = newDot > 0 ? newName.slice(newDot) : "";
|
|
1712
|
+
if (newExt.toLowerCase() !== origExt.toLowerCase()) {
|
|
1713
|
+
if (!confirm("Extension changed from '" + origExt + "' to '" + (newExt || "none") + "'. Continue?")) return;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
if (newName === origText) { restore(); return; }
|
|
1717
|
+
var parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
|
|
1718
|
+
var newPath = parentDir ? parentDir + "/" + newName : newName;
|
|
1719
|
+
if (pathExists(newPath)) {
|
|
1720
|
+
RED.notify("'" + newName + "' already exists", "warning");
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
$.ajax({
|
|
1724
|
+
type: "POST", url: "portal-react/assets/move",
|
|
1725
|
+
contentType: "application/json",
|
|
1726
|
+
data: JSON.stringify({ from: fullPath, to: newPath }),
|
|
1727
|
+
success: function () { refreshList(); },
|
|
1728
|
+
error: function (xhr) {
|
|
1729
|
+
var msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
|
|
1730
|
+
RED.notify("Rename failed: " + msg, "error");
|
|
1731
|
+
restore();
|
|
1732
|
+
},
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
okBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); submit(); });
|
|
1736
|
+
cancelBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); restore(); });
|
|
1737
|
+
inp.on("keydown", function (e) {
|
|
1738
|
+
if (e.key === "Enter") submit();
|
|
1739
|
+
if (e.key === "Escape") restore();
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// ── Tree rendering ──
|
|
1744
|
+
function buildTree(entries) {
|
|
1745
|
+
// Build nested structure: { children: { name: { type, children, entry } } }
|
|
1746
|
+
var root = { children: {} };
|
|
1747
|
+
entries.forEach(function (e) {
|
|
1748
|
+
var parts = e.name.split("/");
|
|
1749
|
+
var node = root;
|
|
1750
|
+
for (var i = 0; i < parts.length; i++) {
|
|
1751
|
+
if (!node.children[parts[i]]) {
|
|
1752
|
+
node.children[parts[i]] = { children: {} };
|
|
1753
|
+
}
|
|
1754
|
+
node = node.children[parts[i]];
|
|
1755
|
+
}
|
|
1756
|
+
node.entry = e;
|
|
1757
|
+
});
|
|
1758
|
+
return root;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function renderTree() {
|
|
1762
|
+
fileList.empty();
|
|
1763
|
+
if (allEntries.length === 0) {
|
|
1764
|
+
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
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
var tree = buildTree(allEntries);
|
|
1768
|
+
renderNode(tree, "", 0);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function renderNode(node, parentPath, depth) {
|
|
1772
|
+
// Collect and sort: dirs first, then files
|
|
1773
|
+
var names = Object.keys(node.children).sort(function (a, b) {
|
|
1774
|
+
var aIsDir = node.children[a].entry && node.children[a].entry.type === "dir";
|
|
1775
|
+
var bIsDir = node.children[b].entry && node.children[b].entry.type === "dir";
|
|
1776
|
+
if (aIsDir && !bIsDir) return -1;
|
|
1777
|
+
if (!aIsDir && bIsDir) return 1;
|
|
1778
|
+
return a.localeCompare(b);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
names.forEach(function (name) {
|
|
1782
|
+
var child = node.children[name];
|
|
1783
|
+
var e = child.entry;
|
|
1784
|
+
if (!e) return;
|
|
1785
|
+
var fullPath = e.name;
|
|
1786
|
+
var indent = 8 + depth * 18;
|
|
1787
|
+
var isDir = e.type === "dir";
|
|
1788
|
+
var isOpen = !collapsed[fullPath];
|
|
1789
|
+
|
|
1790
|
+
var row = $('<div data-path="' + fullPath + '" style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
|
|
1791
|
+
row.css("padding-left", indent + "px");
|
|
1792
|
+
|
|
1793
|
+
if (isDir) {
|
|
1794
|
+
// Expand/collapse arrow
|
|
1795
|
+
var arrow = $('<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>');
|
|
1796
|
+
arrow.on("click", function (e) {
|
|
1797
|
+
e.stopPropagation();
|
|
1798
|
+
collapsed[fullPath] = isOpen;
|
|
1799
|
+
renderTree();
|
|
1800
|
+
});
|
|
1801
|
+
row.append(arrow);
|
|
1802
|
+
$('<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)
|
|
1804
|
+
.on("click", function () { collapsed[fullPath] = isOpen; renderTree(); })
|
|
1805
|
+
.appendTo(row);
|
|
1806
|
+
|
|
1807
|
+
// Context menu trigger
|
|
1808
|
+
var dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
|
|
1809
|
+
(function (fp, nm) {
|
|
1810
|
+
dirMenuBtn.on("click", function (ev) {
|
|
1811
|
+
ev.preventDefault();
|
|
1812
|
+
ev.stopPropagation();
|
|
1813
|
+
showMenu(dirMenuBtn, [
|
|
1814
|
+
{ icon: "fa-pencil", label: "Rename", action: function () {
|
|
1815
|
+
showRenameInput(row, fp, nm);
|
|
1816
|
+
}},
|
|
1817
|
+
{ icon: "fa-plus", label: "New subfolder", action: function () {
|
|
1818
|
+
if (collapsed[fp]) { collapsed[fp] = false; renderTree(); }
|
|
1819
|
+
setTimeout(function () { showNewFolderInput(fp); }, 50);
|
|
1820
|
+
}},
|
|
1821
|
+
{ divider: true },
|
|
1822
|
+
{ icon: "fa-trash", label: "Delete folder", danger: true, action: function () {
|
|
1823
|
+
if (!confirm("Delete folder '" + nm + "' and all contents?")) return;
|
|
1824
|
+
$.ajax({
|
|
1825
|
+
type: "DELETE",
|
|
1826
|
+
url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
|
|
1827
|
+
success: function () { refreshList(); },
|
|
1828
|
+
error: function () { RED.notify("Delete failed", "error"); },
|
|
1829
|
+
});
|
|
1830
|
+
}},
|
|
1831
|
+
]);
|
|
1832
|
+
});
|
|
1833
|
+
})(fullPath, name);
|
|
1834
|
+
dirMenuBtn.appendTo(row);
|
|
1835
|
+
|
|
1836
|
+
// Drop target
|
|
1837
|
+
row.on("dragover", function (ev) {
|
|
1838
|
+
ev.preventDefault();
|
|
1839
|
+
ev.stopPropagation();
|
|
1840
|
+
row.css("background", "rgba(251,191,36,0.08)");
|
|
1841
|
+
});
|
|
1842
|
+
row.on("dragleave", function () { row.css("background", ""); });
|
|
1843
|
+
row.on("drop", function (ev) {
|
|
1844
|
+
ev.preventDefault();
|
|
1845
|
+
ev.stopPropagation();
|
|
1846
|
+
row.css("background", "");
|
|
1847
|
+
var srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
|
|
1848
|
+
if (srcPath) {
|
|
1849
|
+
moveItem(srcPath, fullPath);
|
|
1850
|
+
} else if (ev.originalEvent.dataTransfer.files && ev.originalEvent.dataTransfer.files.length > 0) {
|
|
1851
|
+
uploadFiles(ev.originalEvent.dataTransfer.files, fullPath);
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
fileList.append(row);
|
|
1855
|
+
|
|
1856
|
+
// Render children if open
|
|
1857
|
+
if (isOpen) {
|
|
1858
|
+
renderNode(child, fullPath, depth + 1);
|
|
1859
|
+
}
|
|
1860
|
+
} else {
|
|
1861
|
+
// File
|
|
1862
|
+
row.attr("draggable", "true").css("cursor", "grab");
|
|
1863
|
+
$('<span style="width:10px;"></span>').appendTo(row); // spacer for arrow alignment
|
|
1864
|
+
$('<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;word-break:break-all;"></span>').text(name).appendTo(row);
|
|
1866
|
+
$('<span style="font-size:10px;color:var(--red-ui-tertiary-text-color);white-space:nowrap;"></span>').text(formatSize(e.size)).appendTo(row);
|
|
1867
|
+
|
|
1868
|
+
row.on("dragstart", function (ev) {
|
|
1869
|
+
ev.originalEvent.dataTransfer.setData("text/x-asset-path", fullPath);
|
|
1870
|
+
ev.originalEvent.dataTransfer.effectAllowed = "move";
|
|
1871
|
+
row.css("opacity", "0.4");
|
|
1872
|
+
});
|
|
1873
|
+
row.on("dragend", function () { row.css("opacity", "1"); });
|
|
1874
|
+
|
|
1875
|
+
var copyBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;" title="Copy public path"><i class="fa fa-clipboard"></i></button>');
|
|
1876
|
+
var fileMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
|
|
1877
|
+
(function (fp, nm) {
|
|
1878
|
+
copyBtn.on("click", function (ev) {
|
|
1879
|
+
ev.preventDefault();
|
|
1880
|
+
ev.stopPropagation();
|
|
1881
|
+
var url = publicBase + fp;
|
|
1882
|
+
navigator.clipboard.writeText(url).then(function () {
|
|
1883
|
+
RED.notify("Copied: " + url, { type: "success", timeout: 2000 });
|
|
1884
|
+
});
|
|
1885
|
+
});
|
|
1886
|
+
fileMenuBtn.on("click", function (ev) {
|
|
1887
|
+
ev.preventDefault();
|
|
1888
|
+
ev.stopPropagation();
|
|
1889
|
+
showMenu(fileMenuBtn, [
|
|
1890
|
+
{ icon: "fa-pencil", label: "Rename", action: function () {
|
|
1891
|
+
showRenameInput(row, fp, nm);
|
|
1892
|
+
}},
|
|
1893
|
+
{ icon: "fa-download", label: "Download", action: function () {
|
|
1894
|
+
var a = document.createElement("a");
|
|
1895
|
+
a.href = adminRoot + "/portal-react/assets/download/" + fp.split("/").map(encodeURIComponent).join("/");
|
|
1896
|
+
a.download = nm;
|
|
1897
|
+
document.body.appendChild(a);
|
|
1898
|
+
a.click();
|
|
1899
|
+
document.body.removeChild(a);
|
|
1900
|
+
}},
|
|
1901
|
+
{ divider: true },
|
|
1902
|
+
{ icon: "fa-trash", label: "Delete", danger: true, action: function () {
|
|
1903
|
+
$.ajax({
|
|
1904
|
+
type: "DELETE",
|
|
1905
|
+
url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
|
|
1906
|
+
success: function () { refreshList(); },
|
|
1907
|
+
error: function () { RED.notify("Delete failed: " + nm, "error"); },
|
|
1908
|
+
});
|
|
1909
|
+
}},
|
|
1910
|
+
]);
|
|
1911
|
+
});
|
|
1912
|
+
})(fullPath, name);
|
|
1913
|
+
copyBtn.appendTo(row);
|
|
1914
|
+
fileMenuBtn.appendTo(row);
|
|
1915
|
+
fileList.append(row);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function refreshList() {
|
|
1921
|
+
$.getJSON("portal-react/assets", function (entries) {
|
|
1922
|
+
allEntries = entries || [];
|
|
1923
|
+
renderTree();
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
RED.sidebar.addTab({
|
|
1928
|
+
id: "fromcubes-public",
|
|
1929
|
+
label: "Portal Assets",
|
|
1930
|
+
name: "fromcubes portal assets",
|
|
1931
|
+
iconClass: "fa fa-desktop",
|
|
1932
|
+
content: content[0],
|
|
1933
|
+
toolbar: toolbar[0],
|
|
1934
|
+
pinned: false,
|
|
1935
|
+
enableOnEdit: true,
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
RED.actions.add("fromcubes:show-assets", function () {
|
|
1939
|
+
RED.sidebar.show("fromcubes-public");
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
RED.events.on("sidebar:resize", function () { refreshList(); });
|
|
1943
|
+
setTimeout(refreshList, 1000);
|
|
1944
|
+
})();
|
|
1945
|
+
</script>
|