@peers-app/peers-ui 0.12.6 → 0.13.1

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) }))),
@@ -72,7 +72,7 @@ function LazyList(props) {
72
72
  if (renderItems.length < 50 && !allLoaded && !loading() && !isLoadingSync.current) {
73
73
  loadMore();
74
74
  }
75
- }, [renderItems.length, allLoaded, loading()]);
75
+ }, [renderItems.length, allLoaded]);
76
76
  (0, react_1.useEffect)(() => {
77
77
  itemsObsAry([]);
78
78
  setAllLoaded(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
+ }
@@ -30,11 +30,17 @@ function applyColorMode(modePreference) {
30
30
  }
31
31
  document.documentElement.setAttribute('data-bs-theme', mode);
32
32
  if (mode === 'light') {
33
+ document.documentElement.style.backgroundColor = '';
33
34
  document.body.style.backgroundColor = 'initial';
34
35
  }
35
36
  else {
37
+ document.documentElement.style.backgroundColor = 'rgb(33, 37, 41)';
36
38
  document.body.style.backgroundColor = 'rgb(33, 37, 41)';
37
39
  }
40
+ const themeMeta = document.querySelector('meta[name="theme-color"]');
41
+ if (themeMeta) {
42
+ themeMeta.setAttribute('content', mode === 'light' ? '#ffffff' : '#212529');
43
+ }
38
44
  (0, exports.colorMode)(mode);
39
45
  exports.colorMode.notifySubscribers();
40
46
  return mode;
@@ -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,
@@ -81,7 +83,8 @@ const AdvancedSettingsTab = () => {
81
83
  react_1.default.createElement(ReloadPackagesOnPageRefresh, null),
82
84
  react_1.default.createElement(AutoUpdatePeersCore, null),
83
85
  react_1.default.createElement(ResetDeviceSyncInfos, null),
84
- react_1.default.createElement(DeleteLocalDatabase, null)));
86
+ react_1.default.createElement(DeleteLocalDatabase, null),
87
+ react_1.default.createElement(ImportOldPeersData, null)));
85
88
  };
86
89
  const ProfileSection = () => {
87
90
  const [deviceId] = (0, hooks_1.useObservable)(peers_sdk_1.thisDeviceId);
@@ -234,3 +237,168 @@ const DeleteLocalDatabase = () => {
234
237
  }
235
238
  } }, "Delete Local Database")));
236
239
  };
240
+ const IMPORT_TOOL_ID = '00mh0wlipkdbeaw8imptsk001';
241
+ const ImportOldPeersData = () => {
242
+ const fileInputRef = (0, react_1.useRef)(null);
243
+ const [filePath, setFilePath] = (0, react_1.useState)('');
244
+ const [fileName, setFileName] = (0, react_1.useState)('');
245
+ const [loading, setLoading] = (0, react_1.useState)(false);
246
+ const [result, setResult] = (0, react_1.useState)(null);
247
+ const [error, setError] = (0, react_1.useState)('');
248
+ const handleFileSelect = (e) => {
249
+ const file = e.target.files?.[0];
250
+ if (!file)
251
+ return;
252
+ // Electron exposes .path on File objects
253
+ const path = file.path;
254
+ if (path) {
255
+ setFilePath(path);
256
+ setFileName(file.name);
257
+ }
258
+ else {
259
+ setFileName(file.name);
260
+ setFilePath('');
261
+ setError('File path not available. This feature requires the Electron desktop app.');
262
+ }
263
+ setResult(null);
264
+ setError('');
265
+ };
266
+ const handleDryRun = async () => {
267
+ if (!filePath)
268
+ return;
269
+ setLoading(true);
270
+ setError('');
271
+ setResult(null);
272
+ try {
273
+ const response = await peers_sdk_1.rpcServerCalls.runTool(IMPORT_TOOL_ID, {
274
+ filePath,
275
+ dryRun: true,
276
+ });
277
+ if (response?.result) {
278
+ setResult(response.result);
279
+ }
280
+ else {
281
+ setError('Unexpected response from import tool');
282
+ }
283
+ }
284
+ catch (err) {
285
+ setError(err.message || 'Failed to run dry run');
286
+ }
287
+ finally {
288
+ setLoading(false);
289
+ }
290
+ };
291
+ return (react_1.default.createElement("div", { className: "mt-4 pt-3 border-top" },
292
+ react_1.default.createElement("h6", { className: "mb-2" }, "Import Old Peers Data"),
293
+ react_1.default.createElement("small", { className: "text-muted d-block mb-2" }, "Import tasks and log entries from an old peers JSON export file."),
294
+ react_1.default.createElement("div", { className: "d-flex align-items-center gap-2 mb-2" },
295
+ react_1.default.createElement("input", { ref: fileInputRef, type: "file", accept: ".json", className: "form-control form-control-sm", style: { maxWidth: 350 }, onChange: handleFileSelect }),
296
+ react_1.default.createElement("button", { className: "btn btn-outline-primary btn-sm", onClick: handleDryRun, disabled: !filePath || loading }, loading ? 'Analyzing...' : 'Dry Run')),
297
+ error && (react_1.default.createElement("div", { className: "alert alert-danger py-1 px-2 small mt-2" }, error)),
298
+ result && react_1.default.createElement(DryRunResults, { result: result })));
299
+ };
300
+ const DryRunResults = ({ result }) => {
301
+ const sm = result.statusMapping;
302
+ const um = result.userMapping;
303
+ return (react_1.default.createElement("div", { className: "mt-2" },
304
+ react_1.default.createElement("div", { className: "alert alert-info py-2 px-3 small" },
305
+ react_1.default.createElement("strong", null, "Summary:"),
306
+ " ",
307
+ result.totalTasks.toLocaleString(),
308
+ " tasks and ",
309
+ result.totalLogEntries.toLocaleString(),
310
+ " log entries across ",
311
+ result.groups.length,
312
+ " groups (",
313
+ result.totalRecords.toLocaleString(),
314
+ " total records in file)"),
315
+ result.warnings.length > 0 && (react_1.default.createElement("div", { className: "alert alert-warning py-2 px-3 small" },
316
+ react_1.default.createElement("strong", null, "Warnings:"),
317
+ react_1.default.createElement("ul", { className: "mb-0 ps-3" }, result.warnings.map((w, i) => react_1.default.createElement("li", { key: i }, w))))),
318
+ react_1.default.createElement("details", { className: "mb-2" },
319
+ react_1.default.createElement("summary", { className: "small fw-bold", style: { cursor: 'pointer' } }, "Status Mapping"),
320
+ react_1.default.createElement("table", { className: "table table-sm small mt-1" },
321
+ react_1.default.createElement("thead", null,
322
+ react_1.default.createElement("tr", null,
323
+ react_1.default.createElement("th", null, "Mapping"),
324
+ react_1.default.createElement("th", { className: "text-end" }, "Count"))),
325
+ react_1.default.createElement("tbody", null,
326
+ react_1.default.createElement("tr", null,
327
+ react_1.default.createElement("td", null, "In-Progress \u2192 Done"),
328
+ react_1.default.createElement("td", { className: "text-end" }, sm.inProgressToDone.toLocaleString())),
329
+ react_1.default.createElement("tr", null,
330
+ react_1.default.createElement("td", null, "In-Progress \u2192 In-Progress"),
331
+ react_1.default.createElement("td", { className: "text-end" }, sm.inProgressKeep.toLocaleString())),
332
+ react_1.default.createElement("tr", null,
333
+ react_1.default.createElement("td", null, "Queued \u2192 Done"),
334
+ react_1.default.createElement("td", { className: "text-end" }, sm.queuedToDone.toLocaleString())),
335
+ react_1.default.createElement("tr", null,
336
+ react_1.default.createElement("td", null, "Queued \u2192 Queued"),
337
+ react_1.default.createElement("td", { className: "text-end" }, sm.queuedKeep.toLocaleString())),
338
+ react_1.default.createElement("tr", null,
339
+ react_1.default.createElement("td", null, "Backlog \u2192 Done"),
340
+ react_1.default.createElement("td", { className: "text-end" }, sm.backlogToDone.toLocaleString())),
341
+ react_1.default.createElement("tr", null,
342
+ react_1.default.createElement("td", null, "Backlog \u2192 Backlog"),
343
+ react_1.default.createElement("td", { className: "text-end" }, sm.backlogKeep.toLocaleString())),
344
+ react_1.default.createElement("tr", null,
345
+ react_1.default.createElement("td", null, "No status \u2192 Done"),
346
+ react_1.default.createElement("td", { className: "text-end" }, sm.noStatusToDone.toLocaleString())),
347
+ react_1.default.createElement("tr", null,
348
+ react_1.default.createElement("td", null, "No status \u2192 Backlog"),
349
+ react_1.default.createElement("td", { className: "text-end" }, sm.noStatusToBacklog.toLocaleString()))))),
350
+ react_1.default.createElement("details", { className: "mb-2" },
351
+ react_1.default.createElement("summary", { className: "small fw-bold", style: { cursor: 'pointer' } }, "User Mapping"),
352
+ react_1.default.createElement("table", { className: "table table-sm small mt-1" },
353
+ react_1.default.createElement("thead", null,
354
+ react_1.default.createElement("tr", null,
355
+ react_1.default.createElement("th", null, "User"),
356
+ react_1.default.createElement("th", { className: "text-end" }, "Tasks"))),
357
+ react_1.default.createElement("tbody", null,
358
+ react_1.default.createElement("tr", null,
359
+ react_1.default.createElement("td", null, "Mark"),
360
+ react_1.default.createElement("td", { className: "text-end" }, um.mark.toLocaleString())),
361
+ react_1.default.createElement("tr", null,
362
+ react_1.default.createElement("td", null, "Blair"),
363
+ react_1.default.createElement("td", { className: "text-end" }, um.blair.toLocaleString())),
364
+ react_1.default.createElement("tr", null,
365
+ react_1.default.createElement("td", null, "Other \u2192 Mark"),
366
+ react_1.default.createElement("td", { className: "text-end" }, um.other.toLocaleString()))))),
367
+ react_1.default.createElement("details", { open: true, className: "mb-2" },
368
+ react_1.default.createElement("summary", { className: "small fw-bold", style: { cursor: 'pointer' } },
369
+ "Groups (",
370
+ result.groups.length,
371
+ ")"),
372
+ react_1.default.createElement("table", { className: "table table-sm small mt-1" },
373
+ react_1.default.createElement("thead", null,
374
+ react_1.default.createElement("tr", null,
375
+ react_1.default.createElement("th", null, "Group"),
376
+ react_1.default.createElement("th", null, "Route"),
377
+ react_1.default.createElement("th", { className: "text-end" }, "Tasks"),
378
+ react_1.default.createElement("th", { className: "text-end" }, "Active"),
379
+ react_1.default.createElement("th", { className: "text-end" }, "Done"),
380
+ react_1.default.createElement("th", { className: "text-end" }, "Logs"))),
381
+ react_1.default.createElement("tbody", null, result.groups.map(g => (react_1.default.createElement("tr", { key: g.groupId },
382
+ react_1.default.createElement("td", null, g.groupName),
383
+ react_1.default.createElement("td", null,
384
+ react_1.default.createElement("span", { className: `badge bg-${g.context === 'home' ? 'success' : 'secondary'}` }, g.context)),
385
+ react_1.default.createElement("td", { className: "text-end" }, g.taskCount.toLocaleString()),
386
+ react_1.default.createElement("td", { className: "text-end" }, g.activeCount.toLocaleString()),
387
+ react_1.default.createElement("td", { className: "text-end" }, g.doneCount.toLocaleString()),
388
+ react_1.default.createElement("td", { className: "text-end" }, g.logEntryCount.toLocaleString())))))))));
389
+ };
390
+ const showLogoutInSettings = typeof window !== 'undefined'
391
+ && !window.electronAPI
392
+ && !window.ReactNativeWebView;
393
+ const LogoutSection = () => {
394
+ if (!showLogoutInSettings)
395
+ return null;
396
+ return (react_1.default.createElement("div", { className: "mt-4 pt-3 border-top" },
397
+ react_1.default.createElement("button", { className: "btn btn-outline-danger btn-sm", onClick: async () => {
398
+ const confirmed = confirm('Are you sure you want to logout? This will clear your credentials from this browser. ' +
399
+ 'Make sure you have your User ID and Secret Key saved securely, or you will lose access to your account.');
400
+ if (!confirmed)
401
+ return;
402
+ await peers_sdk_1.rpcServerCalls.logout();
403
+ } }, "Logout")));
404
+ };
@@ -83,6 +83,7 @@ function TabsLayoutApp() {
83
83
  return;
84
84
  }
85
85
  if (!userId) {
86
+ document.getElementById('appLoadingDiv')?.remove();
86
87
  return react_1.default.createElement(setup_user_1.SetupUser, null);
87
88
  }
88
89
  else {
@@ -96,14 +97,13 @@ function TabsLayout(props) {
96
97
  userContextInitialized = true;
97
98
  }
98
99
  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
100
+ await (0, globals_1.loadGlobals)();
101
+ await (0, routes_loader_1.loadAllRoutes)();
102
102
  await Promise.all([
103
103
  tabs_state_1.activeTabs.loadingPromise,
104
104
  tabs_state_1.activeTabId.loadingPromise,
105
105
  tabs_state_1.recentlyUsedApps.loadingPromise,
106
- peers_sdk_1.hasShownWelcomeModal.loadingPromise
106
+ peers_sdk_1.hasShownWelcomeModal.loadingPromise,
107
107
  ]);
108
108
  return true;
109
109
  });
@@ -422,10 +422,13 @@ function AppSection({ title, iconClassName, apps, onOpenApp, isMobile }) {
422
422
  function AppCard({ appItem, onOpenApp, isMobile }) {
423
423
  const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
424
424
  const isDark = _colorMode === 'dark';
425
- return (react_1.default.createElement("div", { className: "d-flex flex-column align-items-center text-center", style: {
425
+ return (react_1.default.createElement("button", { className: "d-flex flex-column align-items-center text-center", style: {
426
426
  cursor: 'pointer',
427
427
  width: isMobile ? '80px' : '90px',
428
- transition: 'all 0.15s ease'
428
+ transition: 'all 0.15s ease',
429
+ background: 'none',
430
+ border: 'none',
431
+ padding: 0,
429
432
  }, title: appItem.name, onMouseEnter: (e) => {
430
433
  e.currentTarget.style.transform = 'scale(1.05)';
431
434
  }, onMouseLeave: (e) => {
@@ -442,7 +445,7 @@ function AppCard({ appItem, onOpenApp, isMobile }) {
442
445
  } },
443
446
  react_1.default.createElement("i", { className: appItem.iconClassName, style: {
444
447
  fontSize: isMobile ? '28px' : '32px',
445
- color: isDark ? '#0d6efd' : '#0d6efd' // Keep primary blue for both themes
448
+ color: isDark ? '#0d6efd' : '#0d6efd'
446
449
  } })),
447
450
  react_1.default.createElement("span", { className: isDark ? 'text-light' : 'text-dark', style: {
448
451
  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.1",
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.1",
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.1",
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
 
@@ -47,7 +47,7 @@ export function LazyList<T>(props: IProps<T>) {
47
47
  if (renderItems.length < 50 && !allLoaded && !loading() && !isLoadingSync.current) {
48
48
  loadMore();
49
49
  }
50
- }, [renderItems.length, allLoaded, loading()]);
50
+ }, [renderItems.length, allLoaded]);
51
51
 
52
52
  useEffect(() => {
53
53
  itemsObsAry([]);
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
+ }
@@ -28,10 +28,17 @@ function applyColorMode(modePreference?: ColorModePreference): ColorMode {
28
28
  }
29
29
  document.documentElement.setAttribute('data-bs-theme', mode);
30
30
  if (mode === 'light') {
31
+ document.documentElement.style.backgroundColor = '';
31
32
  document.body.style.backgroundColor = 'initial';
32
33
  } else {
34
+ document.documentElement.style.backgroundColor = 'rgb(33, 37, 41)';
33
35
  document.body.style.backgroundColor = 'rgb(33, 37, 41)';
34
36
  }
37
+
38
+ const themeMeta = document.querySelector('meta[name="theme-color"]');
39
+ if (themeMeta) {
40
+ themeMeta.setAttribute('content', mode === 'light' ? '#ffffff' : '#212529');
41
+ }
35
42
  colorMode(mode);
36
43
  colorMode.notifySubscribers();
37
44
 
@@ -1,5 +1,5 @@
1
1
  import { autoUpdatePeersCore, Devices, getUserContext, IDevice, packagesRootDir, reloadPackagesOnPageRefresh, rpcServerCalls, thisDeviceId, TrustLevel, Users } from "@peers-app/peers-sdk";
2
- import React, { useEffect } from 'react';
2
+ import React, { useEffect, useRef, useState } from 'react';
3
3
  import { Input } from '../../components/input';
4
4
  import { Tooltip } from '../../components/tooltip';
5
5
  import { Tabs } from '../../components/tabs';
@@ -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
 
@@ -58,6 +61,7 @@ const AdvancedSettingsTab: React.FC = () => {
58
61
  <AutoUpdatePeersCore />
59
62
  <ResetDeviceSyncInfos />
60
63
  <DeleteLocalDatabase />
64
+ <ImportOldPeersData />
61
65
  </>
62
66
  );
63
67
  };
@@ -319,4 +323,233 @@ const DeleteLocalDatabase: React.FC = () => {
319
323
  </button>
320
324
  </div>
321
325
  );
326
+ }
327
+
328
+ const IMPORT_TOOL_ID = '00mh0wlipkdbeaw8imptsk001';
329
+
330
+ interface IDryRunResult {
331
+ totalRecords: number;
332
+ totalTasks: number;
333
+ totalLogEntries: number;
334
+ groups: {
335
+ groupId: string;
336
+ groupName: string;
337
+ context: 'personal' | 'home';
338
+ taskCount: number;
339
+ activeCount: number;
340
+ doneCount: number;
341
+ rootTaskCount: number;
342
+ subtaskCount: number;
343
+ logEntryCount: number;
344
+ }[];
345
+ statusMapping: {
346
+ inProgressToDone: number;
347
+ inProgressKeep: number;
348
+ queuedToDone: number;
349
+ queuedKeep: number;
350
+ backlogToDone: number;
351
+ backlogKeep: number;
352
+ noStatusToDone: number;
353
+ noStatusToBacklog: number;
354
+ };
355
+ userMapping: {
356
+ mark: number;
357
+ blair: number;
358
+ other: number;
359
+ };
360
+ warnings: string[];
361
+ }
362
+
363
+ const ImportOldPeersData: React.FC = () => {
364
+ const fileInputRef = useRef<HTMLInputElement>(null);
365
+ const [filePath, setFilePath] = useState<string>('');
366
+ const [fileName, setFileName] = useState<string>('');
367
+ const [loading, setLoading] = useState(false);
368
+ const [result, setResult] = useState<IDryRunResult | null>(null);
369
+ const [error, setError] = useState<string>('');
370
+
371
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
372
+ const file = e.target.files?.[0];
373
+ if (!file) return;
374
+ // Electron exposes .path on File objects
375
+ const path = (file as any).path as string | undefined;
376
+ if (path) {
377
+ setFilePath(path);
378
+ setFileName(file.name);
379
+ } else {
380
+ setFileName(file.name);
381
+ setFilePath('');
382
+ setError('File path not available. This feature requires the Electron desktop app.');
383
+ }
384
+ setResult(null);
385
+ setError('');
386
+ };
387
+
388
+ const handleDryRun = async () => {
389
+ if (!filePath) return;
390
+ setLoading(true);
391
+ setError('');
392
+ setResult(null);
393
+ try {
394
+ const response = await rpcServerCalls.runTool(IMPORT_TOOL_ID, {
395
+ filePath,
396
+ dryRun: true,
397
+ });
398
+ if (response?.result) {
399
+ setResult(response.result);
400
+ } else {
401
+ setError('Unexpected response from import tool');
402
+ }
403
+ } catch (err) {
404
+ setError((err as Error).message || 'Failed to run dry run');
405
+ } finally {
406
+ setLoading(false);
407
+ }
408
+ };
409
+
410
+ return (
411
+ <div className="mt-4 pt-3 border-top">
412
+ <h6 className="mb-2">Import Old Peers Data</h6>
413
+ <small className="text-muted d-block mb-2">
414
+ Import tasks and log entries from an old peers JSON export file.
415
+ </small>
416
+
417
+ <div className="d-flex align-items-center gap-2 mb-2">
418
+ <input
419
+ ref={fileInputRef}
420
+ type="file"
421
+ accept=".json"
422
+ className="form-control form-control-sm"
423
+ style={{ maxWidth: 350 }}
424
+ onChange={handleFileSelect}
425
+ />
426
+ <button
427
+ className="btn btn-outline-primary btn-sm"
428
+ onClick={handleDryRun}
429
+ disabled={!filePath || loading}
430
+ >
431
+ {loading ? 'Analyzing...' : 'Dry Run'}
432
+ </button>
433
+ </div>
434
+
435
+ {error && (
436
+ <div className="alert alert-danger py-1 px-2 small mt-2">{error}</div>
437
+ )}
438
+
439
+ {result && <DryRunResults result={result} />}
440
+ </div>
441
+ );
442
+ };
443
+
444
+ const DryRunResults: React.FC<{ result: IDryRunResult }> = ({ result }) => {
445
+ const sm = result.statusMapping;
446
+ const um = result.userMapping;
447
+
448
+ return (
449
+ <div className="mt-2">
450
+ <div className="alert alert-info py-2 px-3 small">
451
+ <strong>Summary:</strong> {result.totalTasks.toLocaleString()} tasks
452
+ and {result.totalLogEntries.toLocaleString()} log entries
453
+ across {result.groups.length} groups
454
+ ({result.totalRecords.toLocaleString()} total records in file)
455
+ </div>
456
+
457
+ {result.warnings.length > 0 && (
458
+ <div className="alert alert-warning py-2 px-3 small">
459
+ <strong>Warnings:</strong>
460
+ <ul className="mb-0 ps-3">
461
+ {result.warnings.map((w, i) => <li key={i}>{w}</li>)}
462
+ </ul>
463
+ </div>
464
+ )}
465
+
466
+ <details className="mb-2">
467
+ <summary className="small fw-bold" style={{ cursor: 'pointer' }}>
468
+ Status Mapping
469
+ </summary>
470
+ <table className="table table-sm small mt-1">
471
+ <thead><tr><th>Mapping</th><th className="text-end">Count</th></tr></thead>
472
+ <tbody>
473
+ <tr><td>In-Progress → Done</td><td className="text-end">{sm.inProgressToDone.toLocaleString()}</td></tr>
474
+ <tr><td>In-Progress → In-Progress</td><td className="text-end">{sm.inProgressKeep.toLocaleString()}</td></tr>
475
+ <tr><td>Queued → Done</td><td className="text-end">{sm.queuedToDone.toLocaleString()}</td></tr>
476
+ <tr><td>Queued → Queued</td><td className="text-end">{sm.queuedKeep.toLocaleString()}</td></tr>
477
+ <tr><td>Backlog → Done</td><td className="text-end">{sm.backlogToDone.toLocaleString()}</td></tr>
478
+ <tr><td>Backlog → Backlog</td><td className="text-end">{sm.backlogKeep.toLocaleString()}</td></tr>
479
+ <tr><td>No status → Done</td><td className="text-end">{sm.noStatusToDone.toLocaleString()}</td></tr>
480
+ <tr><td>No status → Backlog</td><td className="text-end">{sm.noStatusToBacklog.toLocaleString()}</td></tr>
481
+ </tbody>
482
+ </table>
483
+ </details>
484
+
485
+ <details className="mb-2">
486
+ <summary className="small fw-bold" style={{ cursor: 'pointer' }}>
487
+ User Mapping
488
+ </summary>
489
+ <table className="table table-sm small mt-1">
490
+ <thead><tr><th>User</th><th className="text-end">Tasks</th></tr></thead>
491
+ <tbody>
492
+ <tr><td>Mark</td><td className="text-end">{um.mark.toLocaleString()}</td></tr>
493
+ <tr><td>Blair</td><td className="text-end">{um.blair.toLocaleString()}</td></tr>
494
+ <tr><td>Other → Mark</td><td className="text-end">{um.other.toLocaleString()}</td></tr>
495
+ </tbody>
496
+ </table>
497
+ </details>
498
+
499
+ <details open className="mb-2">
500
+ <summary className="small fw-bold" style={{ cursor: 'pointer' }}>
501
+ Groups ({result.groups.length})
502
+ </summary>
503
+ <table className="table table-sm small mt-1">
504
+ <thead>
505
+ <tr>
506
+ <th>Group</th>
507
+ <th>Route</th>
508
+ <th className="text-end">Tasks</th>
509
+ <th className="text-end">Active</th>
510
+ <th className="text-end">Done</th>
511
+ <th className="text-end">Logs</th>
512
+ </tr>
513
+ </thead>
514
+ <tbody>
515
+ {result.groups.map(g => (
516
+ <tr key={g.groupId}>
517
+ <td>{g.groupName}</td>
518
+ <td><span className={`badge bg-${g.context === 'home' ? 'success' : 'secondary'}`}>{g.context}</span></td>
519
+ <td className="text-end">{g.taskCount.toLocaleString()}</td>
520
+ <td className="text-end">{g.activeCount.toLocaleString()}</td>
521
+ <td className="text-end">{g.doneCount.toLocaleString()}</td>
522
+ <td className="text-end">{g.logEntryCount.toLocaleString()}</td>
523
+ </tr>
524
+ ))}
525
+ </tbody>
526
+ </table>
527
+ </details>
528
+ </div>
529
+ );
530
+ };
531
+
532
+ const showLogoutInSettings = typeof window !== 'undefined'
533
+ && !(window as any).electronAPI
534
+ && !(window as any).ReactNativeWebView;
535
+
536
+ const LogoutSection: React.FC = () => {
537
+ if (!showLogoutInSettings) return null;
538
+ return (
539
+ <div className="mt-4 pt-3 border-top">
540
+ <button
541
+ className="btn btn-outline-danger btn-sm"
542
+ onClick={async () => {
543
+ const confirmed = confirm(
544
+ 'Are you sure you want to logout? This will clear your credentials from this browser. ' +
545
+ 'Make sure you have your User ID and Secret Key saved securely, or you will lose access to your account.'
546
+ );
547
+ if (!confirmed) return;
548
+ await rpcServerCalls.logout();
549
+ }}
550
+ >
551
+ Logout
552
+ </button>
553
+ </div>
554
+ );
322
555
  }
@@ -73,6 +73,7 @@ export function TabsLayoutApp() {
73
73
  }
74
74
 
75
75
  if (!userId) {
76
+ document.getElementById('appLoadingDiv')?.remove();
76
77
  return <SetupUser />;
77
78
  } else {
78
79
  return <TabsLayout userId={userId} />;
@@ -87,14 +88,13 @@ export function TabsLayout(props: { userId: string }) {
87
88
  }
88
89
 
89
90
  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
91
+ await loadGlobals();
92
+ await loadAllRoutes();
93
93
  await Promise.all([
94
94
  activeTabs.loadingPromise,
95
95
  activeTabId.loadingPromise,
96
96
  recentlyUsedApps.loadingPromise,
97
- hasShownWelcomeModal.loadingPromise
97
+ hasShownWelcomeModal.loadingPromise,
98
98
  ]);
99
99
  return true;
100
100
  });
@@ -730,12 +730,15 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
730
730
  const isDark = _colorMode === 'dark';
731
731
 
732
732
  return (
733
- <div
733
+ <button
734
734
  className="d-flex flex-column align-items-center text-center"
735
735
  style={{
736
736
  cursor: 'pointer',
737
737
  width: isMobile ? '80px' : '90px',
738
- transition: 'all 0.15s ease'
738
+ transition: 'all 0.15s ease',
739
+ background: 'none',
740
+ border: 'none',
741
+ padding: 0,
739
742
  }}
740
743
  title={appItem.name}
741
744
  onMouseEnter={(e) => {
@@ -746,7 +749,6 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
746
749
  }}
747
750
  onClick={() => onOpenApp(appItem)}
748
751
  >
749
- {/* Icon Container */}
750
752
  <div
751
753
  className="d-flex align-items-center justify-content-center mb-2"
752
754
  style={{
@@ -763,12 +765,11 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
763
765
  className={appItem.iconClassName}
764
766
  style={{
765
767
  fontSize: isMobile ? '28px' : '32px',
766
- color: isDark ? '#0d6efd' : '#0d6efd' // Keep primary blue for both themes
768
+ color: isDark ? '#0d6efd' : '#0d6efd'
767
769
  }}
768
770
  />
769
771
  </div>
770
772
 
771
- {/* Title */}
772
773
  <span
773
774
  className={isDark ? 'text-light' : 'text-dark'}
774
775
  style={{
@@ -783,6 +784,6 @@ function AppCard({ appItem, onOpenApp, isMobile }: AppCardProps) {
783
784
  >
784
785
  {appItem.displayName}
785
786
  </span>
786
- </div>
787
+ </button>
787
788
  );
788
789
  }
@@ -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,