@peers-app/peers-ui 0.19.8 → 0.19.10
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/dist/components/list-screen.d.ts +5 -1
- package/dist/components/list-screen.js +3 -1
- package/dist/screens/packages/package-details.js +2 -1
- package/dist/screens/packages/package-info.js +1 -1
- package/dist/screens/settings/settings-page.js +5 -3
- package/dist/screens/variables/variable-details.js +46 -3
- package/dist/screens/variables/variable-list.js +26 -4
- package/package.json +3 -3
- package/src/components/list-screen.tsx +7 -2
- package/src/screens/packages/package-details.tsx +2 -1
- package/src/screens/packages/package-info.tsx +17 -0
- package/src/screens/settings/settings-page.tsx +5 -3
- package/src/screens/variables/variable-details.tsx +93 -4
- package/src/screens/variables/variable-list.tsx +52 -15
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type DataFilter, type SortBy, type Table } from "@peers-app/peers-sdk";
|
|
2
2
|
interface IProps<T extends Record<string, unknown>> {
|
|
3
3
|
table: Table<T>;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Creates a new record from the search text when the user presses Enter. When omitted,
|
|
6
|
+
* the list is browse/search only (no create) and the placeholder drops "or create".
|
|
7
|
+
*/
|
|
8
|
+
newRecord?: (text: string) => Promise<T>;
|
|
5
9
|
getFilter?: (text: string) => DataFilter<T>;
|
|
6
10
|
sortBy?: SortBy<T>;
|
|
7
11
|
renderItem: (record: T) => JSX.Element;
|
|
@@ -93,13 +93,15 @@ function ListScreen(props) {
|
|
|
93
93
|
async function searchSubmit(evt) {
|
|
94
94
|
if (evt.key !== "Enter")
|
|
95
95
|
return;
|
|
96
|
+
if (!newRecord)
|
|
97
|
+
return;
|
|
96
98
|
const name = searchText.trim();
|
|
97
99
|
if (!name)
|
|
98
100
|
return;
|
|
99
101
|
searchTextObs("");
|
|
100
102
|
await newRecord(name);
|
|
101
103
|
}
|
|
102
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid", children: [(0, jsx_runtime_1.jsx)("div", { className: "input-group mt-3 mb-3", children: (0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", placeholder:
|
|
104
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid", children: [(0, jsx_runtime_1.jsx)("div", { className: "input-group mt-3 mb-3", children: (0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", placeholder: `${newRecord ? "Search or create" : "Search"} ${props.placeholderName || table.metaData.name}`, autoFocus: !!(0, globals_1.isDesktop)(), onKeyUp: (evt) => searchSubmit(evt) }) }), (0, jsx_runtime_1.jsx)("div", { className: "peers-list-container", children: (0, jsx_runtime_1.jsx)(lazy_list_1.LazyList, { resetTrigger: searchText, loadMore: loadMore, scrollThreshold: 0.6, renderItems: (notes) => {
|
|
103
105
|
return notes.map(props.renderItem);
|
|
104
106
|
}, loadingIndicator: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 }, children: (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}) }), endOfList: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 } }) }) })] }));
|
|
105
107
|
}
|
|
@@ -56,7 +56,8 @@ const PackageDetails = (props) => {
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex", children: [(0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-fill me-2" }) }) }), (0, jsx_runtime_1.jsx)("div", { className: "flex-grow-1", children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)(input_1.Input, { className: "border border-0", style: { width: "100%", outline: "none", backgroundColor: "transparent" }, value: pkg.qs.name }, pkg.packageId) }) }), (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(save_button_1.SaveButton, { doc: pkg, onClick: async () => {
|
|
59
|
-
await pkg.
|
|
59
|
+
await (0, peers_sdk_1.Packages)().signAndSave(pkg.toJS());
|
|
60
|
+
pkg.q(0);
|
|
60
61
|
}, addActions: [...addActions] }, pkg.packageId) })] }), (0, jsx_runtime_1.jsx)(tabs_1.Tabs, { tabs: [
|
|
61
62
|
{
|
|
62
63
|
name: "Info",
|
|
@@ -72,7 +72,7 @@ const PackageInfo = (props) => {
|
|
|
72
72
|
peers_sdk_1.rpcServerCalls.openPackage(localPath || peers_sdk_1.packagesRootDir);
|
|
73
73
|
}, children: "Open Local" })] })] }), (0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control mb-3 p-0 ps-2", placeholder: "~/peers/packages/my-package", value: localPath || "", onChange: (e) => setLocalPath(e.target.value) })] }), remoteRepoUrl ? ((0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Source URL:", (0, jsx_runtime_1.jsxs)("small", { children: [(0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `The remote source of this package. Often a github repository URL or a link to the Peers page for the package.` }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-link", onClick: () => {
|
|
74
74
|
peers_sdk_1.rpcServerCalls.openLinkInBrowser(remoteRepoUrl);
|
|
75
|
-
}, children: "Open Remote" })] })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.remoteRepo, className: "form-control mb-3 p-0 ps-2", disabled: true })] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Version:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "The currently active version of this package. Manage versions in the Versions tab." })] }), activeVersion ? ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mt-1 mb-3", children: [(0, jsx_runtime_1.jsxs)("strong", { className: "me-2", children: ["v", activeVersion.version] }), activeVersion.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${activeVersion.versionTag === "dev" ? "text-bg-danger" : activeVersion.versionTag.startsWith("beta") ? "text-bg-warning" : "text-bg-success"} me-2`, children: activeVersion.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: activeVersion.packageVersionHash?.substring(0, 8) }), newerLevel === "uptodate" ? ((0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-success", children: "Up to date" })) : newerLevel ? ((0, jsx_runtime_1.jsxs)("span", { className: `badge text-bg-${newerLevel === "major" ? "danger" : newerLevel === "minor" ? "warning" : "info"}`, children: ["Newer ", newerLevel, " version available"] })) : null] })) : ((0, jsx_runtime_1.jsx)("div", { className: "text-muted small mt-1 mb-3", children: "No active version" })), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Auto-Update Range:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Controls which new versions are auto-activated for the group. When an admin device auto-upgrades, the group's active version advances for everyone. **Pinned** = never auto-update. **Patch** = same major.minor (e.g. 1.2.x). **Minor** = same major (e.g. 1.x.x). **Latest** = always auto-update to newest." })] }), (0, jsx_runtime_1.jsxs)("select", { className: "form-select form-select-sm", value: groupRange, onChange: async (e) => {
|
|
75
|
+
}, children: "Open Remote" })] })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.remoteRepo, className: "form-control mb-3 p-0 ps-2", disabled: true })] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Update URL:", (0, jsx_runtime_1.jsx)("small", { children: (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `Base URL for remote package updates. Admin devices poll **<updateUrl>/latest-<tag>.json** on startup (for example **latest-stable.json**). Signed artifacts must be published to this host separately — for example via the publish-package tool or the peers-core full-release pipeline.` }) })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: pkg.qs.updateUrl, className: "form-control mb-3 p-0 ps-2", placeholder: "https://example.com/my-package", title: "Update URL" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("small", { children: ["Version:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "The currently active version of this package. Manage versions in the Versions tab." })] }), activeVersion ? ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mt-1 mb-3", children: [(0, jsx_runtime_1.jsxs)("strong", { className: "me-2", children: ["v", activeVersion.version] }), activeVersion.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ${activeVersion.versionTag === "dev" ? "text-bg-danger" : activeVersion.versionTag.startsWith("beta") ? "text-bg-warning" : "text-bg-success"} me-2`, children: activeVersion.versionTag })), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small me-2", children: activeVersion.packageVersionHash?.substring(0, 8) }), newerLevel === "uptodate" ? ((0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-success", children: "Up to date" })) : newerLevel ? ((0, jsx_runtime_1.jsxs)("span", { className: `badge text-bg-${newerLevel === "major" ? "danger" : newerLevel === "minor" ? "warning" : "info"}`, children: ["Newer ", newerLevel, " version available"] })) : null] })) : ((0, jsx_runtime_1.jsx)("div", { className: "text-muted small mt-1 mb-3", children: "No active version" })), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Auto-Update Range:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Controls which new versions are auto-activated for the group. When an admin device auto-upgrades, the group's active version advances for everyone. **Pinned** = never auto-update. **Patch** = same major.minor (e.g. 1.2.x). **Minor** = same major (e.g. 1.x.x). **Latest** = always auto-update to newest." })] }), (0, jsx_runtime_1.jsxs)("select", { className: "form-select form-select-sm", value: groupRange, onChange: async (e) => {
|
|
76
76
|
const val = e.target.value;
|
|
77
77
|
const current = await (0, peers_sdk_1.Packages)().get(pkg.packageId);
|
|
78
78
|
if (current) {
|
|
@@ -105,10 +105,12 @@ const ProfileSection = () => {
|
|
|
105
105
|
}, [deviceId, deviceName, savedDeviceName]);
|
|
106
106
|
const handleSave = async () => {
|
|
107
107
|
try {
|
|
108
|
-
// Save user profile
|
|
109
|
-
|
|
108
|
+
// Save user profile (only when it actually changed). Pass me.toJS() — the
|
|
109
|
+
// raw doc's fields are non-enumerable getters that get dropped when the
|
|
110
|
+
// signAndSave call is serialized to JSON over the socket to the server.
|
|
111
|
+
if (me?.q()) {
|
|
110
112
|
const userContext = await (0, peers_sdk_1.getUserContext)();
|
|
111
|
-
await (0, peers_sdk_1.Users)(userContext.userDataContext).signAndSave(me);
|
|
113
|
+
await (0, peers_sdk_1.Users)(userContext.userDataContext).signAndSave(me.toJS());
|
|
112
114
|
me.q(0); // Reset change counter
|
|
113
115
|
}
|
|
114
116
|
// Save device name if changed
|
|
@@ -59,15 +59,58 @@ const VariableValue = (props) => {
|
|
|
59
59
|
const { pvar } = props;
|
|
60
60
|
const [isSecret] = (0, hooks_1.useObservable)(pvar.qs.isSecret);
|
|
61
61
|
const scalarValue = (0, peers_sdk_1.observable)(pvar.value.value);
|
|
62
|
-
// WARNING:
|
|
62
|
+
// WARNING: the editable string path assumes the value is a string
|
|
63
63
|
(0, hooks_1.useSubscription)(pvar.qs.value, (value) => {
|
|
64
64
|
scalarValue(value?.value);
|
|
65
65
|
});
|
|
66
66
|
(0, hooks_1.useSubscription)(scalarValue, (value) => {
|
|
67
67
|
pvar.value = { value };
|
|
68
68
|
});
|
|
69
|
+
const valueLabel = ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-between", children: [(0, jsx_runtime_1.jsx)("small", { children: "Value:" }), (0, jsx_runtime_1.jsx)(CopyValueButton, { pvar: pvar })] }));
|
|
69
70
|
if (isSecret) {
|
|
70
|
-
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [
|
|
71
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [valueLabel, (0, jsx_runtime_1.jsx)(input_1.Input, { value: scalarValue, type: "password", className: "form-control mb-3 p-0 ps-2", placeholder: "Variable value", title: "Variable value" })] }));
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
// The markdown editor only handles strings. System/package pvars may hold booleans,
|
|
74
|
+
// numbers, or objects, so render those read-only (still copyable via the button).
|
|
75
|
+
// Read directly (no subscription) to avoid a render/write-back loop with scalarValue.
|
|
76
|
+
const scalar = pvar.value?.value;
|
|
77
|
+
if (typeof scalar !== "string") {
|
|
78
|
+
const isObject = scalar !== null && typeof scalar === "object";
|
|
79
|
+
const display = scalar === undefined || scalar === null
|
|
80
|
+
? ""
|
|
81
|
+
: isObject
|
|
82
|
+
? JSON.stringify(scalar, null, 2)
|
|
83
|
+
: String(scalar);
|
|
84
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [valueLabel, isObject ? ((0, jsx_runtime_1.jsx)("pre", { className: "form-control mb-1 p-2", style: { whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 300 }, children: display })) : ((0, jsx_runtime_1.jsx)("input", { className: "form-control mb-1 p-0 ps-2", value: display, readOnly: true, title: "Variable value" })), (0, jsx_runtime_1.jsxs)("div", { className: "form-text text-muted mb-3", children: ["This ", typeof scalar, " value is read-only here \u2014 use Copy Value to copy it."] })] }));
|
|
85
|
+
}
|
|
86
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [valueLabel, (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: scalarValue, hideToolbar: true }), (0, jsx_runtime_1.jsx)("br", {})] }));
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Button that copies a persistent variable's value to the clipboard. Decryption (for
|
|
90
|
+
* secret values) and the clipboard write happen entirely on the server/main process via
|
|
91
|
+
* {@link rpcServerCalls.copyPvarValueToClipboard}, so the plaintext is never exposed to
|
|
92
|
+
* the UI. Shows transient success/error feedback on the icon.
|
|
93
|
+
*/
|
|
94
|
+
const CopyValueButton = (props) => {
|
|
95
|
+
const { pvar } = props;
|
|
96
|
+
const [status, setStatus] = (0, react_1.useState)("idle");
|
|
97
|
+
async function onCopy() {
|
|
98
|
+
try {
|
|
99
|
+
const userContext = await (0, peers_sdk_1.getUserContext)();
|
|
100
|
+
const groupId = userContext.currentlyActiveGroupId();
|
|
101
|
+
const result = await peers_sdk_1.rpcServerCalls.copyPvarValueToClipboard(pvar.persistentVarId, groupId);
|
|
102
|
+
setStatus(result.success ? "copied" : "error");
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.error("Failed to copy variable value", err);
|
|
106
|
+
setStatus("error");
|
|
107
|
+
}
|
|
108
|
+
setTimeout(() => setStatus("idle"), 1500);
|
|
109
|
+
}
|
|
110
|
+
const iconClass = status === "copied"
|
|
111
|
+
? "bi-check-lg text-success"
|
|
112
|
+
: status === "error"
|
|
113
|
+
? "bi-exclamation-triangle text-danger"
|
|
114
|
+
: "bi-clipboard";
|
|
115
|
+
return ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-secondary", onClick: onCopy, title: "Copy value to clipboard", children: [(0, jsx_runtime_1.jsx)("i", { className: iconClass }), (0, jsx_runtime_1.jsx)("span", { className: "ms-1", children: "Copy Value" })] }));
|
|
73
116
|
};
|
|
@@ -5,9 +5,14 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
5
5
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
6
6
|
const list_screen_1 = require("../../components/list-screen");
|
|
7
7
|
const markdown_with_mentions_1 = require("../../components/markdown-with-mentions");
|
|
8
|
+
const tabs_1 = require("../../components/tabs");
|
|
8
9
|
const globals_1 = require("../../globals");
|
|
9
10
|
const ui_loader_1 = require("../../ui-router/ui-loader");
|
|
10
|
-
function
|
|
11
|
+
function renderItem(persistentVar) {
|
|
12
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid pb-4", children: [(0, jsx_runtime_1.jsx)("i", { className: (0, peers_sdk_1.getIconClassName)((0, peers_sdk_1.PersistentVars)()) }), "\u00A0", (0, jsx_runtime_1.jsx)("a", { href: `#variables/${persistentVar.persistentVarId}`, children: persistentVar.name || "<empty-name>" }), (0, jsx_runtime_1.jsx)("div", { style: { paddingLeft: "20px" }, children: (0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: persistentVar.description || "" }) })] }, persistentVar.persistentVarId));
|
|
13
|
+
}
|
|
14
|
+
/** List of user-created variables, with search-to-create. */
|
|
15
|
+
function UserVariableList() {
|
|
11
16
|
async function newRecord(name) {
|
|
12
17
|
const userContext = await (0, peers_sdk_1.getUserContext)();
|
|
13
18
|
const isPersonal = userContext.currentlyActiveGroupId() === userContext.userId;
|
|
@@ -29,11 +34,28 @@ function VariableList() {
|
|
|
29
34
|
userCreated: true,
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
|
-
function renderItem(persistentVar) {
|
|
33
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid pb-4", children: [(0, jsx_runtime_1.jsx)("i", { className: (0, peers_sdk_1.getIconClassName)((0, peers_sdk_1.PersistentVars)()) }), "\u00A0", (0, jsx_runtime_1.jsx)("a", { href: `#variables/${persistentVar.persistentVarId}`, children: persistentVar.name || "<empty-name>" }), (0, jsx_runtime_1.jsx)("div", { style: { paddingLeft: "20px" }, children: (0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: persistentVar.description || "" }) })] }, persistentVar.persistentVarId));
|
|
34
|
-
}
|
|
35
37
|
return ((0, jsx_runtime_1.jsx)(list_screen_1.ListScreen, { table: (0, peers_sdk_1.PersistentVars)(), newRecord: newRecord, getFilter: getFilter, sortBy: ["name"], renderItem: renderItem, placeholderName: "environment variable" }));
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Browse/search list of non-user-created variables (system and package-owned pvars).
|
|
41
|
+
* These are read-only here (no create); selecting one opens the same detail screen.
|
|
42
|
+
*/
|
|
43
|
+
function SystemVariableList() {
|
|
44
|
+
function getFilter(searchText) {
|
|
45
|
+
return {
|
|
46
|
+
$or: [{ name: { $matchWords: searchText } }, { description: { $matchWords: searchText } }],
|
|
47
|
+
// $ne: true matches both `false` and unset (system/package pvars omit userCreated)
|
|
48
|
+
userCreated: { $ne: true },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return ((0, jsx_runtime_1.jsx)(list_screen_1.ListScreen, { table: (0, peers_sdk_1.PersistentVars)(), getFilter: getFilter, sortBy: ["name"], renderItem: renderItem, placeholderName: "system variable" }));
|
|
52
|
+
}
|
|
53
|
+
function VariableList() {
|
|
54
|
+
return ((0, jsx_runtime_1.jsx)(tabs_1.Tabs, { tabs: [
|
|
55
|
+
{ name: "User", content: (0, jsx_runtime_1.jsx)(UserVariableList, {}) },
|
|
56
|
+
{ name: "System", content: (0, jsx_runtime_1.jsx)(SystemVariableList, {}) },
|
|
57
|
+
] }));
|
|
58
|
+
}
|
|
37
59
|
(0, ui_loader_1.registerInternalPeersUI)({
|
|
38
60
|
peersUIId: "00m5fshz2g6ea23v8z6y0a1ci",
|
|
39
61
|
component: VariableList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.10",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"lint:fix": "biome check --write ."
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@peers-app/peers-sdk": "^0.19.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.19.10",
|
|
32
32
|
"bootstrap": "^5.3.3",
|
|
33
33
|
"react": "^18.0.0",
|
|
34
34
|
"react-dom": "^18.0.0"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@babel/preset-env": "^7.24.5",
|
|
40
40
|
"@babel/preset-react": "^7.24.1",
|
|
41
41
|
"@babel/preset-typescript": "^7.27.1",
|
|
42
|
-
"@peers-app/peers-sdk": "0.19.
|
|
42
|
+
"@peers-app/peers-sdk": "0.19.10",
|
|
43
43
|
"@testing-library/dom": "^10.4.0",
|
|
44
44
|
"@testing-library/jest-dom": "^6.6.3",
|
|
45
45
|
"@testing-library/react": "^16.3.0",
|
|
@@ -16,7 +16,11 @@ import { LoadingIndicator } from "./loading-indicator";
|
|
|
16
16
|
|
|
17
17
|
interface IProps<T extends Record<string, unknown>> {
|
|
18
18
|
table: Table<T>;
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Creates a new record from the search text when the user presses Enter. When omitted,
|
|
21
|
+
* the list is browse/search only (no create) and the placeholder drops "or create".
|
|
22
|
+
*/
|
|
23
|
+
newRecord?: (text: string) => Promise<T>;
|
|
20
24
|
getFilter?: (text: string) => DataFilter<T>;
|
|
21
25
|
sortBy?: SortBy<T>;
|
|
22
26
|
renderItem: (record: T) => JSX.Element;
|
|
@@ -78,6 +82,7 @@ export function ListScreen<T extends Record<string, unknown>>(props: IProps<T>)
|
|
|
78
82
|
|
|
79
83
|
async function searchSubmit(evt: React.KeyboardEvent<HTMLInputElement>) {
|
|
80
84
|
if (evt.key !== "Enter") return;
|
|
85
|
+
if (!newRecord) return;
|
|
81
86
|
const name = searchText.trim();
|
|
82
87
|
if (!name) return;
|
|
83
88
|
searchTextObs("");
|
|
@@ -90,7 +95,7 @@ export function ListScreen<T extends Record<string, unknown>>(props: IProps<T>)
|
|
|
90
95
|
<Input
|
|
91
96
|
value={searchTextObs}
|
|
92
97
|
className="form-control"
|
|
93
|
-
placeholder={
|
|
98
|
+
placeholder={`${newRecord ? "Search or create" : "Search"} ${props.placeholderName || table.metaData.name}`}
|
|
94
99
|
autoFocus={!!isDesktop()}
|
|
95
100
|
onKeyUp={(evt) => searchSubmit(evt)}
|
|
96
101
|
/>
|
|
@@ -152,6 +152,23 @@ export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
|
|
|
152
152
|
</div>
|
|
153
153
|
) : null}
|
|
154
154
|
|
|
155
|
+
<div className="mt-2">
|
|
156
|
+
<small>
|
|
157
|
+
Update URL:
|
|
158
|
+
<small>
|
|
159
|
+
<Tooltip
|
|
160
|
+
markdownContent={`Base URL for remote package updates. Admin devices poll **<updateUrl>/latest-<tag>.json** on startup (for example **latest-stable.json**). Signed artifacts must be published to this host separately — for example via the publish-package tool or the peers-core full-release pipeline.`}
|
|
161
|
+
/>
|
|
162
|
+
</small>
|
|
163
|
+
</small>
|
|
164
|
+
<Input
|
|
165
|
+
value={pkg.qs.updateUrl}
|
|
166
|
+
className="form-control mb-3 p-0 ps-2"
|
|
167
|
+
placeholder="https://example.com/my-package"
|
|
168
|
+
title="Update URL"
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
155
172
|
<div className="mt-2">
|
|
156
173
|
<hr />
|
|
157
174
|
<small>
|
|
@@ -126,10 +126,12 @@ const ProfileSection: React.FC = () => {
|
|
|
126
126
|
|
|
127
127
|
const handleSave = async () => {
|
|
128
128
|
try {
|
|
129
|
-
// Save user profile
|
|
130
|
-
|
|
129
|
+
// Save user profile (only when it actually changed). Pass me.toJS() — the
|
|
130
|
+
// raw doc's fields are non-enumerable getters that get dropped when the
|
|
131
|
+
// signAndSave call is serialized to JSON over the socket to the server.
|
|
132
|
+
if (me?.q()) {
|
|
131
133
|
const userContext = await getUserContext();
|
|
132
|
-
await Users(userContext.userDataContext).signAndSave(me);
|
|
134
|
+
await Users(userContext.userDataContext).signAndSave(me.toJS());
|
|
133
135
|
me.q(0); // Reset change counter
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
camelCaseToSpaces,
|
|
3
3
|
getIconClassName,
|
|
4
|
+
getUserContext,
|
|
4
5
|
type IDoc,
|
|
5
6
|
type IPersistentVar,
|
|
6
7
|
type Observable,
|
|
7
8
|
observable,
|
|
8
9
|
PersistentVars,
|
|
10
|
+
rpcServerCalls,
|
|
9
11
|
} from "@peers-app/peers-sdk";
|
|
10
|
-
import { useEffect } from "react";
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
11
13
|
import { Checkbox } from "../../components/checkbox";
|
|
12
14
|
import { Input } from "../../components/input";
|
|
13
15
|
import { LoadingIndicator } from "../../components/loading-indicator";
|
|
@@ -162,7 +164,7 @@ const VariableValue = (props: { pvar: IDoc<IPersistentVar> }) => {
|
|
|
162
164
|
|
|
163
165
|
const [isSecret] = useObservable(pvar.qs.isSecret);
|
|
164
166
|
const scalarValue = observable(pvar.value.value);
|
|
165
|
-
// WARNING:
|
|
167
|
+
// WARNING: the editable string path assumes the value is a string
|
|
166
168
|
useSubscription(pvar.qs.value, (value) => {
|
|
167
169
|
scalarValue(value?.value);
|
|
168
170
|
});
|
|
@@ -170,10 +172,17 @@ const VariableValue = (props: { pvar: IDoc<IPersistentVar> }) => {
|
|
|
170
172
|
pvar.value = { value };
|
|
171
173
|
});
|
|
172
174
|
|
|
175
|
+
const valueLabel = (
|
|
176
|
+
<div className="d-flex align-items-center justify-content-between">
|
|
177
|
+
<small>Value:</small>
|
|
178
|
+
<CopyValueButton pvar={pvar} />
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
|
|
173
182
|
if (isSecret) {
|
|
174
183
|
return (
|
|
175
184
|
<>
|
|
176
|
-
|
|
185
|
+
{valueLabel}
|
|
177
186
|
<Input
|
|
178
187
|
value={scalarValue}
|
|
179
188
|
type="password"
|
|
@@ -185,11 +194,91 @@ const VariableValue = (props: { pvar: IDoc<IPersistentVar> }) => {
|
|
|
185
194
|
);
|
|
186
195
|
}
|
|
187
196
|
|
|
197
|
+
// The markdown editor only handles strings. System/package pvars may hold booleans,
|
|
198
|
+
// numbers, or objects, so render those read-only (still copyable via the button).
|
|
199
|
+
// Read directly (no subscription) to avoid a render/write-back loop with scalarValue.
|
|
200
|
+
const scalar = pvar.value?.value;
|
|
201
|
+
if (typeof scalar !== "string") {
|
|
202
|
+
const isObject = scalar !== null && typeof scalar === "object";
|
|
203
|
+
const display =
|
|
204
|
+
scalar === undefined || scalar === null
|
|
205
|
+
? ""
|
|
206
|
+
: isObject
|
|
207
|
+
? JSON.stringify(scalar, null, 2)
|
|
208
|
+
: String(scalar);
|
|
209
|
+
return (
|
|
210
|
+
<>
|
|
211
|
+
{valueLabel}
|
|
212
|
+
{isObject ? (
|
|
213
|
+
<pre
|
|
214
|
+
className="form-control mb-1 p-2"
|
|
215
|
+
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 300 }}
|
|
216
|
+
>
|
|
217
|
+
{display}
|
|
218
|
+
</pre>
|
|
219
|
+
) : (
|
|
220
|
+
<input
|
|
221
|
+
className="form-control mb-1 p-0 ps-2"
|
|
222
|
+
value={display}
|
|
223
|
+
readOnly
|
|
224
|
+
title="Variable value"
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
<div className="form-text text-muted mb-3">
|
|
228
|
+
This {typeof scalar} value is read-only here — use Copy Value to copy it.
|
|
229
|
+
</div>
|
|
230
|
+
</>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
188
234
|
return (
|
|
189
235
|
<>
|
|
190
|
-
|
|
236
|
+
{valueLabel}
|
|
191
237
|
<MarkdownEditorInline value={scalarValue} hideToolbar />
|
|
192
238
|
<br />
|
|
193
239
|
</>
|
|
194
240
|
);
|
|
195
241
|
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Button that copies a persistent variable's value to the clipboard. Decryption (for
|
|
245
|
+
* secret values) and the clipboard write happen entirely on the server/main process via
|
|
246
|
+
* {@link rpcServerCalls.copyPvarValueToClipboard}, so the plaintext is never exposed to
|
|
247
|
+
* the UI. Shows transient success/error feedback on the icon.
|
|
248
|
+
*/
|
|
249
|
+
const CopyValueButton = (props: { pvar: IDoc<IPersistentVar> }) => {
|
|
250
|
+
const { pvar } = props;
|
|
251
|
+
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
|
|
252
|
+
|
|
253
|
+
async function onCopy() {
|
|
254
|
+
try {
|
|
255
|
+
const userContext = await getUserContext();
|
|
256
|
+
const groupId = userContext.currentlyActiveGroupId();
|
|
257
|
+
const result = await rpcServerCalls.copyPvarValueToClipboard(pvar.persistentVarId, groupId);
|
|
258
|
+
setStatus(result.success ? "copied" : "error");
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error("Failed to copy variable value", err);
|
|
261
|
+
setStatus("error");
|
|
262
|
+
}
|
|
263
|
+
setTimeout(() => setStatus("idle"), 1500);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const iconClass =
|
|
267
|
+
status === "copied"
|
|
268
|
+
? "bi-check-lg text-success"
|
|
269
|
+
: status === "error"
|
|
270
|
+
? "bi-exclamation-triangle text-danger"
|
|
271
|
+
: "bi-clipboard";
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
className="btn btn-sm btn-outline-secondary"
|
|
277
|
+
onClick={onCopy}
|
|
278
|
+
title="Copy value to clipboard"
|
|
279
|
+
>
|
|
280
|
+
<i className={iconClass}></i>
|
|
281
|
+
<span className="ms-1">Copy Value</span>
|
|
282
|
+
</button>
|
|
283
|
+
);
|
|
284
|
+
};
|
|
@@ -7,10 +7,26 @@ import {
|
|
|
7
7
|
} from "@peers-app/peers-sdk";
|
|
8
8
|
import { ListScreen } from "../../components/list-screen";
|
|
9
9
|
import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
|
|
10
|
+
import { Tabs } from "../../components/tabs";
|
|
10
11
|
import { mainContentPath } from "../../globals";
|
|
11
12
|
import { registerInternalPeersUI } from "../../ui-router/ui-loader";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
function renderItem(persistentVar: IPersistentVar) {
|
|
15
|
+
return (
|
|
16
|
+
<div key={persistentVar.persistentVarId} className="container-fluid pb-4">
|
|
17
|
+
<i className={getIconClassName(PersistentVars())}></i>
|
|
18
|
+
<a href={`#variables/${persistentVar.persistentVarId}`}>
|
|
19
|
+
{persistentVar.name || "<empty-name>"}
|
|
20
|
+
</a>
|
|
21
|
+
<div style={{ paddingLeft: "20px" }}>
|
|
22
|
+
<MarkdownWithMentions content={persistentVar.description || ""} />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** List of user-created variables, with search-to-create. */
|
|
29
|
+
function UserVariableList() {
|
|
14
30
|
async function newRecord(name: string) {
|
|
15
31
|
const userContext = await getUserContext();
|
|
16
32
|
const isPersonal = userContext.currentlyActiveGroupId() === userContext.userId;
|
|
@@ -34,20 +50,6 @@ export function VariableList() {
|
|
|
34
50
|
};
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
function renderItem(persistentVar: IPersistentVar) {
|
|
38
|
-
return (
|
|
39
|
-
<div key={persistentVar.persistentVarId} className="container-fluid pb-4">
|
|
40
|
-
<i className={getIconClassName(PersistentVars())}></i>
|
|
41
|
-
<a href={`#variables/${persistentVar.persistentVarId}`}>
|
|
42
|
-
{persistentVar.name || "<empty-name>"}
|
|
43
|
-
</a>
|
|
44
|
-
<div style={{ paddingLeft: "20px" }}>
|
|
45
|
-
<MarkdownWithMentions content={persistentVar.description || ""} />
|
|
46
|
-
</div>
|
|
47
|
-
</div>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
53
|
return (
|
|
52
54
|
<ListScreen
|
|
53
55
|
table={PersistentVars()}
|
|
@@ -60,6 +62,41 @@ export function VariableList() {
|
|
|
60
62
|
);
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Browse/search list of non-user-created variables (system and package-owned pvars).
|
|
67
|
+
* These are read-only here (no create); selecting one opens the same detail screen.
|
|
68
|
+
*/
|
|
69
|
+
function SystemVariableList() {
|
|
70
|
+
function getFilter(searchText: string) {
|
|
71
|
+
return {
|
|
72
|
+
$or: [{ name: { $matchWords: searchText } }, { description: { $matchWords: searchText } }],
|
|
73
|
+
// $ne: true matches both `false` and unset (system/package pvars omit userCreated)
|
|
74
|
+
userCreated: { $ne: true },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<ListScreen
|
|
80
|
+
table={PersistentVars()}
|
|
81
|
+
getFilter={getFilter}
|
|
82
|
+
sortBy={["name"]}
|
|
83
|
+
renderItem={renderItem}
|
|
84
|
+
placeholderName="system variable"
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function VariableList() {
|
|
90
|
+
return (
|
|
91
|
+
<Tabs
|
|
92
|
+
tabs={[
|
|
93
|
+
{ name: "User", content: <UserVariableList /> },
|
|
94
|
+
{ name: "System", content: <SystemVariableList /> },
|
|
95
|
+
]}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
63
100
|
registerInternalPeersUI({
|
|
64
101
|
peersUIId: "00m5fshz2g6ea23v8z6y0a1ci",
|
|
65
102
|
component: VariableList,
|