@peers-app/peers-ui 0.12.6 → 0.13.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.
@@ -110,17 +110,17 @@ function GroupSwitcher({ colorMode, isMobile = false }) {
110
110
  maxHeight: '300px',
111
111
  overflowY: 'auto'
112
112
  } },
113
- react_1.default.createElement("div", { className: `dropdown-item d-flex align-items-center ${!currentGroup ? 'active' : ''}`, style: { cursor: 'pointer' }, onClick: () => handleGroupSelect(null) },
113
+ react_1.default.createElement("button", { className: `dropdown-item d-flex align-items-center ${!currentGroup ? 'active' : ''}`, style: { cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left' }, onClick: () => handleGroupSelect(null) },
114
114
  react_1.default.createElement("i", { className: "bi-person-fill me-2" }),
115
115
  react_1.default.createElement("span", null, "Personal")),
116
116
  allGroups.length > 0 && (react_1.default.createElement("div", { className: "dropdown-divider" })),
117
117
  allGroups
118
118
  .filter((g) => !g.disabled)
119
- .map((group) => (react_1.default.createElement("div", { key: group.groupId, className: `dropdown-item d-flex align-items-center ${currentGroup?.groupId === group.groupId ? 'active' : ''}`, style: { cursor: 'pointer' }, onClick: () => handleGroupSelect(group) },
119
+ .map((group) => (react_1.default.createElement("button", { key: group.groupId, className: `dropdown-item d-flex align-items-center ${currentGroup?.groupId === group.groupId ? 'active' : ''}`, style: { cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left' }, onClick: () => handleGroupSelect(group) },
120
120
  react_1.default.createElement("i", { className: `${getGroupIcon(group)} me-2` }),
121
121
  react_1.default.createElement("span", { className: "text-truncate" }, group.name)))),
122
122
  react_1.default.createElement("div", { className: "dropdown-divider" }),
123
- react_1.default.createElement("div", { className: "dropdown-item d-flex align-items-center", style: { cursor: 'pointer' }, onClick: handleCreateClick },
123
+ react_1.default.createElement("button", { className: "dropdown-item d-flex align-items-center", style: { cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left' }, onClick: handleCreateClick },
124
124
  react_1.default.createElement("i", { className: "bi-plus-circle me-2" }),
125
125
  react_1.default.createElement("span", null, "Create Group")))),
126
126
  showDropdown && (react_1.default.createElement("div", { className: "position-fixed w-100 h-100", style: { top: 0, left: 0, zIndex: 999 }, onClick: () => setShowDropdown(false) }))),
package/dist/globals.js CHANGED
@@ -124,11 +124,11 @@ async function loadGlobals() {
124
124
  await peers_sdk_1.rpcServerCalls.addOrUpdatePackage('updateAll');
125
125
  }
126
126
  await Promise.all([
127
- (0, peers_sdk_1.Groups)().list().then(exports.groups),
127
+ (0, peers_sdk_1.Groups)().list().then(r => (0, exports.groups)(r)),
128
128
  exports._mainContentPath.loadingPromise,
129
129
  exports.openThreads.loadingPromise,
130
130
  exports.threadViewOpen.loadingPromise,
131
- (0, peers_sdk_1.sleep)(100), // hacky way to prevent some jank
131
+ (0, peers_sdk_1.sleep)(100),
132
132
  ]);
133
133
  function updateWindowHash() {
134
134
  const currentPath = window.location.hash.substring(1);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export * from "./hooks";
2
2
  export * from "./screens/events/cron";
3
3
  export * from "./tabs-layout/tabs-layout";
4
- export { activeTabId, activeTabs, TabState } from "./tabs-layout/tabs-state";
4
+ export { activeTabId, activeTabs, TabState, updateActiveTabTitle, closeCurrentTab, goToTabPath } from "./tabs-layout/tabs-state";
5
+ export { mainContentPath } from "./globals";
5
6
  export * from "./components/voice-indicator";
6
7
  export * from "./components/chat-overlay";
7
8
  export * from "./components/sortable-list";
package/dist/index.js CHANGED
@@ -14,13 +14,18 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.activeTabs = exports.activeTabId = void 0;
17
+ exports.mainContentPath = exports.goToTabPath = exports.closeCurrentTab = exports.updateActiveTabTitle = exports.activeTabs = exports.activeTabId = void 0;
18
18
  __exportStar(require("./hooks"), exports);
19
19
  __exportStar(require("./screens/events/cron"), exports);
20
20
  __exportStar(require("./tabs-layout/tabs-layout"), exports);
21
21
  var tabs_state_1 = require("./tabs-layout/tabs-state");
22
22
  Object.defineProperty(exports, "activeTabId", { enumerable: true, get: function () { return tabs_state_1.activeTabId; } });
23
23
  Object.defineProperty(exports, "activeTabs", { enumerable: true, get: function () { return tabs_state_1.activeTabs; } });
24
+ Object.defineProperty(exports, "updateActiveTabTitle", { enumerable: true, get: function () { return tabs_state_1.updateActiveTabTitle; } });
25
+ Object.defineProperty(exports, "closeCurrentTab", { enumerable: true, get: function () { return tabs_state_1.closeCurrentTab; } });
26
+ Object.defineProperty(exports, "goToTabPath", { enumerable: true, get: function () { return tabs_state_1.goToTabPath; } });
27
+ var globals_1 = require("./globals");
28
+ Object.defineProperty(exports, "mainContentPath", { enumerable: true, get: function () { return globals_1.mainContentPath; } });
24
29
  __exportStar(require("./components/voice-indicator"), exports);
25
30
  __exportStar(require("./components/chat-overlay"), exports);
26
31
  __exportStar(require("./components/sortable-list"), exports);
@@ -136,6 +136,28 @@ const PackageVersionsList = (props) => {
136
136
  setDeleting(null);
137
137
  }
138
138
  }
139
+ async function promoteVersion(pv, newTag) {
140
+ try {
141
+ const pvHash = (0, peers_sdk_1.computePackageVersionHash)(pv.version, newTag, pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
142
+ const updated = { ...pv, versionTag: newTag, packageVersionHash: pvHash };
143
+ await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
144
+ refreshKey(refreshKey() + 1);
145
+ }
146
+ catch (err) {
147
+ alert(`Failed to promote version: ${err}`);
148
+ }
149
+ }
150
+ async function updateVersion(pv, newSemver) {
151
+ try {
152
+ const pvHash = (0, peers_sdk_1.computePackageVersionHash)(newSemver, pv.versionTag || 'beta', pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
153
+ const updated = { ...pv, version: newSemver, packageVersionHash: pvHash };
154
+ await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
155
+ refreshKey(refreshKey() + 1);
156
+ }
157
+ catch (err) {
158
+ alert(`Failed to update version: ${err}`);
159
+ }
160
+ }
139
161
  async function pinVersion() {
140
162
  setPinning(true);
141
163
  try {
@@ -168,30 +190,59 @@ const PackageVersionsList = (props) => {
168
190
  sorted.length,
169
191
  " version",
170
192
  sorted.length !== 1 ? 's' : ''),
171
- react_1.default.createElement("div", { className: "list-group mt-2" }, sorted.map(pv => {
172
- const isActive = pv.packageVersionId === activeVersionId;
173
- const creatorName = userMap.get(pv.createdBy);
174
- return (react_1.default.createElement("div", { key: pv.packageVersionId, className: `list-group-item ${isActive ? 'list-group-item-success' : ''}` },
175
- react_1.default.createElement("div", { className: "d-flex align-items-center justify-content-between" },
176
- react_1.default.createElement("div", null,
177
- react_1.default.createElement("strong", { className: "me-2" },
178
- "v",
179
- pv.version),
180
- pv.versionTag && (react_1.default.createElement("span", { className: `badge ${tagBadgeClass(pv.versionTag)} me-2` }, pv.versionTag)),
181
- react_1.default.createElement("code", { className: "text-muted small me-2" }, pv.packageVersionHash?.substring(0, 8)),
182
- react_1.default.createElement("small", { className: "text-muted me-2" }, formatDate(pv.createdAt)),
183
- creatorName && (react_1.default.createElement("small", { className: "text-muted" },
184
- "by ",
185
- creatorName))),
186
- react_1.default.createElement("div", { className: "d-flex gap-1" },
187
- isActive ? (react_1.default.createElement(react_1.default.Fragment, null,
188
- react_1.default.createElement("button", { className: "btn btn-sm btn-outline-secondary", disabled: isPinned || pinning, onClick: () => pinVersion(), title: isPinned ? 'Already pinned' : 'Pin to this version (disable auto-updates)' },
189
- pinning ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm" })) : (react_1.default.createElement("i", { className: "bi bi-pin-fill" })),
190
- isPinned ? ' Pinned' : ' Pin'),
191
- react_1.default.createElement("span", { className: "badge text-bg-success align-self-center" }, "active"))) : (react_1.default.createElement("button", { className: "btn btn-sm btn-outline-primary", disabled: activating === pv.packageVersionId, onClick: () => activateVersion(pv) },
192
- activating === pv.packageVersionId ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm me-1" })) : (react_1.default.createElement("i", { className: "bi bi-check2-circle me-1" })),
193
- "Activate")),
194
- !isActive && (react_1.default.createElement("button", { className: "btn btn-sm btn-outline-danger", disabled: deleting === pv.packageVersionId, onClick: () => deleteVersion(pv) }, deleting === pv.packageVersionId ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm" })) : (react_1.default.createElement("i", { className: "bi bi-trash" }))))))));
195
- }))));
193
+ react_1.default.createElement("div", { className: "list-group mt-2" }, sorted.map(pv => (react_1.default.createElement(VersionRow, { key: pv.packageVersionId, pv: pv, isActive: pv.packageVersionId === activeVersionId, isPinned: isPinned, pinning: pinning, activating: activating, deleting: deleting, userMap: userMap, onActivate: activateVersion, onDelete: deleteVersion, onPin: pinVersion, onPromote: promoteVersion, onUpdateVersion: updateVersion }))))));
196
194
  };
197
195
  exports.PackageVersionsList = PackageVersionsList;
196
+ const TAG_OPTIONS = ['beta', 'stable'];
197
+ function VersionRow(props) {
198
+ const { pv, isActive, isPinned, pinning, activating, deleting, userMap } = props;
199
+ const creatorName = userMap.get(pv.createdBy);
200
+ const [editingVersion, setEditingVersion] = (0, react_1.useState)(false);
201
+ const [versionDraft, setVersionDraft] = (0, react_1.useState)(pv.version);
202
+ function startEditing() {
203
+ setVersionDraft(pv.version);
204
+ setEditingVersion(true);
205
+ }
206
+ function commitVersion() {
207
+ const trimmed = versionDraft.trim();
208
+ if (trimmed && trimmed !== pv.version) {
209
+ props.onUpdateVersion(pv, trimmed);
210
+ }
211
+ setEditingVersion(false);
212
+ }
213
+ const currentTag = pv.versionTag || 'beta';
214
+ const promoteTargets = TAG_OPTIONS.filter(t => t !== currentTag);
215
+ return (react_1.default.createElement("div", { className: `list-group-item ${isActive ? 'list-group-item-success' : ''}` },
216
+ react_1.default.createElement("div", { className: "d-flex align-items-center justify-content-between" },
217
+ react_1.default.createElement("div", { className: "d-flex align-items-center flex-wrap" },
218
+ editingVersion ? (react_1.default.createElement("input", { type: "text", className: "form-control form-control-sm me-2", style: { width: '7em' }, value: versionDraft, onChange: e => setVersionDraft(e.target.value), onBlur: commitVersion, onKeyDown: e => {
219
+ if (e.key === 'Enter')
220
+ commitVersion();
221
+ if (e.key === 'Escape')
222
+ setEditingVersion(false);
223
+ }, autoFocus: true })) : (react_1.default.createElement("strong", { className: "me-2", style: { cursor: 'pointer' }, onClick: startEditing, title: "Click to edit version" },
224
+ "v",
225
+ pv.version)),
226
+ pv.versionTag && (react_1.default.createElement("span", { className: `badge ${tagBadgeClass(pv.versionTag)} me-2` }, pv.versionTag)),
227
+ react_1.default.createElement("code", { className: "text-muted small me-2" }, pv.packageVersionHash?.substring(0, 8)),
228
+ react_1.default.createElement("small", { className: "text-muted me-2" }, formatDate(pv.createdAt)),
229
+ creatorName && (react_1.default.createElement("small", { className: "text-muted" },
230
+ "by ",
231
+ creatorName))),
232
+ react_1.default.createElement("div", { className: "d-flex gap-1 align-items-center" },
233
+ promoteTargets.length > 0 && (react_1.default.createElement("div", { className: "dropdown" },
234
+ react_1.default.createElement("button", { className: "btn btn-sm btn-outline-info dropdown-toggle", "data-bs-toggle": "dropdown", title: "Change version tag" }, "Promote"),
235
+ react_1.default.createElement("ul", { className: "dropdown-menu dropdown-menu-end" }, promoteTargets.map(tag => (react_1.default.createElement("li", { key: tag },
236
+ react_1.default.createElement("button", { className: "dropdown-item", onClick: () => props.onPromote(pv, tag) },
237
+ currentTag,
238
+ " \u2192 ",
239
+ tag))))))),
240
+ isActive ? (react_1.default.createElement(react_1.default.Fragment, null,
241
+ react_1.default.createElement("button", { className: "btn btn-sm btn-outline-secondary", disabled: isPinned || pinning, onClick: () => props.onPin(), title: isPinned ? 'Already pinned' : 'Pin to this version (disable auto-updates)' },
242
+ pinning ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm" })) : (react_1.default.createElement("i", { className: "bi bi-pin-fill" })),
243
+ isPinned ? ' Pinned' : ' Pin'),
244
+ react_1.default.createElement("span", { className: "badge text-bg-success align-self-center" }, "active"))) : (react_1.default.createElement("button", { className: "btn btn-sm btn-outline-primary", disabled: activating === pv.packageVersionId, onClick: () => props.onActivate(pv) },
245
+ activating === pv.packageVersionId ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm me-1" })) : (react_1.default.createElement("i", { className: "bi bi-check2-circle me-1" })),
246
+ "Activate")),
247
+ !isActive && (react_1.default.createElement("button", { className: "btn btn-sm btn-outline-danger", disabled: deleting === pv.packageVersionId, onClick: () => props.onDelete(pv) }, deleting === pv.packageVersionId ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm" })) : (react_1.default.createElement("i", { className: "bi bi-trash" }))))))));
248
+ }
@@ -67,7 +67,9 @@ const SettingsPage = () => {
67
67
  };
68
68
  exports.SettingsPage = SettingsPage;
69
69
  const UserSettingsTab = () => {
70
- return (react_1.default.createElement(ProfileSection, null));
70
+ return (react_1.default.createElement(react_1.default.Fragment, null,
71
+ react_1.default.createElement(ProfileSection, null),
72
+ react_1.default.createElement(LogoutSection, null)));
71
73
  };
72
74
  const AppearanceSettingsTab = () => {
73
75
  return (react_1.default.createElement(react_1.default.Fragment, null,
@@ -234,3 +236,18 @@ const DeleteLocalDatabase = () => {
234
236
  }
235
237
  } }, "Delete Local Database")));
236
238
  };
239
+ const showLogoutInSettings = typeof window !== 'undefined'
240
+ && !window.electronAPI
241
+ && !window.ReactNativeWebView;
242
+ const LogoutSection = () => {
243
+ if (!showLogoutInSettings)
244
+ return null;
245
+ return (react_1.default.createElement("div", { className: "mt-4 pt-3 border-top" },
246
+ react_1.default.createElement("button", { className: "btn btn-outline-danger btn-sm", onClick: async () => {
247
+ const confirmed = confirm('Are you sure you want to logout? This will clear your credentials from this browser. ' +
248
+ 'Make sure you have your User ID and Secret Key saved securely, or you will lose access to your account.');
249
+ if (!confirmed)
250
+ return;
251
+ await peers_sdk_1.rpcServerCalls.logout();
252
+ } }, "Logout")));
253
+ };
@@ -96,14 +96,13 @@ function TabsLayout(props) {
96
96
  userContextInitialized = true;
97
97
  }
98
98
  const loaded = (0, hooks_1.usePromise)(async () => {
99
- await (0, globals_1.loadGlobals)().then(() => console.log('Globals loaded'));
100
- await (0, routes_loader_1.loadAllRoutes)().then(() => console.log('Routes loaded'));
101
- // Wait for tab state and welcome modal state to load from database
99
+ await (0, globals_1.loadGlobals)();
100
+ await (0, routes_loader_1.loadAllRoutes)();
102
101
  await Promise.all([
103
102
  tabs_state_1.activeTabs.loadingPromise,
104
103
  tabs_state_1.activeTabId.loadingPromise,
105
104
  tabs_state_1.recentlyUsedApps.loadingPromise,
106
- peers_sdk_1.hasShownWelcomeModal.loadingPromise
105
+ peers_sdk_1.hasShownWelcomeModal.loadingPromise,
107
106
  ]);
108
107
  return true;
109
108
  });
@@ -422,10 +421,13 @@ function AppSection({ title, iconClassName, apps, onOpenApp, isMobile }) {
422
421
  function AppCard({ appItem, onOpenApp, isMobile }) {
423
422
  const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
424
423
  const isDark = _colorMode === 'dark';
425
- return (react_1.default.createElement("div", { className: "d-flex flex-column align-items-center text-center", style: {
424
+ return (react_1.default.createElement("button", { className: "d-flex flex-column align-items-center text-center", style: {
426
425
  cursor: 'pointer',
427
426
  width: isMobile ? '80px' : '90px',
428
- transition: 'all 0.15s ease'
427
+ transition: 'all 0.15s ease',
428
+ background: 'none',
429
+ border: 'none',
430
+ padding: 0,
429
431
  }, title: appItem.name, onMouseEnter: (e) => {
430
432
  e.currentTarget.style.transform = 'scale(1.05)';
431
433
  }, onMouseLeave: (e) => {
@@ -442,7 +444,7 @@ function AppCard({ appItem, onOpenApp, isMobile }) {
442
444
  } },
443
445
  react_1.default.createElement("i", { className: appItem.iconClassName, style: {
444
446
  fontSize: isMobile ? '28px' : '32px',
445
- color: isDark ? '#0d6efd' : '#0d6efd' // Keep primary blue for both themes
447
+ color: isDark ? '#0d6efd' : '#0d6efd'
446
448
  } })),
447
449
  react_1.default.createElement("span", { className: isDark ? 'text-light' : 'text-dark', style: {
448
450
  fontSize: isMobile ? '11px' : '12px',
@@ -164,7 +164,10 @@ function determineAppFromPath(path) {
164
164
  const pkg = _allPackages.find(p => p.packageId === packageId);
165
165
  if (pkg) {
166
166
  const subPath = parts.slice(1).join('/');
167
- const navItem = pkg.appNavs?.find(n => n.navigationPath === subPath || n.name.toLowerCase() === subPath);
167
+ const navItem = pkg.appNavs?.find(n => {
168
+ const navPath = n.navigationPath || n.name.toLowerCase();
169
+ return subPath === navPath || subPath.startsWith(navPath + '/');
170
+ });
168
171
  if (navItem) {
169
172
  return {
170
173
  navItem,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.12.6",
3
+ "version": "0.13.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -26,7 +26,7 @@
26
26
  "test:coverage": "jest --coverage"
27
27
  },
28
28
  "peerDependencies": {
29
- "@peers-app/peers-sdk": "^0.12.6",
29
+ "@peers-app/peers-sdk": "^0.13.0",
30
30
  "bootstrap": "^5.3.3",
31
31
  "react": "^18.0.0",
32
32
  "react-dom": "^18.0.0"
@@ -36,7 +36,7 @@
36
36
  "@babel/preset-env": "^7.24.5",
37
37
  "@babel/preset-react": "^7.24.1",
38
38
  "@babel/preset-typescript": "^7.27.1",
39
- "@peers-app/peers-sdk": "0.12.6",
39
+ "@peers-app/peers-sdk": "0.13.0",
40
40
  "@testing-library/dom": "^10.4.0",
41
41
  "@testing-library/jest-dom": "^6.6.3",
42
42
  "@testing-library/react": "^16.3.0",
@@ -106,14 +106,14 @@ export function GroupSwitcher({ colorMode, isMobile = false }: GroupSwitcherProp
106
106
  }}
107
107
  >
108
108
  {/* Personal Group */}
109
- <div
109
+ <button
110
110
  className={`dropdown-item d-flex align-items-center ${!currentGroup ? 'active' : ''}`}
111
- style={{ cursor: 'pointer' }}
111
+ style={{ cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left' }}
112
112
  onClick={() => handleGroupSelect(null)}
113
113
  >
114
114
  <i className="bi-person-fill me-2" />
115
115
  <span>Personal</span>
116
- </div>
116
+ </button>
117
117
 
118
118
  {/* Divider */}
119
119
  {allGroups.length > 0 && (
@@ -124,27 +124,27 @@ export function GroupSwitcher({ colorMode, isMobile = false }: GroupSwitcherProp
124
124
  {allGroups
125
125
  .filter((g: IGroup) => !g.disabled)
126
126
  .map((group: IGroup) => (
127
- <div
127
+ <button
128
128
  key={group.groupId}
129
129
  className={`dropdown-item d-flex align-items-center ${currentGroup?.groupId === group.groupId ? 'active' : ''}`}
130
- style={{ cursor: 'pointer' }}
130
+ style={{ cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left' }}
131
131
  onClick={() => handleGroupSelect(group)}
132
132
  >
133
133
  <i className={`${getGroupIcon(group)} me-2`} />
134
134
  <span className="text-truncate">{group.name}</span>
135
- </div>
135
+ </button>
136
136
  ))}
137
137
 
138
138
  {/* Create New Group */}
139
139
  <div className="dropdown-divider" />
140
- <div
140
+ <button
141
141
  className="dropdown-item d-flex align-items-center"
142
- style={{ cursor: 'pointer' }}
142
+ style={{ cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left' }}
143
143
  onClick={handleCreateClick}
144
144
  >
145
145
  <i className="bi-plus-circle me-2" />
146
146
  <span>Create Group</span>
147
- </div>
147
+ </button>
148
148
  </div>
149
149
  )}
150
150
 
package/src/globals.tsx CHANGED
@@ -134,11 +134,11 @@ export async function loadGlobals() {
134
134
  await rpcServerCalls.addOrUpdatePackage('updateAll');
135
135
  }
136
136
  await Promise.all([
137
- Groups().list().then(groups),
137
+ Groups().list().then(r => groups(r)),
138
138
  _mainContentPath.loadingPromise,
139
139
  openThreads.loadingPromise,
140
140
  threadViewOpen.loadingPromise,
141
- sleep(100), // hacky way to prevent some jank
141
+ sleep(100),
142
142
  ]);
143
143
 
144
144
  function updateWindowHash() {
package/src/index.tsx CHANGED
@@ -2,7 +2,8 @@ export * from "./hooks";
2
2
  export * from "./screens/events/cron";
3
3
 
4
4
  export * from "./tabs-layout/tabs-layout";
5
- export { activeTabId, activeTabs, TabState } from "./tabs-layout/tabs-state";
5
+ export { activeTabId, activeTabs, TabState, updateActiveTabTitle, closeCurrentTab, goToTabPath } from "./tabs-layout/tabs-state";
6
+ export { mainContentPath } from "./globals";
6
7
  export * from "./components/voice-indicator";
7
8
  export * from "./components/chat-overlay";
8
9
  export * from "./components/sortable-list";
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from "react";
2
- import { IDoc, IPackage, IPackageVersion, Packages, PackageVersions, Users } from "@peers-app/peers-sdk";
2
+ import { computePackageVersionHash, IDoc, IPackage, IPackageVersion, Packages, PackageVersions, Users } from "@peers-app/peers-sdk";
3
3
  import { useObservable, useObservableState, usePromise } from "../../hooks";
4
4
 
5
5
  function formatDate(iso: string): string {
@@ -89,6 +89,28 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
89
89
  }
90
90
  }
91
91
 
92
+ async function promoteVersion(pv: IPackageVersion, newTag: string) {
93
+ try {
94
+ const pvHash = computePackageVersionHash(pv.version, newTag, pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
95
+ const updated = { ...pv, versionTag: newTag, packageVersionHash: pvHash };
96
+ await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
97
+ refreshKey(refreshKey() + 1);
98
+ } catch (err) {
99
+ alert(`Failed to promote version: ${err}`);
100
+ }
101
+ }
102
+
103
+ async function updateVersion(pv: IPackageVersion, newSemver: string) {
104
+ try {
105
+ const pvHash = computePackageVersionHash(newSemver, pv.versionTag || 'beta', pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
106
+ const updated = { ...pv, version: newSemver, packageVersionHash: pvHash };
107
+ await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
108
+ refreshKey(refreshKey() + 1);
109
+ } catch (err) {
110
+ alert(`Failed to update version: ${err}`);
111
+ }
112
+ }
113
+
92
114
  async function pinVersion() {
93
115
  setPinning(true);
94
116
  try {
@@ -124,81 +146,174 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
124
146
  <div>
125
147
  <small className="text-muted">{sorted.length} version{sorted.length !== 1 ? 's' : ''}</small>
126
148
  <div className="list-group mt-2">
127
- {sorted.map(pv => {
128
- const isActive = pv.packageVersionId === activeVersionId;
129
- const creatorName = userMap.get(pv.createdBy);
130
- return (
131
- <div
132
- key={pv.packageVersionId}
133
- className={`list-group-item ${isActive ? 'list-group-item-success' : ''}`}
149
+ {sorted.map(pv => (
150
+ <VersionRow
151
+ key={pv.packageVersionId}
152
+ pv={pv}
153
+ isActive={pv.packageVersionId === activeVersionId}
154
+ isPinned={isPinned}
155
+ pinning={pinning}
156
+ activating={activating}
157
+ deleting={deleting}
158
+ userMap={userMap}
159
+ onActivate={activateVersion}
160
+ onDelete={deleteVersion}
161
+ onPin={pinVersion}
162
+ onPromote={promoteVersion}
163
+ onUpdateVersion={updateVersion}
164
+ />
165
+ ))}
166
+ </div>
167
+ </div>
168
+ );
169
+ };
170
+
171
+ const TAG_OPTIONS = ['beta', 'stable'];
172
+
173
+ function VersionRow(props: {
174
+ pv: IPackageVersion;
175
+ isActive: boolean;
176
+ isPinned: boolean;
177
+ pinning: boolean;
178
+ activating: string | null;
179
+ deleting: string | null;
180
+ userMap: Map<string, string>;
181
+ onActivate: (pv: IPackageVersion) => void;
182
+ onDelete: (pv: IPackageVersion) => void;
183
+ onPin: () => void;
184
+ onPromote: (pv: IPackageVersion, newTag: string) => void;
185
+ onUpdateVersion: (pv: IPackageVersion, newSemver: string) => void;
186
+ }) {
187
+ const { pv, isActive, isPinned, pinning, activating, deleting, userMap } = props;
188
+ const creatorName = userMap.get(pv.createdBy);
189
+ const [editingVersion, setEditingVersion] = useState(false);
190
+ const [versionDraft, setVersionDraft] = useState(pv.version);
191
+
192
+ function startEditing() {
193
+ setVersionDraft(pv.version);
194
+ setEditingVersion(true);
195
+ }
196
+
197
+ function commitVersion() {
198
+ const trimmed = versionDraft.trim();
199
+ if (trimmed && trimmed !== pv.version) {
200
+ props.onUpdateVersion(pv, trimmed);
201
+ }
202
+ setEditingVersion(false);
203
+ }
204
+
205
+ const currentTag = pv.versionTag || 'beta';
206
+ const promoteTargets = TAG_OPTIONS.filter(t => t !== currentTag);
207
+
208
+ return (
209
+ <div className={`list-group-item ${isActive ? 'list-group-item-success' : ''}`}>
210
+ <div className="d-flex align-items-center justify-content-between">
211
+ <div className="d-flex align-items-center flex-wrap">
212
+ {editingVersion ? (
213
+ <input
214
+ type="text"
215
+ className="form-control form-control-sm me-2"
216
+ style={{ width: '7em' }}
217
+ value={versionDraft}
218
+ onChange={e => setVersionDraft(e.target.value)}
219
+ onBlur={commitVersion}
220
+ onKeyDown={e => {
221
+ if (e.key === 'Enter') commitVersion();
222
+ if (e.key === 'Escape') setEditingVersion(false);
223
+ }}
224
+ autoFocus
225
+ />
226
+ ) : (
227
+ <strong
228
+ className="me-2"
229
+ style={{ cursor: 'pointer' }}
230
+ onClick={startEditing}
231
+ title="Click to edit version"
134
232
  >
135
- <div className="d-flex align-items-center justify-content-between">
136
- <div>
137
- <strong className="me-2">v{pv.version}</strong>
138
- {pv.versionTag && (
139
- <span className={`badge ${tagBadgeClass(pv.versionTag)} me-2`}>
140
- {pv.versionTag}
141
- </span>
142
- )}
143
- <code className="text-muted small me-2">
144
- {pv.packageVersionHash?.substring(0, 8)}
145
- </code>
146
- <small className="text-muted me-2">{formatDate(pv.createdAt)}</small>
147
- {creatorName && (
148
- <small className="text-muted">
149
- by {creatorName}
150
- </small>
151
- )}
152
- </div>
153
- <div className="d-flex gap-1">
154
- {isActive ? (<>
155
- <button
156
- className="btn btn-sm btn-outline-secondary"
157
- disabled={isPinned || pinning}
158
- onClick={() => pinVersion()}
159
- title={isPinned ? 'Already pinned' : 'Pin to this version (disable auto-updates)'}
160
- >
161
- {pinning ? (
162
- <span className="spinner-border spinner-border-sm" />
163
- ) : (
164
- <i className="bi bi-pin-fill"></i>
165
- )}
166
- {isPinned ? ' Pinned' : ' Pin'}
167
- </button>
168
- <span className="badge text-bg-success align-self-center">active</span>
169
- </>) : (
233
+ v{pv.version}
234
+ </strong>
235
+ )}
236
+ {pv.versionTag && (
237
+ <span className={`badge ${tagBadgeClass(pv.versionTag)} me-2`}>
238
+ {pv.versionTag}
239
+ </span>
240
+ )}
241
+ <code className="text-muted small me-2">
242
+ {pv.packageVersionHash?.substring(0, 8)}
243
+ </code>
244
+ <small className="text-muted me-2">{formatDate(pv.createdAt)}</small>
245
+ {creatorName && (
246
+ <small className="text-muted">by {creatorName}</small>
247
+ )}
248
+ </div>
249
+ <div className="d-flex gap-1 align-items-center">
250
+ {promoteTargets.length > 0 && (
251
+ <div className="dropdown">
252
+ <button
253
+ className="btn btn-sm btn-outline-info dropdown-toggle"
254
+ data-bs-toggle="dropdown"
255
+ title="Change version tag"
256
+ >
257
+ Promote
258
+ </button>
259
+ <ul className="dropdown-menu dropdown-menu-end">
260
+ {promoteTargets.map(tag => (
261
+ <li key={tag}>
170
262
  <button
171
- className="btn btn-sm btn-outline-primary"
172
- disabled={activating === pv.packageVersionId}
173
- onClick={() => activateVersion(pv)}
263
+ className="dropdown-item"
264
+ onClick={() => props.onPromote(pv, tag)}
174
265
  >
175
- {activating === pv.packageVersionId ? (
176
- <span className="spinner-border spinner-border-sm me-1" />
177
- ) : (
178
- <i className="bi bi-check2-circle me-1"></i>
179
- )}
180
- Activate
266
+ {currentTag} &rarr; {tag}
181
267
  </button>
182
- )}
183
- {!isActive && (
184
- <button
185
- className="btn btn-sm btn-outline-danger"
186
- disabled={deleting === pv.packageVersionId}
187
- onClick={() => deleteVersion(pv)}
188
- >
189
- {deleting === pv.packageVersionId ? (
190
- <span className="spinner-border spinner-border-sm" />
191
- ) : (
192
- <i className="bi bi-trash"></i>
193
- )}
194
- </button>
195
- )}
196
- </div>
197
- </div>
268
+ </li>
269
+ ))}
270
+ </ul>
198
271
  </div>
199
- );
200
- })}
272
+ )}
273
+ {isActive ? (<>
274
+ <button
275
+ className="btn btn-sm btn-outline-secondary"
276
+ disabled={isPinned || pinning}
277
+ onClick={() => props.onPin()}
278
+ title={isPinned ? 'Already pinned' : 'Pin to this version (disable auto-updates)'}
279
+ >
280
+ {pinning ? (
281
+ <span className="spinner-border spinner-border-sm" />
282
+ ) : (
283
+ <i className="bi bi-pin-fill"></i>
284
+ )}
285
+ {isPinned ? ' Pinned' : ' Pin'}
286
+ </button>
287
+ <span className="badge text-bg-success align-self-center">active</span>
288
+ </>) : (
289
+ <button
290
+ className="btn btn-sm btn-outline-primary"
291
+ disabled={activating === pv.packageVersionId}
292
+ onClick={() => props.onActivate(pv)}
293
+ >
294
+ {activating === pv.packageVersionId ? (
295
+ <span className="spinner-border spinner-border-sm me-1" />
296
+ ) : (
297
+ <i className="bi bi-check2-circle me-1"></i>
298
+ )}
299
+ Activate
300
+ </button>
301
+ )}
302
+ {!isActive && (
303
+ <button
304
+ className="btn btn-sm btn-outline-danger"
305
+ disabled={deleting === pv.packageVersionId}
306
+ onClick={() => props.onDelete(pv)}
307
+ >
308
+ {deleting === pv.packageVersionId ? (
309
+ <span className="spinner-border spinner-border-sm" />
310
+ ) : (
311
+ <i className="bi bi-trash"></i>
312
+ )}
313
+ </button>
314
+ )}
315
+ </div>
201
316
  </div>
202
317
  </div>
203
318
  );
204
- };
319
+ }
@@ -36,7 +36,10 @@ export const SettingsPage: React.FC = () => {
36
36
 
37
37
  const UserSettingsTab: React.FC = () => {
38
38
  return (
39
- <ProfileSection />
39
+ <>
40
+ <ProfileSection />
41
+ <LogoutSection />
42
+ </>
40
43
  );
41
44
  };
42
45
 
@@ -319,4 +322,29 @@ const DeleteLocalDatabase: React.FC = () => {
319
322
  </button>
320
323
  </div>
321
324
  );
325
+ }
326
+
327
+ const showLogoutInSettings = typeof window !== 'undefined'
328
+ && !(window as any).electronAPI
329
+ && !(window as any).ReactNativeWebView;
330
+
331
+ const LogoutSection: React.FC = () => {
332
+ if (!showLogoutInSettings) return null;
333
+ return (
334
+ <div className="mt-4 pt-3 border-top">
335
+ <button
336
+ className="btn btn-outline-danger btn-sm"
337
+ onClick={async () => {
338
+ const confirmed = confirm(
339
+ 'Are you sure you want to logout? This will clear your credentials from this browser. ' +
340
+ 'Make sure you have your User ID and Secret Key saved securely, or you will lose access to your account.'
341
+ );
342
+ if (!confirmed) return;
343
+ await rpcServerCalls.logout();
344
+ }}
345
+ >
346
+ Logout
347
+ </button>
348
+ </div>
349
+ );
322
350
  }
@@ -87,14 +87,13 @@ export function TabsLayout(props: { userId: string }) {
87
87
  }
88
88
 
89
89
  const loaded = usePromise(async () => {
90
- await loadGlobals().then(() => console.log('Globals loaded'));
91
- await loadAllRoutes().then(() => console.log('Routes loaded'));
92
- // Wait for tab state and welcome modal state to load from database
90
+ await loadGlobals();
91
+ await loadAllRoutes();
93
92
  await Promise.all([
94
93
  activeTabs.loadingPromise,
95
94
  activeTabId.loadingPromise,
96
95
  recentlyUsedApps.loadingPromise,
97
- hasShownWelcomeModal.loadingPromise
96
+ hasShownWelcomeModal.loadingPromise,
98
97
  ]);
99
98
  return true;
100
99
  });
@@ -730,12 +729,15 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
730
729
  const isDark = _colorMode === 'dark';
731
730
 
732
731
  return (
733
- <div
732
+ <button
734
733
  className="d-flex flex-column align-items-center text-center"
735
734
  style={{
736
735
  cursor: 'pointer',
737
736
  width: isMobile ? '80px' : '90px',
738
- transition: 'all 0.15s ease'
737
+ transition: 'all 0.15s ease',
738
+ background: 'none',
739
+ border: 'none',
740
+ padding: 0,
739
741
  }}
740
742
  title={appItem.name}
741
743
  onMouseEnter={(e) => {
@@ -746,7 +748,6 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
746
748
  }}
747
749
  onClick={() => onOpenApp(appItem)}
748
750
  >
749
- {/* Icon Container */}
750
751
  <div
751
752
  className="d-flex align-items-center justify-content-center mb-2"
752
753
  style={{
@@ -763,12 +764,11 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
763
764
  className={appItem.iconClassName}
764
765
  style={{
765
766
  fontSize: isMobile ? '28px' : '32px',
766
- color: isDark ? '#0d6efd' : '#0d6efd' // Keep primary blue for both themes
767
+ color: isDark ? '#0d6efd' : '#0d6efd'
767
768
  }}
768
769
  />
769
770
  </div>
770
771
 
771
- {/* Title */}
772
772
  <span
773
773
  className={isDark ? 'text-light' : 'text-dark'}
774
774
  style={{
@@ -783,6 +783,6 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
783
783
  >
784
784
  {appItem.displayName}
785
785
  </span>
786
- </div>
786
+ </button>
787
787
  );
788
788
  }
@@ -194,7 +194,10 @@ export function determineAppFromPath(path: string): AppInfo | undefined {
194
194
  const pkg = _allPackages.find(p => p.packageId === packageId);
195
195
  if (pkg) {
196
196
  const subPath = parts.slice(1).join('/');
197
- const navItem = pkg.appNavs?.find(n => n.navigationPath === subPath || n.name.toLowerCase() === subPath);
197
+ const navItem = pkg.appNavs?.find(n => {
198
+ const navPath = n.navigationPath || n.name.toLowerCase();
199
+ return subPath === navPath || subPath.startsWith(navPath + '/');
200
+ });
198
201
  if (navItem) {
199
202
  return {
200
203
  navItem,