@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.
@@ -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> &mdash; 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">&mdash;</span>
755
+ </div>
756
+ <div class="team-list" id="team-list">
757
+ <div class="team-empty">Loading your groups&hellip;</div>
758
+ </div>
759
+ </div>
760
+
398
761
  <!-- ═══════ Actions ═══════ -->
399
762
  <div class="section-h">
400
- <h2>Three things to try</h2>
763
+ <h2>Two things to try</h2>
401
764
  <span class="meta">each takes under a minute &middot; 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: create a group ── -->
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 &mdash; 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 &lt;link&gt;</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, '&amp;')
940
+ .replace(/</g, '&lt;')
941
+ .replace(/>/g, '&gt;')
942
+ .replace(/"/g, '&quot;')
943
+ .replace(/'/g, '&#39;');
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>