@bitpub/cli 2.1.3 → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/browser.js +178 -0
- package/src/commands/group.js +40 -3
- package/static/console.html +185 -4
- package/static/welcome.html +705 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpub/cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
4
4
|
"description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bitpub": "./bin/bitpub.js"
|
package/src/commands/browser.js
CHANGED
|
@@ -22,6 +22,7 @@ const { readConfig, BITPUB_DIR } = require('../config');
|
|
|
22
22
|
const { getSyncedNamespaces, initCache } = require('../db/cache');
|
|
23
23
|
const { isPrivateHcu, decrypt, isEncrypted } = require('../crypto');
|
|
24
24
|
const { runOneShot: runSyncOneShot } = require('./sync');
|
|
25
|
+
const { _internals: groupInternals } = require('./group');
|
|
25
26
|
|
|
26
27
|
const Database = require('better-sqlite3');
|
|
27
28
|
const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
|
|
@@ -489,6 +490,149 @@ function handleBridgeSync(req, res) {
|
|
|
489
490
|
});
|
|
490
491
|
}
|
|
491
492
|
|
|
493
|
+
/* ── Groups (browser chrome) ─────────────────────────────────────
|
|
494
|
+
*
|
|
495
|
+
* Three small JSON endpoints that back the "Make a group" / "Your
|
|
496
|
+
* groups" UI in the Welcome slice (and anywhere else in the browser
|
|
497
|
+
* chrome that wants to read/write group membership).
|
|
498
|
+
*
|
|
499
|
+
* Like /bridge/sync, this lives outside the manifest/apps system —
|
|
500
|
+
* group management is part of the browser shell itself, not something
|
|
501
|
+
* a user-published app should be able to do unprompted.
|
|
502
|
+
*
|
|
503
|
+
* Wire format:
|
|
504
|
+
* POST /bridge/group/create { display }
|
|
505
|
+
* → { ok, group: { slug, display, hosted_on, base_url, role, joined_at }, invite_url }
|
|
506
|
+
*
|
|
507
|
+
* GET /bridge/group/list
|
|
508
|
+
* → { ok, groups: [{ slug, display, address, role, joined_at, api_url }] }
|
|
509
|
+
*
|
|
510
|
+
* POST /bridge/group/invite { slug, rotate }
|
|
511
|
+
* rotate=false (default) → GET the current active invite from the
|
|
512
|
+
* server (server is source of truth);
|
|
513
|
+
* returns { invite_url, rotated: false }.
|
|
514
|
+
* invite_url is null if every invite has
|
|
515
|
+
* been revoked and not yet replaced.
|
|
516
|
+
* rotate=true → POST a new invite (invalidates the prior one);
|
|
517
|
+
* returns { invite_url, rotated: true }.
|
|
518
|
+
*
|
|
519
|
+
* No local invite-URL caching: every read is a live server call. Group
|
|
520
|
+
* operations require network connectivity by design (create/join/leave
|
|
521
|
+
* all hit the server), so making the read path also network-bound is
|
|
522
|
+
* consistent and avoids sync hazards (rotate from machine A, stale
|
|
523
|
+
* cache on machine B).
|
|
524
|
+
*/
|
|
525
|
+
function jsonBody(req) {
|
|
526
|
+
return new Promise((resolve, reject) => {
|
|
527
|
+
let buf = '';
|
|
528
|
+
req.setEncoding('utf-8');
|
|
529
|
+
req.on('data', (c) => {
|
|
530
|
+
buf += c;
|
|
531
|
+
// Reject oversized bodies early — group endpoints take small JSON only.
|
|
532
|
+
if (buf.length > 16 * 1024) { reject(new Error('body too large')); req.destroy(); }
|
|
533
|
+
});
|
|
534
|
+
req.on('end', () => {
|
|
535
|
+
if (!buf) return resolve({});
|
|
536
|
+
try { resolve(JSON.parse(buf)); }
|
|
537
|
+
catch (err) { reject(new Error(`invalid JSON: ${err.message}`)); }
|
|
538
|
+
});
|
|
539
|
+
req.on('error', reject);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function sendJson(res, status, payload) {
|
|
544
|
+
res.statusCode = status;
|
|
545
|
+
res.setHeader('Content-Type', 'application/json');
|
|
546
|
+
res.end(JSON.stringify(payload));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function handleBridgeGroupCreate(req, res) {
|
|
550
|
+
let body;
|
|
551
|
+
try { body = await jsonBody(req); }
|
|
552
|
+
catch (err) { return sendJson(res, 400, { ok: false, error: err.message }); }
|
|
553
|
+
|
|
554
|
+
const display = String((body && body.display) || '').trim();
|
|
555
|
+
if (!display) return sendJson(res, 400, { ok: false, error: 'display name required' });
|
|
556
|
+
if (display.length > 80) return sendJson(res, 400, { ok: false, error: 'display name too long (max 80 chars)' });
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
// createGroup itself caches invite_url locally (see group.js) so
|
|
560
|
+
// we don't need to do it here too.
|
|
561
|
+
const out = await groupInternals.createGroup(display);
|
|
562
|
+
sendJson(res, 200, {
|
|
563
|
+
ok: true,
|
|
564
|
+
group: {
|
|
565
|
+
slug: out.slug,
|
|
566
|
+
display: out.display,
|
|
567
|
+
hosted_on: out.hosted_on || null,
|
|
568
|
+
base_url: out.base_url || null,
|
|
569
|
+
role: 'owner',
|
|
570
|
+
},
|
|
571
|
+
invite_url: out.invite_url || null,
|
|
572
|
+
});
|
|
573
|
+
} catch (err) {
|
|
574
|
+
const msg = (err && err.message) || String(err);
|
|
575
|
+
sendJson(res, 500, { ok: false, error: msg });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function handleBridgeGroupList(_req, res) {
|
|
580
|
+
const cfg = readConfig() || {};
|
|
581
|
+
const groups = Array.isArray(cfg.groups) ? cfg.groups : [];
|
|
582
|
+
const out = groups.map((g) => ({
|
|
583
|
+
slug: g.slug,
|
|
584
|
+
display: g.display || g.slug,
|
|
585
|
+
address: `bitpub://group:${g.slug}/`,
|
|
586
|
+
role: g.role || null,
|
|
587
|
+
joined_at: g.joined_at || null,
|
|
588
|
+
api_url: g.api_url || null,
|
|
589
|
+
}));
|
|
590
|
+
sendJson(res, 200, { ok: true, groups: out });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function handleBridgeGroupInvite(req, res) {
|
|
594
|
+
let body;
|
|
595
|
+
try { body = await jsonBody(req); }
|
|
596
|
+
catch (err) { return sendJson(res, 400, { ok: false, error: err.message }); }
|
|
597
|
+
|
|
598
|
+
const slug = String((body && body.slug) || '').trim();
|
|
599
|
+
const rotate = !!(body && body.rotate);
|
|
600
|
+
if (!slug) return sendJson(res, 400, { ok: false, error: 'slug required' });
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
if (rotate) {
|
|
604
|
+
const out = await groupInternals.rotateInvite(slug);
|
|
605
|
+
return sendJson(res, 200, {
|
|
606
|
+
ok: true,
|
|
607
|
+
invite_url: out.invite_url,
|
|
608
|
+
rotated: true,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
// Read path — server is the source of truth (GET /v1/groups/:slug/invites).
|
|
612
|
+
// Returns { invite_url: null } if every invite has been revoked.
|
|
613
|
+
const out = await groupInternals.showInvite(slug);
|
|
614
|
+
sendJson(res, 200, {
|
|
615
|
+
ok: true,
|
|
616
|
+
invite_url: out.invite_url || null,
|
|
617
|
+
rotated: false,
|
|
618
|
+
});
|
|
619
|
+
} catch (err) {
|
|
620
|
+
const raw = (err && err.message) || String(err);
|
|
621
|
+
// httpJson wraps upstream errors as "[STATUS] message". Pluck the
|
|
622
|
+
// status so we can return a matching HTTP code, and strip the
|
|
623
|
+
// prefix from the message so the UI renders just the human part.
|
|
624
|
+
const m = /^\[(\d{3})\]\s*(.*)$/.exec(raw);
|
|
625
|
+
const status = m ? Number(m[1]) : 500;
|
|
626
|
+
let message = m ? m[2] : raw;
|
|
627
|
+
if (status === 404 && /not found/i.test(message)) {
|
|
628
|
+
// Most common reason today: the running backend hasn't shipped
|
|
629
|
+
// GET /v1/groups/:slug/invites yet. Make that diagnosable.
|
|
630
|
+
message = 'This backend does not support reading invites (upgrade required).';
|
|
631
|
+
}
|
|
632
|
+
sendJson(res, status, { ok: false, error: message });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
492
636
|
function openInBrowser(url) {
|
|
493
637
|
const cmd = process.platform === 'darwin' ? 'open' :
|
|
494
638
|
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -545,15 +689,34 @@ function startBrowserServer(opts = {}) {
|
|
|
545
689
|
|
|
546
690
|
if (url.pathname === '/api/data') {
|
|
547
691
|
res.setHeader('Content-Type', 'application/json');
|
|
692
|
+
// Re-read the config on every /api/data hit. The captured
|
|
693
|
+
// `cfg` above is set once at server start, but groups (and
|
|
694
|
+
// other config) can change mid-session — e.g. after the
|
|
695
|
+
// Welcome UI calls /bridge/group/create. Without this we'd
|
|
696
|
+
// serve a stale known_groups list and the new group wouldn't
|
|
697
|
+
// show in the sidebar until the user restarted `bitpub
|
|
698
|
+
// browser`. We fall back to the boot cfg for the key/domain
|
|
699
|
+
// fields, which don't change at runtime.
|
|
700
|
+
const liveCfg = readConfig() || cfg || {};
|
|
548
701
|
const slices = getAllSlices().map(s => {
|
|
549
702
|
if (cfg && cfg.api_key) return decryptSlice(s, cfg.api_key);
|
|
550
703
|
return s;
|
|
551
704
|
});
|
|
705
|
+
// known_groups: every group in the user's local config, even
|
|
706
|
+
// groups that contain zero slices yet. Lets the sidebar tree
|
|
707
|
+
// render a freshly-created group address as an empty top-level
|
|
708
|
+
// node — without this, `bitpub group create` would succeed
|
|
709
|
+
// but nothing visible would change until the first slice
|
|
710
|
+
// landed under that namespace.
|
|
711
|
+
const knownGroups = Array.isArray(liveCfg.groups)
|
|
712
|
+
? liveCfg.groups.map((g) => ({ slug: g.slug, display: g.display || g.slug }))
|
|
713
|
+
: [];
|
|
552
714
|
res.end(JSON.stringify({
|
|
553
715
|
mode: 'local',
|
|
554
716
|
domain: (cfg && cfg.domain) || '',
|
|
555
717
|
slices,
|
|
556
718
|
synced_namespaces: getSyncedNamespaces(),
|
|
719
|
+
known_groups: knownGroups,
|
|
557
720
|
}));
|
|
558
721
|
return;
|
|
559
722
|
}
|
|
@@ -601,6 +764,21 @@ function startBrowserServer(opts = {}) {
|
|
|
601
764
|
return;
|
|
602
765
|
}
|
|
603
766
|
|
|
767
|
+
// Bridge: group management for the Welcome UI (create, list,
|
|
768
|
+
// invite). Same chrome-not-app reasoning as /bridge/sync.
|
|
769
|
+
if (url.pathname === '/bridge/group/create' && req.method === 'POST') {
|
|
770
|
+
handleBridgeGroupCreate(req, res);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (url.pathname === '/bridge/group/list' && req.method === 'GET') {
|
|
774
|
+
handleBridgeGroupList(req, res);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (url.pathname === '/bridge/group/invite' && req.method === 'POST') {
|
|
778
|
+
handleBridgeGroupInvite(req, res);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
604
782
|
// Bridge: deterministic namespace read. Apps call this to fetch
|
|
605
783
|
// any slices that match an HCU pattern (exact or prefix-with-/*).
|
|
606
784
|
// No LLM, no MCP, no auth in the loop — this is the read path
|
package/src/commands/group.js
CHANGED
|
@@ -99,6 +99,12 @@ async function createGroup(display) {
|
|
|
99
99
|
// the right backend with the same personal key (creator stays on
|
|
100
100
|
// their own key; the per-group-key mint happens at *accept* time, not
|
|
101
101
|
// create time, since the creator already has a key on this backend).
|
|
102
|
+
//
|
|
103
|
+
// We deliberately do NOT cache invite_url here. The server is the
|
|
104
|
+
// source of truth — `bitpub group invite <slug> --show` (and the
|
|
105
|
+
// browser-chrome `/bridge/group/invite` endpoint) re-fetch it via
|
|
106
|
+
// `listInvites` below whenever needed. Caching it would just be a
|
|
107
|
+
// sync hazard (rotate from another machine, lose the cache, etc.).
|
|
102
108
|
upsertGroupEntry({
|
|
103
109
|
slug: data.slug,
|
|
104
110
|
display: data.display,
|
|
@@ -112,6 +118,23 @@ async function createGroup(display) {
|
|
|
112
118
|
return { ...data, invite_url: data.invite_url, base_url: baseUrl };
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// ── group invite show (read current invite from the server) ────────────────
|
|
122
|
+
|
|
123
|
+
async function showInvite(slug) {
|
|
124
|
+
const config = requireConfig();
|
|
125
|
+
const entry = groupEntryFor(config, slug);
|
|
126
|
+
if (!entry) {
|
|
127
|
+
throw new Error(`Not a member of group "${slug}". Run \`bitpub group list\` to see your groups.`);
|
|
128
|
+
}
|
|
129
|
+
const baseUrl = trimSlash(entry.api_url) || primaryApiUrl(config);
|
|
130
|
+
// GET /v1/groups/:slug/invites returns the current active invite for
|
|
131
|
+
// members. Server is the source of truth — we don't cache.
|
|
132
|
+
const data = await httpJson('GET', `${baseUrl}/v1/groups/${encodeURIComponent(slug)}/invites`, {
|
|
133
|
+
headers: { 'x-api-key': entry.key },
|
|
134
|
+
});
|
|
135
|
+
return data; // { slug, display, invite_url, created_at }
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
// ── group list ─────────────────────────────────────────────────────────────
|
|
116
139
|
|
|
117
140
|
async function listGroupsLocal() {
|
|
@@ -128,7 +151,10 @@ async function rotateInvite(slug, { show } = {}) {
|
|
|
128
151
|
throw new Error(`Not a member of group "${slug}". Run \`bitpub group list\` to see your groups.`);
|
|
129
152
|
}
|
|
130
153
|
if (show) {
|
|
131
|
-
|
|
154
|
+
// `--show` reads the current invite without rotating. Implemented
|
|
155
|
+
// by GET /v1/groups/:slug/invites (members-only) above; same
|
|
156
|
+
// endpoint the Welcome UI's lazy "Copy invite link" calls.
|
|
157
|
+
return showInvite(slug);
|
|
132
158
|
}
|
|
133
159
|
const baseUrl = trimSlash(entry.api_url) || primaryApiUrl(config);
|
|
134
160
|
const data = await httpJson('POST', `${baseUrl}/v1/groups/${encodeURIComponent(slug)}/invites`, {
|
|
@@ -259,11 +285,21 @@ module.exports = function registerGroup(program) {
|
|
|
259
285
|
|
|
260
286
|
group
|
|
261
287
|
.command('invite <slug>')
|
|
262
|
-
.description('
|
|
263
|
-
.option('--show', '
|
|
288
|
+
.description('Show or rotate the shareable invite link for a group')
|
|
289
|
+
.option('--show', 'Show the current link without rotating it (read-only)')
|
|
264
290
|
.action(async (slug, opts) => {
|
|
265
291
|
try {
|
|
266
292
|
const out = await rotateInvite(slug, opts);
|
|
293
|
+
if (opts.show) {
|
|
294
|
+
if (out.invite_url) {
|
|
295
|
+
console.log(`\n Share link for ${out.display || out.slug}:`);
|
|
296
|
+
console.log(` ${out.invite_url}`);
|
|
297
|
+
} else {
|
|
298
|
+
console.log(`\n No active invite link for ${out.display || out.slug}.`);
|
|
299
|
+
console.log(` Mint one: bitpub group invite ${out.slug}`);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
267
303
|
console.log(`\n✓ Rotated invite for ${out.display || out.slug}`);
|
|
268
304
|
console.log(`\n New share link:`);
|
|
269
305
|
console.log(` ${out.invite_url}`);
|
|
@@ -329,5 +365,6 @@ module.exports._internals = {
|
|
|
329
365
|
createGroup,
|
|
330
366
|
joinFromInvite,
|
|
331
367
|
rotateInvite,
|
|
368
|
+
showInvite,
|
|
332
369
|
leaveGroup,
|
|
333
370
|
};
|
package/static/console.html
CHANGED
|
@@ -1017,6 +1017,7 @@ const S = {
|
|
|
1017
1017
|
stats: {},
|
|
1018
1018
|
mode: 'remote',
|
|
1019
1019
|
domain: '',
|
|
1020
|
+
knownGroups: [],
|
|
1020
1021
|
apiKey: sessionStorage.getItem('bitpub_key'),
|
|
1021
1022
|
raw: false,
|
|
1022
1023
|
searchQuery: '',
|
|
@@ -1205,7 +1206,8 @@ function ingestData(data) {
|
|
|
1205
1206
|
S.slices = data.slices.map(parseSlice);
|
|
1206
1207
|
S.mode = data.mode || 'remote';
|
|
1207
1208
|
S.domain = data.domain || '';
|
|
1208
|
-
S.
|
|
1209
|
+
S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : [];
|
|
1210
|
+
S.tree = buildTree(S.slices, S.knownGroups);
|
|
1209
1211
|
S.stats = computeStats(S.slices);
|
|
1210
1212
|
S.lastUpdated = maxTs(S.slices);
|
|
1211
1213
|
}
|
|
@@ -1254,7 +1256,8 @@ async function pollData() {
|
|
|
1254
1256
|
|
|
1255
1257
|
S.slices = nextSlices;
|
|
1256
1258
|
S.mode = data.mode || S.mode;
|
|
1257
|
-
S.
|
|
1259
|
+
S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : S.knownGroups;
|
|
1260
|
+
S.tree = buildTree(S.slices, S.knownGroups);
|
|
1258
1261
|
S.stats = computeStats(S.slices);
|
|
1259
1262
|
S.lastUpdated = maxTs(S.slices);
|
|
1260
1263
|
S.newArrivals = arrivals;
|
|
@@ -1345,7 +1348,7 @@ function pathOf(hcu) {
|
|
|
1345
1348
|
return (m && m[1]) ? m[1].replace(/^\//, '').split('/') : [];
|
|
1346
1349
|
}
|
|
1347
1350
|
|
|
1348
|
-
function buildTree(slices) {
|
|
1351
|
+
function buildTree(slices, knownGroups) {
|
|
1349
1352
|
const tree = {};
|
|
1350
1353
|
for (const s of slices) {
|
|
1351
1354
|
const { scope, type } = scopeOf(s.hcu);
|
|
@@ -1358,6 +1361,17 @@ function buildTree(slices) {
|
|
|
1358
1361
|
if (i === segs.length - 1) node.slice = s;
|
|
1359
1362
|
}
|
|
1360
1363
|
}
|
|
1364
|
+
// Seed empty top-level nodes for groups the user belongs to but
|
|
1365
|
+
// hasn't put any slices in yet. The tree is otherwise slice-driven,
|
|
1366
|
+
// so brand-new groups would be invisible in the sidebar until the
|
|
1367
|
+
// first save — confusing right after `bitpub group create`.
|
|
1368
|
+
if (Array.isArray(knownGroups)) {
|
|
1369
|
+
for (const g of knownGroups) {
|
|
1370
|
+
if (!g || !g.slug) continue;
|
|
1371
|
+
const scope = `group:${g.slug}`;
|
|
1372
|
+
if (!tree[scope]) tree[scope] = { type: 'group', children: {}, count: 0 };
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1361
1375
|
return tree;
|
|
1362
1376
|
}
|
|
1363
1377
|
|
|
@@ -1592,7 +1606,8 @@ async function runFullSync() {
|
|
|
1592
1606
|
if (!oldKey.has(k)) arrivals.add(s.hcu);
|
|
1593
1607
|
}
|
|
1594
1608
|
S.slices = nextSlices;
|
|
1595
|
-
S.
|
|
1609
|
+
S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : S.knownGroups;
|
|
1610
|
+
S.tree = buildTree(S.slices, S.knownGroups);
|
|
1596
1611
|
S.stats = computeStats(S.slices);
|
|
1597
1612
|
S.lastUpdated = maxTs(S.slices);
|
|
1598
1613
|
S.newArrivals = arrivals;
|
|
@@ -2798,6 +2813,23 @@ const BRIDGE_SHIM_SOURCE = String.raw`
|
|
|
2798
2813
|
private_prefix: d.private_prefix,
|
|
2799
2814
|
});
|
|
2800
2815
|
else ih.reject(new Error(d.error || 'identity lookup failed'));
|
|
2816
|
+
} else if (d.type === 'bitpub.groups.result') {
|
|
2817
|
+
var gh = readHandlers.get(d.requestId);
|
|
2818
|
+
if (!gh) return;
|
|
2819
|
+
readHandlers.delete(d.requestId);
|
|
2820
|
+
if (d.ok) {
|
|
2821
|
+
// Three response shapes share one event for symmetry with how
|
|
2822
|
+
// the parent router replies. Callers just see their op's keys.
|
|
2823
|
+
var payload = {};
|
|
2824
|
+
if (typeof d.group !== 'undefined') payload.group = d.group;
|
|
2825
|
+
if (typeof d.invite_url!== 'undefined') payload.invite_url = d.invite_url;
|
|
2826
|
+
if (typeof d.groups !== 'undefined') payload.groups = d.groups;
|
|
2827
|
+
if (typeof d.rotated !== 'undefined') payload.rotated = d.rotated;
|
|
2828
|
+
if (typeof d.needs_rotate !== 'undefined') payload.needs_rotate = d.needs_rotate;
|
|
2829
|
+
gh.resolve(payload);
|
|
2830
|
+
} else {
|
|
2831
|
+
gh.reject(new Error(d.error || 'groups op failed'));
|
|
2832
|
+
}
|
|
2801
2833
|
}
|
|
2802
2834
|
});
|
|
2803
2835
|
window.bitpub = {
|
|
@@ -2879,6 +2911,69 @@ const BRIDGE_SHIM_SOURCE = String.raw`
|
|
|
2879
2911
|
}, 5000);
|
|
2880
2912
|
});
|
|
2881
2913
|
}
|
|
2914
|
+
},
|
|
2915
|
+
groups: {
|
|
2916
|
+
// Group management (browser chrome — backed by the local CLI's
|
|
2917
|
+
// bridge endpoints, not the manifest/apps system). Available to
|
|
2918
|
+
// any slice the parent has agreed to render; the user already
|
|
2919
|
+
// trusts these endpoints by running 'bitpub browser'.
|
|
2920
|
+
//
|
|
2921
|
+
// create(display) -> Promise of { group, invite_url }
|
|
2922
|
+
// list() -> Promise of { groups }
|
|
2923
|
+
// invite(slug, opts) -> Promise of { invite_url, rotated, needs_rotate }
|
|
2924
|
+
// opts.rotate=true rotates server-side
|
|
2925
|
+
// (invalidates any link teammates hold).
|
|
2926
|
+
create: function (display) {
|
|
2927
|
+
return new Promise(function (resolve, reject) {
|
|
2928
|
+
var requestId = ++nextId;
|
|
2929
|
+
readHandlers.set(requestId, { resolve: resolve, reject: reject });
|
|
2930
|
+
window.parent.postMessage({
|
|
2931
|
+
__bitpub: true,
|
|
2932
|
+
type: 'bitpub.groups.create',
|
|
2933
|
+
requestId: requestId,
|
|
2934
|
+
display: String(display || ''),
|
|
2935
|
+
}, '*');
|
|
2936
|
+
setTimeout(function () {
|
|
2937
|
+
if (!readHandlers.has(requestId)) return;
|
|
2938
|
+
readHandlers.delete(requestId);
|
|
2939
|
+
reject(new Error('groups.create timed out'));
|
|
2940
|
+
}, 30000);
|
|
2941
|
+
});
|
|
2942
|
+
},
|
|
2943
|
+
list: function () {
|
|
2944
|
+
return new Promise(function (resolve, reject) {
|
|
2945
|
+
var requestId = ++nextId;
|
|
2946
|
+
readHandlers.set(requestId, { resolve: resolve, reject: reject });
|
|
2947
|
+
window.parent.postMessage({
|
|
2948
|
+
__bitpub: true,
|
|
2949
|
+
type: 'bitpub.groups.list',
|
|
2950
|
+
requestId: requestId,
|
|
2951
|
+
}, '*');
|
|
2952
|
+
setTimeout(function () {
|
|
2953
|
+
if (!readHandlers.has(requestId)) return;
|
|
2954
|
+
readHandlers.delete(requestId);
|
|
2955
|
+
reject(new Error('groups.list timed out'));
|
|
2956
|
+
}, 5000);
|
|
2957
|
+
});
|
|
2958
|
+
},
|
|
2959
|
+
invite: function (slug, opts) {
|
|
2960
|
+
return new Promise(function (resolve, reject) {
|
|
2961
|
+
var requestId = ++nextId;
|
|
2962
|
+
readHandlers.set(requestId, { resolve: resolve, reject: reject });
|
|
2963
|
+
window.parent.postMessage({
|
|
2964
|
+
__bitpub: true,
|
|
2965
|
+
type: 'bitpub.groups.invite',
|
|
2966
|
+
requestId: requestId,
|
|
2967
|
+
slug: String(slug || ''),
|
|
2968
|
+
rotate: !!(opts && opts.rotate),
|
|
2969
|
+
}, '*');
|
|
2970
|
+
setTimeout(function () {
|
|
2971
|
+
if (!readHandlers.has(requestId)) return;
|
|
2972
|
+
readHandlers.delete(requestId);
|
|
2973
|
+
reject(new Error('groups.invite timed out'));
|
|
2974
|
+
}, 30000);
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2882
2977
|
}
|
|
2883
2978
|
};
|
|
2884
2979
|
})();
|
|
@@ -3173,6 +3268,89 @@ async function handleBridgeNamespaceListMessage(source, msg) {
|
|
|
3173
3268
|
}
|
|
3174
3269
|
}
|
|
3175
3270
|
|
|
3271
|
+
async function handleBridgeGroupsMessage(source, msg) {
|
|
3272
|
+
// Three ops share one reply event for symmetry with the iframe-side
|
|
3273
|
+
// dispatcher. Each op posts whatever subset of keys the caller asked
|
|
3274
|
+
// for; the shim unpacks them.
|
|
3275
|
+
const { requestId, type } = msg;
|
|
3276
|
+
if (typeof requestId !== 'number') return;
|
|
3277
|
+
const reply = (extra) => {
|
|
3278
|
+
try {
|
|
3279
|
+
source.postMessage({
|
|
3280
|
+
__bitpub: true,
|
|
3281
|
+
type: 'bitpub.groups.result',
|
|
3282
|
+
requestId,
|
|
3283
|
+
...extra,
|
|
3284
|
+
}, '*');
|
|
3285
|
+
} catch (_) { /* iframe gone */ }
|
|
3286
|
+
};
|
|
3287
|
+
|
|
3288
|
+
try {
|
|
3289
|
+
if (type === 'bitpub.groups.create') {
|
|
3290
|
+
const r = await fetch('/bridge/group/create', {
|
|
3291
|
+
method: 'POST',
|
|
3292
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3293
|
+
body: JSON.stringify({ display: msg.display || '' }),
|
|
3294
|
+
});
|
|
3295
|
+
const body = await r.json();
|
|
3296
|
+
if (!r.ok || !body.ok) {
|
|
3297
|
+
reply({ ok: false, error: (body && body.error) || `http ${r.status}` });
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
// The new group will appear in the local config; refresh the
|
|
3301
|
+
// sidebar tree so it shows up alongside any existing namespaces.
|
|
3302
|
+
// We refetch /api/data immediately (rather than waiting for the
|
|
3303
|
+
// 30s poll) and re-run buildTree so the empty group node lands
|
|
3304
|
+
// in the sidebar.
|
|
3305
|
+
(async () => {
|
|
3306
|
+
try {
|
|
3307
|
+
const fresh = await fetchData({ silent: true });
|
|
3308
|
+
if (!fresh) return;
|
|
3309
|
+
S.slices = fresh.slices.map(parseSlice);
|
|
3310
|
+
S.knownGroups = Array.isArray(fresh.known_groups) ? fresh.known_groups : S.knownGroups;
|
|
3311
|
+
S.tree = buildTree(S.slices, S.knownGroups);
|
|
3312
|
+
S.stats = computeStats(S.slices);
|
|
3313
|
+
renderAll();
|
|
3314
|
+
} catch (_) { /* poll will catch up */ }
|
|
3315
|
+
})();
|
|
3316
|
+
reply({ ok: true, group: body.group, invite_url: body.invite_url });
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
if (type === 'bitpub.groups.list') {
|
|
3320
|
+
const r = await fetch('/bridge/group/list');
|
|
3321
|
+
const body = await r.json();
|
|
3322
|
+
if (!r.ok || !body.ok) {
|
|
3323
|
+
reply({ ok: false, error: (body && body.error) || `http ${r.status}` });
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
3326
|
+
reply({ ok: true, groups: body.groups || [] });
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
if (type === 'bitpub.groups.invite') {
|
|
3330
|
+
const r = await fetch('/bridge/group/invite', {
|
|
3331
|
+
method: 'POST',
|
|
3332
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3333
|
+
body: JSON.stringify({ slug: msg.slug || '', rotate: !!msg.rotate }),
|
|
3334
|
+
});
|
|
3335
|
+
const body = await r.json();
|
|
3336
|
+
if (!r.ok || !body.ok) {
|
|
3337
|
+
reply({ ok: false, error: (body && body.error) || `http ${r.status}` });
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
reply({
|
|
3341
|
+
ok: true,
|
|
3342
|
+
invite_url: body.invite_url || null,
|
|
3343
|
+
rotated: !!body.rotated,
|
|
3344
|
+
needs_rotate: !!body.needs_rotate,
|
|
3345
|
+
});
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
reply({ ok: false, error: `unknown groups op: ${type}` });
|
|
3349
|
+
} catch (err) {
|
|
3350
|
+
reply({ ok: false, error: (err && err.message) || 'fetch failed' });
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3176
3354
|
async function handleBridgeIdentityMessage(source, msg) {
|
|
3177
3355
|
const { requestId } = msg;
|
|
3178
3356
|
if (typeof requestId !== 'number') return;
|
|
@@ -3212,6 +3390,9 @@ window.addEventListener('message', (e) => {
|
|
|
3212
3390
|
if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
|
|
3213
3391
|
else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
|
|
3214
3392
|
else if (d.type === 'bitpub.identity.me') handleBridgeIdentityMessage(e.source, d);
|
|
3393
|
+
else if (d.type === 'bitpub.groups.create' ||
|
|
3394
|
+
d.type === 'bitpub.groups.list' ||
|
|
3395
|
+
d.type === 'bitpub.groups.invite') handleBridgeGroupsMessage(e.source, d);
|
|
3215
3396
|
else if (d.type === 'bitpub.navigate' || d.type === 'navigate') handleBridgeNavigateMessage(e.source, d);
|
|
3216
3397
|
});
|
|
3217
3398
|
|
package/static/welcome.html
CHANGED
|
@@ -340,6 +340,331 @@
|
|
|
340
340
|
details.recipe pre .p { color: #7c9d7a; }
|
|
341
341
|
details.recipe pre .n { color: #b5a5ff; }
|
|
342
342
|
|
|
343
|
+
/* ─── Team / Groups section ─────────────────────────────
|
|
344
|
+
Dedicated component that lets the user create a group and
|
|
345
|
+
see the ones they're already in — no terminal commands.
|
|
346
|
+
The card mirrors the visual language of .meta-card so it
|
|
347
|
+
reads as "a serious thing, not a hint." Form + list live
|
|
348
|
+
inside the same card because the empty state ("no groups
|
|
349
|
+
yet") is itself a teaching moment about why groups exist. */
|
|
350
|
+
.team-card {
|
|
351
|
+
margin: 8px 0 52px;
|
|
352
|
+
background: var(--card);
|
|
353
|
+
border: 1px solid var(--line);
|
|
354
|
+
border-radius: var(--radius);
|
|
355
|
+
padding: 28px 30px 24px;
|
|
356
|
+
box-shadow: var(--shadow-1);
|
|
357
|
+
}
|
|
358
|
+
.team-card .team-hd { margin-bottom: 18px; }
|
|
359
|
+
.team-card .team-eyebrow {
|
|
360
|
+
display: inline-block;
|
|
361
|
+
font-size: 11px;
|
|
362
|
+
font-weight: 600;
|
|
363
|
+
letter-spacing: .06em;
|
|
364
|
+
text-transform: uppercase;
|
|
365
|
+
color: var(--accent);
|
|
366
|
+
background: var(--accent-bg);
|
|
367
|
+
padding: 3px 9px;
|
|
368
|
+
border-radius: 999px;
|
|
369
|
+
margin-bottom: 12px;
|
|
370
|
+
}
|
|
371
|
+
.team-card h2 {
|
|
372
|
+
font-family: var(--display);
|
|
373
|
+
font-size: 26px;
|
|
374
|
+
font-weight: 600;
|
|
375
|
+
letter-spacing: -.02em;
|
|
376
|
+
line-height: 1.2;
|
|
377
|
+
color: var(--text);
|
|
378
|
+
margin-bottom: 10px;
|
|
379
|
+
}
|
|
380
|
+
.team-card .lede-2 {
|
|
381
|
+
font-size: 14.5px;
|
|
382
|
+
color: var(--muted);
|
|
383
|
+
line-height: 1.6;
|
|
384
|
+
max-width: 64ch;
|
|
385
|
+
}
|
|
386
|
+
.team-card .lede-2 b { color: var(--text); font-weight: 600; }
|
|
387
|
+
|
|
388
|
+
/* Create form */
|
|
389
|
+
.team-form {
|
|
390
|
+
display: flex;
|
|
391
|
+
gap: 10px;
|
|
392
|
+
margin-top: 22px;
|
|
393
|
+
padding: 14px;
|
|
394
|
+
background: var(--inset-2);
|
|
395
|
+
border: 1px solid var(--line);
|
|
396
|
+
border-radius: var(--radius-sm);
|
|
397
|
+
}
|
|
398
|
+
.team-form input[type="text"] {
|
|
399
|
+
flex: 1;
|
|
400
|
+
min-width: 0;
|
|
401
|
+
padding: 10px 12px;
|
|
402
|
+
background: var(--card);
|
|
403
|
+
border: 1px solid var(--line-2);
|
|
404
|
+
border-radius: var(--radius-xs);
|
|
405
|
+
font: inherit;
|
|
406
|
+
font-size: 14px;
|
|
407
|
+
color: var(--text);
|
|
408
|
+
transition: border-color .15s ease, box-shadow .15s ease;
|
|
409
|
+
}
|
|
410
|
+
.team-form input[type="text"]:focus {
|
|
411
|
+
outline: none;
|
|
412
|
+
border-color: var(--accent);
|
|
413
|
+
box-shadow: 0 0 0 3px var(--accent-bg);
|
|
414
|
+
}
|
|
415
|
+
.team-form input[type="text"]::placeholder { color: var(--faint); }
|
|
416
|
+
.team-form input[type="text"]:disabled {
|
|
417
|
+
opacity: .6; cursor: not-allowed;
|
|
418
|
+
background: var(--inset);
|
|
419
|
+
}
|
|
420
|
+
.btn-primary {
|
|
421
|
+
padding: 10px 18px;
|
|
422
|
+
background: var(--accent);
|
|
423
|
+
color: white;
|
|
424
|
+
border: 0;
|
|
425
|
+
border-radius: var(--radius-xs);
|
|
426
|
+
font: inherit;
|
|
427
|
+
font-size: 13.5px;
|
|
428
|
+
font-weight: 600;
|
|
429
|
+
cursor: pointer;
|
|
430
|
+
transition: background .15s ease, transform .1s ease;
|
|
431
|
+
white-space: nowrap;
|
|
432
|
+
display: inline-flex;
|
|
433
|
+
align-items: center;
|
|
434
|
+
gap: 8px;
|
|
435
|
+
}
|
|
436
|
+
.btn-primary:hover { background: var(--accent-2); }
|
|
437
|
+
.btn-primary:active { transform: translateY(1px); }
|
|
438
|
+
.btn-primary:disabled {
|
|
439
|
+
opacity: .55; cursor: not-allowed; transform: none; background: var(--accent);
|
|
440
|
+
}
|
|
441
|
+
.btn-primary .spinner {
|
|
442
|
+
width: 12px; height: 12px;
|
|
443
|
+
border: 2px solid rgba(255,255,255,.35);
|
|
444
|
+
border-top-color: white;
|
|
445
|
+
border-radius: 50%;
|
|
446
|
+
animation: spin .6s linear infinite;
|
|
447
|
+
display: none;
|
|
448
|
+
}
|
|
449
|
+
.btn-primary.busy .spinner { display: inline-block; }
|
|
450
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
451
|
+
|
|
452
|
+
.team-error {
|
|
453
|
+
margin-top: 12px;
|
|
454
|
+
padding: 10px 12px;
|
|
455
|
+
background: rgba(229, 87, 70, .08);
|
|
456
|
+
border: 1px solid rgba(229, 87, 70, .25);
|
|
457
|
+
color: #b3392a;
|
|
458
|
+
border-radius: var(--radius-xs);
|
|
459
|
+
font-size: 13px;
|
|
460
|
+
line-height: 1.5;
|
|
461
|
+
}
|
|
462
|
+
.team-error.hidden { display: none; }
|
|
463
|
+
|
|
464
|
+
/* Groups list */
|
|
465
|
+
.team-list-h {
|
|
466
|
+
display: flex;
|
|
467
|
+
align-items: baseline;
|
|
468
|
+
gap: 10px;
|
|
469
|
+
margin: 28px 0 12px;
|
|
470
|
+
padding-top: 22px;
|
|
471
|
+
border-top: 1px solid var(--line);
|
|
472
|
+
}
|
|
473
|
+
.team-list-h h3 {
|
|
474
|
+
font-family: var(--display);
|
|
475
|
+
font-size: 15px;
|
|
476
|
+
font-weight: 600;
|
|
477
|
+
color: var(--text);
|
|
478
|
+
letter-spacing: -.005em;
|
|
479
|
+
}
|
|
480
|
+
.team-list-h .count {
|
|
481
|
+
font-size: 12px;
|
|
482
|
+
color: var(--subtle);
|
|
483
|
+
}
|
|
484
|
+
.team-list { display: flex; flex-direction: column; gap: 10px; }
|
|
485
|
+
.team-empty {
|
|
486
|
+
padding: 18px 16px;
|
|
487
|
+
background: var(--inset-2);
|
|
488
|
+
border: 1px dashed var(--line-2);
|
|
489
|
+
border-radius: var(--radius-sm);
|
|
490
|
+
color: var(--muted);
|
|
491
|
+
font-size: 13px;
|
|
492
|
+
line-height: 1.55;
|
|
493
|
+
text-align: center;
|
|
494
|
+
}
|
|
495
|
+
.group-row {
|
|
496
|
+
display: grid;
|
|
497
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
498
|
+
gap: 14px;
|
|
499
|
+
align-items: start;
|
|
500
|
+
padding: 14px 16px;
|
|
501
|
+
background: var(--inset-2);
|
|
502
|
+
border: 1px solid var(--line);
|
|
503
|
+
border-radius: var(--radius-sm);
|
|
504
|
+
transition: background .15s ease;
|
|
505
|
+
}
|
|
506
|
+
.group-row.is-new {
|
|
507
|
+
background: var(--accent-bg);
|
|
508
|
+
border-color: rgba(255, 71, 26, .25);
|
|
509
|
+
animation: groupArrive .8s ease both;
|
|
510
|
+
}
|
|
511
|
+
@keyframes groupArrive { from { transform: scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
512
|
+
.group-row .info { min-width: 0; }
|
|
513
|
+
.group-row .name {
|
|
514
|
+
font-family: var(--display);
|
|
515
|
+
font-size: 15px;
|
|
516
|
+
font-weight: 600;
|
|
517
|
+
color: var(--text);
|
|
518
|
+
margin-bottom: 2px;
|
|
519
|
+
}
|
|
520
|
+
.group-row .role {
|
|
521
|
+
display: inline-block;
|
|
522
|
+
margin-left: 8px;
|
|
523
|
+
font-size: 10.5px;
|
|
524
|
+
font-weight: 600;
|
|
525
|
+
text-transform: uppercase;
|
|
526
|
+
letter-spacing: .04em;
|
|
527
|
+
padding: 1px 6px;
|
|
528
|
+
border-radius: 4px;
|
|
529
|
+
background: var(--inset);
|
|
530
|
+
color: var(--subtle);
|
|
531
|
+
vertical-align: 2px;
|
|
532
|
+
}
|
|
533
|
+
.group-row .role.owner {
|
|
534
|
+
background: var(--accent-bg);
|
|
535
|
+
color: var(--accent);
|
|
536
|
+
}
|
|
537
|
+
.group-row .addr {
|
|
538
|
+
font-family: var(--mono);
|
|
539
|
+
font-size: 12px;
|
|
540
|
+
color: var(--muted);
|
|
541
|
+
word-break: break-all;
|
|
542
|
+
line-height: 1.5;
|
|
543
|
+
}
|
|
544
|
+
.group-row .actions-row {
|
|
545
|
+
display: flex;
|
|
546
|
+
gap: 6px;
|
|
547
|
+
align-items: center;
|
|
548
|
+
}
|
|
549
|
+
.btn-ghost {
|
|
550
|
+
padding: 6px 10px;
|
|
551
|
+
background: var(--card);
|
|
552
|
+
border: 1px solid var(--line-2);
|
|
553
|
+
border-radius: var(--radius-xs);
|
|
554
|
+
font: inherit;
|
|
555
|
+
font-size: 12px;
|
|
556
|
+
font-weight: 500;
|
|
557
|
+
color: var(--muted);
|
|
558
|
+
cursor: pointer;
|
|
559
|
+
transition: background .15s ease, color .15s ease, border-color .15s ease;
|
|
560
|
+
display: inline-flex;
|
|
561
|
+
align-items: center;
|
|
562
|
+
gap: 6px;
|
|
563
|
+
}
|
|
564
|
+
.btn-ghost:hover {
|
|
565
|
+
background: var(--inset);
|
|
566
|
+
color: var(--text);
|
|
567
|
+
border-color: var(--line-2);
|
|
568
|
+
}
|
|
569
|
+
.btn-ghost.copied {
|
|
570
|
+
background: var(--ok-bg);
|
|
571
|
+
color: var(--ok);
|
|
572
|
+
border-color: rgba(42,157,110,.3);
|
|
573
|
+
}
|
|
574
|
+
.btn-ghost svg { width: 12px; height: 12px; }
|
|
575
|
+
.btn-ghost.danger:hover {
|
|
576
|
+
background: rgba(229, 87, 70, .08);
|
|
577
|
+
color: #b3392a;
|
|
578
|
+
border-color: rgba(229, 87, 70, .25);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.invite-banner {
|
|
582
|
+
grid-column: 1 / -1;
|
|
583
|
+
margin-top: 10px;
|
|
584
|
+
padding: 12px;
|
|
585
|
+
background: var(--card);
|
|
586
|
+
border: 1px solid var(--line);
|
|
587
|
+
border-radius: var(--radius-xs);
|
|
588
|
+
display: flex;
|
|
589
|
+
flex-direction: column;
|
|
590
|
+
gap: 8px;
|
|
591
|
+
}
|
|
592
|
+
.invite-banner .invite-url {
|
|
593
|
+
flex: 1;
|
|
594
|
+
min-width: 0;
|
|
595
|
+
font-family: var(--mono);
|
|
596
|
+
font-size: 12px;
|
|
597
|
+
color: var(--text);
|
|
598
|
+
background: var(--inset-2);
|
|
599
|
+
padding: 8px 10px;
|
|
600
|
+
border-radius: var(--radius-xs);
|
|
601
|
+
border: 1px solid var(--line);
|
|
602
|
+
overflow-x: auto;
|
|
603
|
+
white-space: nowrap;
|
|
604
|
+
}
|
|
605
|
+
.invite-banner .row {
|
|
606
|
+
display: flex;
|
|
607
|
+
gap: 10px;
|
|
608
|
+
align-items: center;
|
|
609
|
+
}
|
|
610
|
+
.invite-banner .row .invite-url { margin: 0; }
|
|
611
|
+
.invite-banner .rotate-link {
|
|
612
|
+
align-self: flex-start;
|
|
613
|
+
background: none;
|
|
614
|
+
border: 0;
|
|
615
|
+
padding: 0;
|
|
616
|
+
font: inherit;
|
|
617
|
+
font-size: 11.5px;
|
|
618
|
+
color: var(--subtle);
|
|
619
|
+
cursor: pointer;
|
|
620
|
+
text-decoration: underline dotted;
|
|
621
|
+
text-underline-offset: 3px;
|
|
622
|
+
}
|
|
623
|
+
.invite-banner .rotate-link:hover { color: var(--text); }
|
|
624
|
+
|
|
625
|
+
/* Terminal-style ephemeral "fetching..." indicator that flashes
|
|
626
|
+
in place of the invite button while the bridge does its server
|
|
627
|
+
round-trip. Makes the network step visible without being noisy. */
|
|
628
|
+
.term-pill {
|
|
629
|
+
display: inline-flex;
|
|
630
|
+
align-items: center;
|
|
631
|
+
gap: 8px;
|
|
632
|
+
padding: 6px 10px;
|
|
633
|
+
background: #1e1a19;
|
|
634
|
+
color: #e8e2dc;
|
|
635
|
+
border-radius: var(--radius-xs);
|
|
636
|
+
font-family: var(--mono);
|
|
637
|
+
font-size: 11.5px;
|
|
638
|
+
line-height: 1;
|
|
639
|
+
box-shadow: var(--shadow-1);
|
|
640
|
+
white-space: nowrap;
|
|
641
|
+
overflow: hidden;
|
|
642
|
+
max-width: 100%;
|
|
643
|
+
}
|
|
644
|
+
.term-pill .term-prompt { color: var(--accent); }
|
|
645
|
+
.term-pill .term-cmd { color: #e8e2dc; }
|
|
646
|
+
.term-pill .term-result {
|
|
647
|
+
color: #7c9d7a; /* green for success */
|
|
648
|
+
}
|
|
649
|
+
.term-pill.is-error { background: #3a1816; }
|
|
650
|
+
.term-pill.is-error .term-result { color: #ff8266; }
|
|
651
|
+
.term-pill .term-dots {
|
|
652
|
+
display: inline-flex; gap: 3px;
|
|
653
|
+
}
|
|
654
|
+
.term-pill .term-dots span {
|
|
655
|
+
width: 4px; height: 4px;
|
|
656
|
+
background: #e8e2dc;
|
|
657
|
+
border-radius: 50%;
|
|
658
|
+
opacity: .3;
|
|
659
|
+
animation: termDot 1.1s infinite;
|
|
660
|
+
}
|
|
661
|
+
.term-pill .term-dots span:nth-child(2) { animation-delay: .15s; }
|
|
662
|
+
.term-pill .term-dots span:nth-child(3) { animation-delay: .3s; }
|
|
663
|
+
@keyframes termDot {
|
|
664
|
+
0%,80%,100% { opacity: .3; }
|
|
665
|
+
40% { opacity: 1; }
|
|
666
|
+
}
|
|
667
|
+
|
|
343
668
|
/* ─── Footnote ─────────────────────────────────────────── */
|
|
344
669
|
.footnote {
|
|
345
670
|
margin-top: 44px;
|
|
@@ -395,13 +720,51 @@
|
|
|
395
720
|
</div>
|
|
396
721
|
</div>
|
|
397
722
|
|
|
723
|
+
<!-- ═══════ Team / Groups ═══════
|
|
724
|
+
The group creation surface is its own dedicated component
|
|
725
|
+
rather than another copy-the-command card. The button below
|
|
726
|
+
is wired to window.bitpub.groups.create() (browser-chrome
|
|
727
|
+
bridge — deterministic, no LLM in the loop). The "Your
|
|
728
|
+
groups" list updates on success and persists across reloads. -->
|
|
729
|
+
<div class="team-card" id="team-card">
|
|
730
|
+
<div class="team-hd">
|
|
731
|
+
<span class="team-eyebrow">Your team workspace</span>
|
|
732
|
+
<h2>Create a group with your team</h2>
|
|
733
|
+
<p class="lede-2">
|
|
734
|
+
A group is a <b>shared namespace</b> — one address everyone on your team can read and write. No GitHub repos, no PRs, no email invites. You'll get a single link to share; teammates click it and they're in. Use a group for <b>team transcripts, shared apps, agents you want everyone to run, the running thread of context your team works against.</b>
|
|
735
|
+
</p>
|
|
736
|
+
</div>
|
|
737
|
+
|
|
738
|
+
<!-- Not a real <form>: the iframe sandbox is "allow-scripts" only
|
|
739
|
+
(no allow-forms), so form submission is blocked by the browser
|
|
740
|
+
and the submit event never fires. We listen on button-click +
|
|
741
|
+
Enter-in-input directly. -->
|
|
742
|
+
<div class="team-form" id="team-form">
|
|
743
|
+
<input type="text" id="team-name" placeholder="Name your team (e.g. Acme Engineering)"
|
|
744
|
+
maxlength="80" autocomplete="off">
|
|
745
|
+
<button class="btn-primary" type="button" id="team-create-btn">
|
|
746
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
747
|
+
<span class="btn-label">Create group</span>
|
|
748
|
+
</button>
|
|
749
|
+
</div>
|
|
750
|
+
<div class="team-error hidden" id="team-error" role="alert"></div>
|
|
751
|
+
|
|
752
|
+
<div class="team-list-h">
|
|
753
|
+
<h3>Your groups</h3>
|
|
754
|
+
<span class="count" id="team-count">—</span>
|
|
755
|
+
</div>
|
|
756
|
+
<div class="team-list" id="team-list">
|
|
757
|
+
<div class="team-empty">Loading your groups…</div>
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
|
|
398
761
|
<!-- ═══════ Actions ═══════ -->
|
|
399
762
|
<div class="section-h">
|
|
400
|
-
<h2>
|
|
763
|
+
<h2>Two things to try</h2>
|
|
401
764
|
<span class="meta">each takes under a minute · click to copy</span>
|
|
402
765
|
</div>
|
|
403
766
|
|
|
404
|
-
<div class="actions">
|
|
767
|
+
<div class="actions" style="grid-template-columns: 1fr 1fr;">
|
|
405
768
|
|
|
406
769
|
<!-- ── Card 1: look at your namespace ── -->
|
|
407
770
|
<div class="action">
|
|
@@ -425,31 +788,9 @@
|
|
|
425
788
|
</p>
|
|
426
789
|
</div>
|
|
427
790
|
|
|
428
|
-
<!-- ── Card 2:
|
|
791
|
+
<!-- ── Card 2: build an app with the agent you already have ── -->
|
|
429
792
|
<div class="action">
|
|
430
793
|
<div class="action-num">2</div>
|
|
431
|
-
<h3>Create a group with your team</h3>
|
|
432
|
-
<p>
|
|
433
|
-
Groups are shared namespaces. Members can read and write together. You'll get a shareable invite link to send your teammates — no GitHub, no email-invites, no PRs.
|
|
434
|
-
</p>
|
|
435
|
-
<div class="copy-block">
|
|
436
|
-
<pre class="cmd"><span class="prompt">$ </span>bitpub group create yourcompany</pre>
|
|
437
|
-
<button class="copy-btn" type="button" data-copy="bitpub group create yourcompany">
|
|
438
|
-
<span class="label">
|
|
439
|
-
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg>
|
|
440
|
-
Copy command
|
|
441
|
-
</span>
|
|
442
|
-
<span class="hint" aria-hidden="true">⌘V in terminal</span>
|
|
443
|
-
</button>
|
|
444
|
-
</div>
|
|
445
|
-
<p class="followup">
|
|
446
|
-
Then share the printed link. Teammates run <code>bitpub join <link></code> and are in.
|
|
447
|
-
</p>
|
|
448
|
-
</div>
|
|
449
|
-
|
|
450
|
-
<!-- ── Card 3: build an app with the agent you already have ── -->
|
|
451
|
-
<div class="action">
|
|
452
|
-
<div class="action-num">3</div>
|
|
453
794
|
<h3>Build an app with the agent you already have</h3>
|
|
454
795
|
<p>
|
|
455
796
|
Paste the prompt below into Claude Code, Cursor, or Codex. Your agent will write you a small HTML app and save it to your namespace. It'll render here, in this same window.
|
|
@@ -569,6 +910,345 @@
|
|
|
569
910
|
labelEl.innerHTML = original;
|
|
570
911
|
}, 1600);
|
|
571
912
|
}
|
|
913
|
+
|
|
914
|
+
/* ─── Team / Groups wiring ─────────────────────────────────────
|
|
915
|
+
Uses the browser-chrome bridge (window.bitpub.groups.*) — same
|
|
916
|
+
trust boundary as the sidebar Sync button. The create button is
|
|
917
|
+
deterministic: one HTTP POST to /bridge/group/create.
|
|
918
|
+
|
|
919
|
+
Invite reads are LAZY — clicking "Copy invite link" hits the
|
|
920
|
+
server fresh every time via GET /v1/groups/:slug/invites (server
|
|
921
|
+
is the source of truth). No local caching, no stale-link
|
|
922
|
+
hazards. The intermediate "fetching..." pill is intentional UX:
|
|
923
|
+
group ops require network and we want that visible. */
|
|
924
|
+
const groupsApi = (window.bitpub && window.bitpub.groups) || null;
|
|
925
|
+
|
|
926
|
+
// Tracks the slug of the group just created in this page session so
|
|
927
|
+
// we can anchor its row at the top and highlight it. Cleared on
|
|
928
|
+
// reload — purely cosmetic, no data lives here.
|
|
929
|
+
let lastCreatedSlug = null;
|
|
930
|
+
|
|
931
|
+
function el(html) {
|
|
932
|
+
const t = document.createElement('template');
|
|
933
|
+
t.innerHTML = html.trim();
|
|
934
|
+
return t.content.firstChild;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function escapeHtml(s) {
|
|
938
|
+
return String(s == null ? '' : s)
|
|
939
|
+
.replace(/&/g, '&')
|
|
940
|
+
.replace(/</g, '<')
|
|
941
|
+
.replace(/>/g, '>')
|
|
942
|
+
.replace(/"/g, '"')
|
|
943
|
+
.replace(/'/g, ''');
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function showTeamError(msg) {
|
|
947
|
+
const e = document.getElementById('team-error');
|
|
948
|
+
if (!e) return;
|
|
949
|
+
e.textContent = msg || '';
|
|
950
|
+
e.classList.toggle('hidden', !msg);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function loadGroups() {
|
|
954
|
+
const list = document.getElementById('team-list');
|
|
955
|
+
const count = document.getElementById('team-count');
|
|
956
|
+
if (!groupsApi) {
|
|
957
|
+
list.innerHTML = '<div class="team-empty">Group management isn\'t available in this context. Open this page inside the BitPub Browser to manage groups visually.</div>';
|
|
958
|
+
count.textContent = '';
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
const { groups = [] } = await groupsApi.list();
|
|
963
|
+
renderGroups(groups);
|
|
964
|
+
} catch (err) {
|
|
965
|
+
list.innerHTML = `<div class="team-empty">Could not load your groups: ${escapeHtml(err.message || String(err))}</div>`;
|
|
966
|
+
count.textContent = '';
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function renderGroups(groups) {
|
|
971
|
+
const list = document.getElementById('team-list');
|
|
972
|
+
const count = document.getElementById('team-count');
|
|
973
|
+
count.textContent = groups.length === 0
|
|
974
|
+
? 'none yet'
|
|
975
|
+
: (groups.length === 1 ? '1 group' : `${groups.length} groups`);
|
|
976
|
+
|
|
977
|
+
if (groups.length === 0) {
|
|
978
|
+
list.innerHTML = '<div class="team-empty">You\'re not in any groups yet. Create one above and you\'ll see it here, along with the invite link to send your teammates.</div>';
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Sort: most-recently-joined first, freshly-created group pinned
|
|
983
|
+
// to the top so it lands in the user's first glance.
|
|
984
|
+
const sorted = groups.slice().sort((a, b) => {
|
|
985
|
+
if (a.slug === lastCreatedSlug) return -1;
|
|
986
|
+
if (b.slug === lastCreatedSlug) return 1;
|
|
987
|
+
return String(b.joined_at || '').localeCompare(String(a.joined_at || ''));
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
list.innerHTML = '';
|
|
991
|
+
for (const g of sorted) {
|
|
992
|
+
const isNew = g.slug === lastCreatedSlug;
|
|
993
|
+
const roleBadge = g.role === 'owner'
|
|
994
|
+
? '<span class="role owner">owner</span>'
|
|
995
|
+
: (g.role ? `<span class="role">${escapeHtml(g.role)}</span>` : '');
|
|
996
|
+
|
|
997
|
+
// Same affordance for every row: "Copy invite link". Clicking
|
|
998
|
+
// does a lazy server GET, copies to clipboard, then shows the
|
|
999
|
+
// URL in a banner so the user can confirm what got copied.
|
|
1000
|
+
const row = el(`<div class="group-row${isNew ? ' is-new' : ''}" data-slug="${escapeHtml(g.slug)}">
|
|
1001
|
+
<div class="info">
|
|
1002
|
+
<div class="name">${escapeHtml(g.display || g.slug)}${roleBadge}</div>
|
|
1003
|
+
<div class="addr">${escapeHtml(g.address || `bitpub://group:${g.slug}/`)}</div>
|
|
1004
|
+
</div>
|
|
1005
|
+
<div class="actions-row">
|
|
1006
|
+
<button class="btn-ghost open-btn" type="button" data-slug="${escapeHtml(g.slug)}" title="Open this group's namespace in the sidebar">
|
|
1007
|
+
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2.5h5.5L9.5 3.5V9a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z"/></svg>
|
|
1008
|
+
Open
|
|
1009
|
+
</button>
|
|
1010
|
+
<button class="btn-ghost copy-invite-btn" type="button" data-slug="${escapeHtml(g.slug)}" title="Fetch and copy this group's invite link (one network call)">
|
|
1011
|
+
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg>
|
|
1012
|
+
Copy invite link
|
|
1013
|
+
</button>
|
|
1014
|
+
</div>
|
|
1015
|
+
</div>`);
|
|
1016
|
+
list.appendChild(row);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
list.querySelectorAll('.copy-invite-btn').forEach(btn => {
|
|
1020
|
+
btn.addEventListener('click', () => copyInviteLazy(btn));
|
|
1021
|
+
});
|
|
1022
|
+
list.querySelectorAll('.open-btn').forEach(btn => {
|
|
1023
|
+
btn.addEventListener('click', () => {
|
|
1024
|
+
const slug = btn.getAttribute('data-slug');
|
|
1025
|
+
if (!slug) return;
|
|
1026
|
+
// Hand off to the parent BitPub Browser via the navigate
|
|
1027
|
+
// bridge — same path the sidebar uses.
|
|
1028
|
+
window.parent.postMessage({
|
|
1029
|
+
__bitpub: true,
|
|
1030
|
+
type: 'bitpub.navigate',
|
|
1031
|
+
hcu: `bitpub://group:${slug}/`,
|
|
1032
|
+
}, '*');
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* Replace the button with a small terminal-style pill that shows
|
|
1038
|
+
the command being run, hold there until the bridge resolves,
|
|
1039
|
+
then either:
|
|
1040
|
+
- success: drop the URL banner under the row, copy to clipboard,
|
|
1041
|
+
swap the pill back to a "Copy invite link" button
|
|
1042
|
+
so a second copy is one click away
|
|
1043
|
+
- failure: turn the pill red with the error, restore the button
|
|
1044
|
+
after a few seconds */
|
|
1045
|
+
async function copyInviteLazy(btn) {
|
|
1046
|
+
const slug = btn.getAttribute('data-slug');
|
|
1047
|
+
if (!slug || !groupsApi) return;
|
|
1048
|
+
const row = btn.closest('.group-row');
|
|
1049
|
+
if (!row) return;
|
|
1050
|
+
|
|
1051
|
+
// Drop any prior banner from a previous click — single source of
|
|
1052
|
+
// truth per row.
|
|
1053
|
+
const prior = row.querySelector('.invite-banner');
|
|
1054
|
+
if (prior) prior.remove();
|
|
1055
|
+
|
|
1056
|
+
const cmd = `bitpub group invite ${slug} --show`;
|
|
1057
|
+
const pill = el(`<span class="term-pill" role="status">
|
|
1058
|
+
<span class="term-prompt">$</span>
|
|
1059
|
+
<span class="term-cmd">${escapeHtml(cmd)}</span>
|
|
1060
|
+
<span class="term-dots"><span></span><span></span><span></span></span>
|
|
1061
|
+
</span>`);
|
|
1062
|
+
btn.replaceWith(pill);
|
|
1063
|
+
|
|
1064
|
+
let result;
|
|
1065
|
+
try {
|
|
1066
|
+
result = await groupsApi.invite(slug, { rotate: false });
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
renderInviteError(pill, row, slug, err.message || 'fetch failed');
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (!result || !result.invite_url) {
|
|
1072
|
+
// Every invite has been revoked — surface as a non-fatal note
|
|
1073
|
+
// and offer to mint a new one in the same gesture.
|
|
1074
|
+
renderInviteEmpty(pill, row, slug);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Replace pill with the URL banner; copy to clipboard immediately.
|
|
1079
|
+
const banner = renderInviteBanner(row, slug, result.invite_url);
|
|
1080
|
+
pill.replaceWith(makeCopyAgainButton(slug));
|
|
1081
|
+
const ok = await copyToClipboard(result.invite_url);
|
|
1082
|
+
flashBannerCopied(banner, ok);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function makeCopyAgainButton(slug) {
|
|
1086
|
+
const btn = el(`<button class="btn-ghost copy-invite-btn copied" type="button" data-slug="${escapeHtml(slug)}" title="Copy this invite link again">
|
|
1087
|
+
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 6.5L5 9.5l5-6"/></svg>
|
|
1088
|
+
Copied · click to re-fetch
|
|
1089
|
+
</button>`);
|
|
1090
|
+
btn.addEventListener('click', () => {
|
|
1091
|
+
// Strip the "copied" state so a fresh click starts a fresh fetch.
|
|
1092
|
+
btn.classList.remove('copied');
|
|
1093
|
+
btn.innerHTML = '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg> Copy invite link';
|
|
1094
|
+
copyInviteLazy(btn);
|
|
1095
|
+
});
|
|
1096
|
+
return btn;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function renderInviteBanner(row, slug, url) {
|
|
1100
|
+
const banner = el(`<div class="invite-banner">
|
|
1101
|
+
<div class="row">
|
|
1102
|
+
<div class="invite-url" title="${escapeHtml(url)}">${escapeHtml(url)}</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
<button class="rotate-link" type="button">Rotate this link (invalidates the URL above)</button>
|
|
1105
|
+
</div>`);
|
|
1106
|
+
banner.querySelector('.rotate-link').addEventListener('click', () => rotateLazy(slug, row));
|
|
1107
|
+
row.appendChild(banner);
|
|
1108
|
+
return banner;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function renderInviteEmpty(pill, row, slug) {
|
|
1112
|
+
const note = el(`<div class="invite-banner">
|
|
1113
|
+
<div class="row">
|
|
1114
|
+
<div class="invite-url" style="color:var(--muted)">No active invite for this group — every prior link has been revoked.</div>
|
|
1115
|
+
</div>
|
|
1116
|
+
<button class="rotate-link" type="button">Mint a fresh invite link</button>
|
|
1117
|
+
</div>`);
|
|
1118
|
+
note.querySelector('.rotate-link').addEventListener('click', () => rotateLazy(slug, row));
|
|
1119
|
+
pill.replaceWith(el(`<button class="btn-ghost copy-invite-btn" type="button" data-slug="${escapeHtml(slug)}"><svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg> Copy invite link</button>`));
|
|
1120
|
+
// Re-wire the click on the restored button.
|
|
1121
|
+
row.querySelector('.copy-invite-btn').addEventListener('click', (e) => copyInviteLazy(e.currentTarget));
|
|
1122
|
+
row.appendChild(note);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function renderInviteError(pill, row, slug, message) {
|
|
1126
|
+
pill.classList.add('is-error');
|
|
1127
|
+
pill.innerHTML = `<span class="term-prompt">$</span><span class="term-cmd">bitpub group invite ${escapeHtml(slug)} --show</span><span class="term-result">→ ${escapeHtml(message)}</span>`;
|
|
1128
|
+
// After a beat, restore the button so the user can retry.
|
|
1129
|
+
setTimeout(() => {
|
|
1130
|
+
const btn = el(`<button class="btn-ghost copy-invite-btn" type="button" data-slug="${escapeHtml(slug)}" title="Try fetching again"><svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg> Copy invite link</button>`);
|
|
1131
|
+
btn.addEventListener('click', () => copyInviteLazy(btn));
|
|
1132
|
+
pill.replaceWith(btn);
|
|
1133
|
+
}, 4000);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function flashBannerCopied(banner, ok) {
|
|
1137
|
+
const urlEl = banner.querySelector('.invite-url');
|
|
1138
|
+
if (!urlEl) return;
|
|
1139
|
+
const original = urlEl.style.borderColor;
|
|
1140
|
+
urlEl.style.transition = 'border-color .2s ease, background .2s ease';
|
|
1141
|
+
urlEl.style.borderColor = ok ? 'rgba(42,157,110,.5)' : 'rgba(229,87,70,.5)';
|
|
1142
|
+
urlEl.style.background = ok ? 'var(--ok-bg)' : 'rgba(229,87,70,.08)';
|
|
1143
|
+
setTimeout(() => {
|
|
1144
|
+
urlEl.style.borderColor = original;
|
|
1145
|
+
urlEl.style.background = '';
|
|
1146
|
+
}, 900);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async function rotateLazy(slug, row) {
|
|
1150
|
+
const ok = window.confirm(
|
|
1151
|
+
'Generate a fresh invite link?\n\nThe link currently shown will stop working — anyone you haven\'t given the new one to will need it re-sent.'
|
|
1152
|
+
);
|
|
1153
|
+
if (!ok) return;
|
|
1154
|
+
const banner = row.querySelector('.invite-banner');
|
|
1155
|
+
// Show a terminal pill inside the banner while we rotate.
|
|
1156
|
+
const note = banner.querySelector('.invite-url');
|
|
1157
|
+
const original = note.outerHTML;
|
|
1158
|
+
const pill = el(`<span class="term-pill" role="status">
|
|
1159
|
+
<span class="term-prompt">$</span>
|
|
1160
|
+
<span class="term-cmd">bitpub group invite ${escapeHtml(slug)}</span>
|
|
1161
|
+
<span class="term-dots"><span></span><span></span><span></span></span>
|
|
1162
|
+
</span>`);
|
|
1163
|
+
note.replaceWith(pill);
|
|
1164
|
+
try {
|
|
1165
|
+
const r = await groupsApi.invite(slug, { rotate: true });
|
|
1166
|
+
if (!r || !r.invite_url) throw new Error('server returned no invite_url');
|
|
1167
|
+
const fresh = el(`<div class="invite-url" title="${escapeHtml(r.invite_url)}">${escapeHtml(r.invite_url)}</div>`);
|
|
1168
|
+
pill.replaceWith(fresh);
|
|
1169
|
+
await copyToClipboard(r.invite_url);
|
|
1170
|
+
flashBannerCopied(banner, true);
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
pill.classList.add('is-error');
|
|
1173
|
+
pill.innerHTML = `<span class="term-prompt">$</span><span class="term-cmd">bitpub group invite ${escapeHtml(slug)}</span><span class="term-result">→ ${escapeHtml(err.message || 'rotation failed')}</span>`;
|
|
1174
|
+
setTimeout(() => {
|
|
1175
|
+
const restored = el(original);
|
|
1176
|
+
pill.replaceWith(restored);
|
|
1177
|
+
}, 4000);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function setCreatingBusy(busy) {
|
|
1182
|
+
const btn = document.getElementById('team-create-btn');
|
|
1183
|
+
const input = document.getElementById('team-name');
|
|
1184
|
+
if (busy) {
|
|
1185
|
+
btn.classList.add('busy');
|
|
1186
|
+
btn.disabled = true;
|
|
1187
|
+
input.disabled = true;
|
|
1188
|
+
btn.querySelector('.btn-label').textContent = 'Creating…';
|
|
1189
|
+
} else {
|
|
1190
|
+
btn.classList.remove('busy');
|
|
1191
|
+
btn.disabled = false;
|
|
1192
|
+
input.disabled = false;
|
|
1193
|
+
btn.querySelector('.btn-label').textContent = 'Create group';
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async function submitCreateGroup() {
|
|
1198
|
+
showTeamError('');
|
|
1199
|
+
const input = document.getElementById('team-name');
|
|
1200
|
+
const display = (input.value || '').trim();
|
|
1201
|
+
if (!display) {
|
|
1202
|
+
showTeamError('Give your group a name first.');
|
|
1203
|
+
input.focus();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (!groupsApi) {
|
|
1207
|
+
showTeamError('Group management isn\'t available in this context.');
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
setCreatingBusy(true);
|
|
1211
|
+
try {
|
|
1212
|
+
const { group, invite_url } = await groupsApi.create(display);
|
|
1213
|
+
if (group && group.slug) lastCreatedSlug = group.slug;
|
|
1214
|
+
input.value = '';
|
|
1215
|
+
await loadGroups();
|
|
1216
|
+
// The create response already includes the invite URL — show
|
|
1217
|
+
// it under the new row without a second round-trip and copy
|
|
1218
|
+
// it to clipboard so the user can paste it immediately.
|
|
1219
|
+
if (group && group.slug && invite_url) {
|
|
1220
|
+
const row = document.querySelector(
|
|
1221
|
+
`.group-row[data-slug="${cssEscape(group.slug)}"]`
|
|
1222
|
+
);
|
|
1223
|
+
if (row) {
|
|
1224
|
+
renderInviteBanner(row, group.slug, invite_url);
|
|
1225
|
+
const ok = await copyToClipboard(invite_url);
|
|
1226
|
+
flashBannerCopied(row.querySelector('.invite-banner'), ok);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
showTeamError(err.message || 'Could not create group');
|
|
1231
|
+
} finally {
|
|
1232
|
+
setCreatingBusy(false);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function cssEscape(s) {
|
|
1237
|
+
// Minimal CSS attribute-value escaper for our slug shape
|
|
1238
|
+
// ([a-z0-9-]+). Real CSS.escape() exists in modern browsers but
|
|
1239
|
+
// we keep this lightweight for older sandboxes.
|
|
1240
|
+
return String(s).replace(/["\\]/g, '\\$&');
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
document.getElementById('team-create-btn').addEventListener('click', submitCreateGroup);
|
|
1244
|
+
document.getElementById('team-name').addEventListener('keydown', (e) => {
|
|
1245
|
+
if (e.key === 'Enter') { e.preventDefault(); submitCreateGroup(); }
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// Auto-load on mount. Tiny delay so the parent's bridge router
|
|
1249
|
+
// has wired up before we start postMessage-ing — defensive only;
|
|
1250
|
+
// the shim queues internally.
|
|
1251
|
+
setTimeout(loadGroups, 50);
|
|
572
1252
|
</script>
|
|
573
1253
|
</body>
|
|
574
1254
|
</html>
|