@bitpub/cli 2.1.2 → 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 +310 -0
- package/src/commands/group.js +40 -3
- package/src/commands/sync.js +18 -13
- package/static/console.html +340 -5
- package/static/welcome.html +705 -25
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>
|