@bitpub/cli 2.0.3 → 2.0.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 +2 -2
- package/src/commands/browser.js +89 -15
- package/src/commands/welcome.js +60 -7
- package/static/assets/BITPUB-dark.png +0 -0
- package/static/assets/tollbit-icon.png +0 -0
- package/static/console.html +604 -129
- package/static/welcome.html +574 -0
package/static/console.html
CHANGED
|
@@ -61,27 +61,99 @@ svg { flex-shrink: 0; }
|
|
|
61
61
|
/* ── Layout shell ───────────────────────────────────── */
|
|
62
62
|
#app { display: flex; flex-direction: column; height: 100vh; }
|
|
63
63
|
|
|
64
|
-
/* ──
|
|
64
|
+
/* ── Browser shell: address bar + nav controls ───────
|
|
65
|
+
Treated like Chrome's omnibox. The URL bar is a single rounded pill
|
|
66
|
+
that holds either the read-only breadcrumb (default) or a typeable
|
|
67
|
+
text input (on click/focus). Back/forward/home sit outside the pill,
|
|
68
|
+
left-aligned, exactly like a desktop browser. */
|
|
65
69
|
.addressbar {
|
|
66
|
-
height:
|
|
67
|
-
display: flex; align-items: center; gap:
|
|
70
|
+
height: 52px; flex-shrink: 0;
|
|
71
|
+
display: flex; align-items: center; gap: 10px;
|
|
68
72
|
padding: 0 14px;
|
|
69
73
|
background: var(--bg-card);
|
|
70
74
|
border-bottom: 1px solid var(--border);
|
|
71
75
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
/* Left zone holds the wordmark + nav-controls and is sized to align
|
|
77
|
+
the URL bar's left edge with the main content panel's left edge
|
|
78
|
+
below (i.e. just past the sidebar's right edge). The total width
|
|
79
|
+
below was tuned for a 264px sidebar: 14 (addressbar left padding)
|
|
80
|
+
+ 240 (this zone) + 10 (gap) = 264. If the sidebar width changes,
|
|
81
|
+
keep this in sync.
|
|
82
|
+
|
|
83
|
+
The wordmark is pinned to the far left and the nav-controls to
|
|
84
|
+
the far right so the back/forward/home buttons sit flush against
|
|
85
|
+
the URL bar's left edge. */
|
|
86
|
+
.addressbar-left {
|
|
87
|
+
display: flex; align-items: center;
|
|
88
|
+
justify-content: space-between;
|
|
89
|
+
width: 240px; flex-shrink: 0;
|
|
90
|
+
}
|
|
91
|
+
/* The wordmark uses the same two assets as the marketing nav so the
|
|
92
|
+
in-app browser has consistent brand identity with bitpub.io.
|
|
93
|
+
Sized down for the 52px addressbar: a 20px icon + 28px wordmark
|
|
94
|
+
leaves comfortable vertical padding. The wordmark .name / .soft /
|
|
95
|
+
.mark rules are kept around (as no-ops here) for any legacy callers
|
|
96
|
+
of the wordmark structure. */
|
|
97
|
+
.wordmark { display: inline-flex; align-items: center; gap: 6px; color: var(--text); white-space: nowrap; cursor: pointer; text-decoration: none; }
|
|
98
|
+
.wordmark .nav-icon { display: block; height: 20px; width: auto; flex-shrink: 0; }
|
|
99
|
+
.wordmark .nav-wordmark { display: block; height: 28px; width: auto; flex-shrink: 0; }
|
|
100
|
+
|
|
101
|
+
/* Nav controls (back / forward / home) */
|
|
102
|
+
.nav-controls { display: inline-flex; align-items: center; gap: 2px; }
|
|
103
|
+
.nav-btn {
|
|
104
|
+
width: 28px; height: 28px; flex-shrink: 0;
|
|
105
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
106
|
+
border: 0; border-radius: 50%;
|
|
107
|
+
background: transparent; color: var(--text-muted);
|
|
108
|
+
cursor: pointer; transition: background .1s, color .1s;
|
|
109
|
+
}
|
|
110
|
+
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
|
111
|
+
.nav-btn:active:not(:disabled) { background: var(--bg-inset); }
|
|
112
|
+
.nav-btn:disabled { color: var(--text-subtle); opacity: .45; cursor: default; }
|
|
113
|
+
|
|
114
|
+
/* URL bar — the single pill that holds breadcrumb or typeable input */
|
|
115
|
+
.url-bar {
|
|
116
|
+
flex: 1; min-width: 0;
|
|
117
|
+
display: flex; align-items: center; gap: 6px;
|
|
118
|
+
height: 34px;
|
|
119
|
+
padding: 0 4px 0 10px;
|
|
120
|
+
background: var(--bg-inset);
|
|
121
|
+
border: 1px solid transparent;
|
|
122
|
+
border-radius: 100px;
|
|
123
|
+
transition: background .12s, border-color .12s, box-shadow .12s;
|
|
124
|
+
cursor: text;
|
|
125
|
+
}
|
|
126
|
+
.url-bar:hover { background: var(--bg-hover); }
|
|
127
|
+
.url-bar.editing,
|
|
128
|
+
.url-bar:focus-within {
|
|
129
|
+
background: var(--bg-card);
|
|
130
|
+
border-color: var(--accent);
|
|
131
|
+
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
132
|
+
}
|
|
77
133
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.mode-badge
|
|
134
|
+
/* Site-info chip on the left edge of the URL bar (replaces the standalone
|
|
135
|
+
mode badge). When local-decrypted we tint it with the lock color to
|
|
136
|
+
echo a browser's https indicator. */
|
|
137
|
+
.mode-badge {
|
|
138
|
+
width: 22px; height: 22px; flex-shrink: 0;
|
|
139
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
140
|
+
border-radius: 50%;
|
|
141
|
+
background: transparent;
|
|
142
|
+
color: var(--text-subtle);
|
|
143
|
+
cursor: default;
|
|
144
|
+
}
|
|
145
|
+
.mode-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-subtle); }
|
|
146
|
+
.mode-badge.local { color: var(--scope-private); }
|
|
147
|
+
.mode-badge.local .dot { background: var(--scope-private); }
|
|
82
148
|
|
|
83
|
-
|
|
84
|
-
.address
|
|
149
|
+
/* Breadcrumb (display mode of the URL bar) */
|
|
150
|
+
.address {
|
|
151
|
+
flex: 1; min-width: 0;
|
|
152
|
+
display: flex; align-items: center; gap: 2px;
|
|
153
|
+
font-family: var(--mono); font-size: 12.5px; color: var(--text-muted);
|
|
154
|
+
overflow: hidden; white-space: nowrap;
|
|
155
|
+
}
|
|
156
|
+
.address .seg { padding: 2px 6px; border-radius: var(--radius-sm); cursor: pointer; color: var(--accent); transition: background .1s, color .1s; }
|
|
85
157
|
.address .seg:hover { background: var(--accent-soft); }
|
|
86
158
|
.address .seg.scheme { color: var(--text-subtle); cursor: default; font-weight: 500; }
|
|
87
159
|
.address .seg.scheme:hover { background: transparent; }
|
|
@@ -89,39 +161,34 @@ svg { flex-shrink: 0; }
|
|
|
89
161
|
.address .seg.current:hover { background: transparent; }
|
|
90
162
|
.address .sep { color: var(--text-subtle); padding: 0 1px; user-select: none; }
|
|
91
163
|
.address .glob { color: var(--text-subtle); font-weight: 500; padding-left: 4px; }
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
border-bottom: 1px solid var(--border);
|
|
121
|
-
overflow-x: auto;
|
|
122
|
-
}
|
|
123
|
-
.filter-strip::-webkit-scrollbar { display: none; }
|
|
124
|
-
.filter-label { font-size: 11px; color: var(--text-subtle); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; white-space: nowrap; }
|
|
164
|
+
.address .placeholder { color: var(--text-subtle); font-style: italic; font-family: var(--font); font-size: 12.5px; }
|
|
165
|
+
|
|
166
|
+
/* Typeable URL input (edit mode of the URL bar) */
|
|
167
|
+
.address-input {
|
|
168
|
+
flex: 1; min-width: 0;
|
|
169
|
+
height: 26px;
|
|
170
|
+
padding: 0 6px;
|
|
171
|
+
background: transparent; border: 0; outline: none;
|
|
172
|
+
font-family: var(--mono); font-size: 12.5px; color: var(--text);
|
|
173
|
+
}
|
|
174
|
+
.address-input::placeholder { color: var(--text-subtle); font-family: var(--font); font-style: italic; }
|
|
175
|
+
|
|
176
|
+
.address-edit-btn,
|
|
177
|
+
.address-copy-btn {
|
|
178
|
+
width: 24px; height: 24px; flex-shrink: 0;
|
|
179
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
180
|
+
border: 0; border-radius: 50%;
|
|
181
|
+
background: transparent; color: var(--text-subtle);
|
|
182
|
+
cursor: pointer; transition: background .1s, color .1s;
|
|
183
|
+
}
|
|
184
|
+
.address-edit-btn { margin-left: auto; }
|
|
185
|
+
.address-edit-btn:hover,
|
|
186
|
+
.address-copy-btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
187
|
+
.url-bar.editing .address-edit-btn,
|
|
188
|
+
.url-bar.editing .address-copy-btn { display: none; }
|
|
189
|
+
.address-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
|
190
|
+
|
|
191
|
+
/* ── Tag pills (reused in slice view for tag chips) ────── */
|
|
125
192
|
.pill-group { display: inline-flex; gap: 4px; }
|
|
126
193
|
.pill {
|
|
127
194
|
display: inline-flex; align-items: center; gap: 5px;
|
|
@@ -141,10 +208,6 @@ svg { flex-shrink: 0; }
|
|
|
141
208
|
.pill.tag .x { color: var(--text-subtle); margin-left: 2px; font-weight: 700; }
|
|
142
209
|
.pill.tag:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
143
210
|
.pill.tag:hover .x { color: #fff; }
|
|
144
|
-
.filter-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
|
|
145
|
-
.filter-count { margin-left: auto; font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
146
|
-
.filter-count .n { color: var(--text); font-weight: 600; }
|
|
147
|
-
|
|
148
211
|
/* ── Body layout ────────────────────────────────────── */
|
|
149
212
|
.layout { display: flex; flex: 1; min-height: 0; }
|
|
150
213
|
#sidebar { width: 264px; min-width: 264px; background: var(--bg-card); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
|
@@ -385,21 +448,6 @@ svg { flex-shrink: 0; }
|
|
|
385
448
|
}
|
|
386
449
|
|
|
387
450
|
/* ── Namespace panel ────────────────────────────────── */
|
|
388
|
-
.hcu-hero {
|
|
389
|
-
display: flex; align-items: center; gap: 10px;
|
|
390
|
-
padding: 10px 14px;
|
|
391
|
-
background: var(--bg-card); border: 1px solid var(--border);
|
|
392
|
-
border-radius: var(--radius);
|
|
393
|
-
font-family: var(--mono); font-size: 12.5px;
|
|
394
|
-
margin-bottom: 12px;
|
|
395
|
-
}
|
|
396
|
-
.hcu-hero .lock { color: var(--scope-private); }
|
|
397
|
-
.hcu-hero .uri { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
|
398
|
-
.hcu-hero .uri .scheme { color: var(--text-subtle); }
|
|
399
|
-
.hcu-hero .uri .glob { color: var(--text-subtle); padding-left: 2px; }
|
|
400
|
-
.hcu-hero .copy-btn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-muted); cursor: pointer; font-family: var(--font); transition: background .1s, color .1s; }
|
|
401
|
-
.hcu-hero .copy-btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
402
|
-
|
|
403
451
|
.stats-strip { display: flex; align-items: center; gap: 16px; padding: 0 2px 12px; font-size: 12px; color: var(--text-muted); flex-wrap: wrap; }
|
|
404
452
|
.stats-strip .s-item { display: inline-flex; align-items: center; gap: 5px; }
|
|
405
453
|
.stats-strip .s-item .n { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
@@ -781,6 +829,48 @@ svg { flex-shrink: 0; }
|
|
|
781
829
|
/* Subtle stream subtitle */
|
|
782
830
|
.stream-sub { font-size: 11.5px; color: var(--text-subtle); margin: -6px 0 10px 2px; font-weight: 400; letter-spacing: 0; text-transform: none; }
|
|
783
831
|
|
|
832
|
+
/* ── Sandboxed app frame (slice view, text/html payloads) ────────────
|
|
833
|
+
When a slice's content type is HTML and the scope is private/group,
|
|
834
|
+
the content dispatcher renders it inside a sandboxed iframe. The
|
|
835
|
+
frame fills the blob container's horizontal space and grows to a
|
|
836
|
+
workable default height (the app can be taller; the user scrolls
|
|
837
|
+
the main pane to follow). */
|
|
838
|
+
.app-frame {
|
|
839
|
+
display: block;
|
|
840
|
+
width: 100%;
|
|
841
|
+
min-height: 480px;
|
|
842
|
+
border: 0;
|
|
843
|
+
background: var(--bg-page);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.app-badge {
|
|
847
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
848
|
+
font-size: 10.5px; font-weight: 600;
|
|
849
|
+
padding: 2px 8px;
|
|
850
|
+
border: 1px solid rgba(124, 58, 237, .25);
|
|
851
|
+
background: rgba(124, 58, 237, .06);
|
|
852
|
+
color: var(--scope-private);
|
|
853
|
+
border-radius: 100px;
|
|
854
|
+
white-space: nowrap;
|
|
855
|
+
cursor: help;
|
|
856
|
+
}
|
|
857
|
+
.app-badge svg { color: var(--scope-private); }
|
|
858
|
+
|
|
859
|
+
.app-public-note {
|
|
860
|
+
display: flex; align-items: flex-start; gap: 10px;
|
|
861
|
+
padding: 10px 14px;
|
|
862
|
+
background: var(--bg-canvas);
|
|
863
|
+
border-bottom: 1px solid var(--border-soft);
|
|
864
|
+
font-size: 12px; color: var(--text-muted);
|
|
865
|
+
line-height: 1.5;
|
|
866
|
+
}
|
|
867
|
+
.app-public-note svg { color: var(--text-subtle); flex-shrink: 0; margin-top: 1px; }
|
|
868
|
+
.app-public-note code {
|
|
869
|
+
font-family: var(--mono); font-size: 11.5px;
|
|
870
|
+
background: var(--bg-card); padding: 1px 5px; border-radius: 3px;
|
|
871
|
+
border: 1px solid var(--border-soft); color: var(--text);
|
|
872
|
+
}
|
|
873
|
+
|
|
784
874
|
.hidden { display: none !important; }
|
|
785
875
|
</style>
|
|
786
876
|
</head>
|
|
@@ -799,25 +889,45 @@ svg { flex-shrink: 0; }
|
|
|
799
889
|
</div>
|
|
800
890
|
</div>
|
|
801
891
|
|
|
802
|
-
<!-- Address bar -->
|
|
892
|
+
<!-- Address bar (browser-shell layout) -->
|
|
803
893
|
<header class="addressbar">
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
894
|
+
<!-- Left zone: wordmark + nav controls. Sized to match the sidebar
|
|
895
|
+
width below so the URL bar starts at the same x as the main
|
|
896
|
+
content panel. -->
|
|
897
|
+
<div class="addressbar-left">
|
|
898
|
+
<div class="wordmark" onclick="goRoot()" title="bitpub home" role="link" aria-label="bitpub home">
|
|
899
|
+
<img class="nav-icon" src="/assets/tollbit-icon.png" alt="" aria-hidden="true">
|
|
900
|
+
<img class="nav-wordmark" src="/assets/BITPUB-dark.png" alt="BitPub">
|
|
901
|
+
</div>
|
|
902
|
+
|
|
903
|
+
<div class="nav-controls">
|
|
904
|
+
<button id="nav-back" class="nav-btn" onclick="goBack()" title="Back" disabled>
|
|
905
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/></svg>
|
|
906
|
+
</button>
|
|
907
|
+
<button id="nav-forward" class="nav-btn" onclick="goForward()" title="Forward" disabled>
|
|
908
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 11-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/></svg>
|
|
909
|
+
</button>
|
|
910
|
+
<button id="nav-home" class="nav-btn" onclick="goRoot()" title="Home">
|
|
911
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6.906.664a1.749 1.749 0 012.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0113.25 15h-3.5a.75.75 0 01-.75-.75V9.5h-2v4.75a.75.75 0 01-.75.75h-3.5A1.75 1.75 0 011 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2zm1.25 1.171a.25.25 0 00-.312 0l-5.25 4.2a.25.25 0 00-.094.196v7.019c0 .138.112.25.25.25H5v-4.75a.75.75 0 01.75-.75h3.5a.75.75 0 01.75.75v4.75h2.75a.25.25 0 00.25-.25V6.23a.25.25 0 00-.094-.195l-5.25-4.2z"/></svg>
|
|
912
|
+
</button>
|
|
913
|
+
</div>
|
|
807
914
|
</div>
|
|
808
|
-
|
|
809
|
-
<div
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
<input id="
|
|
813
|
-
|
|
814
|
-
|
|
915
|
+
|
|
916
|
+
<div class="url-bar" id="url-bar">
|
|
917
|
+
<span id="mode-badge" class="mode-badge" title="Connection mode"><span class="dot"></span></span>
|
|
918
|
+
<div id="address" class="address" onclick="enterAddressEdit()"></div>
|
|
919
|
+
<input id="address-input" class="address-input hidden" type="text"
|
|
920
|
+
spellcheck="false" autocomplete="off"
|
|
921
|
+
placeholder="Enter a bitpub:// URL, /path, or search">
|
|
922
|
+
<button id="address-edit-btn" class="address-edit-btn" onclick="enterAddressEdit()" title="Edit address">
|
|
923
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064L11.189 6.25z"/></svg>
|
|
924
|
+
</button>
|
|
925
|
+
<button id="address-copy-btn" class="address-copy-btn" onclick="copyAddressUri(this)" title="Copy URI">
|
|
926
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>
|
|
927
|
+
</button>
|
|
815
928
|
</div>
|
|
816
929
|
</header>
|
|
817
930
|
|
|
818
|
-
<!-- Filter strip -->
|
|
819
|
-
<div id="filter-strip" class="filter-strip"></div>
|
|
820
|
-
|
|
821
931
|
<!-- Body -->
|
|
822
932
|
<div class="layout">
|
|
823
933
|
<aside id="sidebar">
|
|
@@ -873,6 +983,11 @@ const ICON = {
|
|
|
873
983
|
chev: '<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/></svg>',
|
|
874
984
|
lock: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M4 4v2h-.25A1.75 1.75 0 002 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 13.25v-5.5A1.75 1.75 0 0012.25 6H12V4a4 4 0 00-8 0zm6.5 2V4a2.5 2.5 0 00-5 0v2h5z"/></svg>',
|
|
875
985
|
copy: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>',
|
|
986
|
+
// Same copy glyph reused as the icon for the address-bar Copy URI
|
|
987
|
+
// button; kept under a distinct key so its dimensions can drift
|
|
988
|
+
// (currently identical to ICON.copy).
|
|
989
|
+
copyLg: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>',
|
|
990
|
+
check: '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.751.751 0 01.018-1.042.751.751 0 011.042-.018L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg>',
|
|
876
991
|
hex: '<svg class="hex" viewBox="0 0 12 12" fill="currentColor"><path d="M6 0L11.196 3V9L6 12L0.804 9V3L6 0Z"/></svg>',
|
|
877
992
|
search: '<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z"/></svg>',
|
|
878
993
|
info: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M0 8a8 8 0 1116 0A8 8 0 010 8zm8-6.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM6.5 7.75A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"/></svg>',
|
|
@@ -887,6 +1002,7 @@ async function boot() {
|
|
|
887
1002
|
const data = await fetchData();
|
|
888
1003
|
if (!data) return;
|
|
889
1004
|
ingestData(data);
|
|
1005
|
+
maybeOpenWelcomeSlice();
|
|
890
1006
|
renderAll();
|
|
891
1007
|
setInterval(pollData, 30000);
|
|
892
1008
|
document.addEventListener('visibilitychange', () => {
|
|
@@ -913,18 +1029,43 @@ function findWelcomeSlice(slices) {
|
|
|
913
1029
|
}
|
|
914
1030
|
|
|
915
1031
|
function shouldShowWelcomePanel(slices) {
|
|
916
|
-
//
|
|
917
|
-
//
|
|
1032
|
+
// The Welcome slice is now itself an HTML app (the entire welcome
|
|
1033
|
+
// experience is rendered from inside the slice's sandboxed iframe).
|
|
1034
|
+
// So we only show this fallback panel during the install.sh boot race:
|
|
1035
|
+
// the page was opened with ?welcome=1 *before* the welcome push landed
|
|
1036
|
+
// in the local cache. `maybeOpenWelcomeSlice()` auto-navigates to the
|
|
1037
|
+
// slice as soon as it appears, at which point this panel disappears
|
|
1038
|
+
// and the slice's own app takes over.
|
|
918
1039
|
const params = new URLSearchParams(location.search);
|
|
919
|
-
if (params.get('welcome')
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1040
|
+
if (params.get('welcome') !== '1') return false;
|
|
1041
|
+
const all = slices || S.slices;
|
|
1042
|
+
const hasWelcome = (all || []).some(s => WELCOME_RE.test(s.hcu));
|
|
1043
|
+
return !hasWelcome;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* One-shot navigator: when the page is loaded with `?welcome=1` (the URL
|
|
1048
|
+
* install.sh opens) and the Welcome slice is already in the cache, jump
|
|
1049
|
+
* straight to the slice view so the user lands in the welcome HTML app
|
|
1050
|
+
* instead of the recent-writes overview. Idempotent — runs at most once
|
|
1051
|
+
* per page load.
|
|
1052
|
+
*/
|
|
1053
|
+
function maybeOpenWelcomeSlice() {
|
|
1054
|
+
if (S.welcomeOpened) return;
|
|
1055
|
+
const params = new URLSearchParams(location.search);
|
|
1056
|
+
if (params.get('welcome') !== '1') return;
|
|
1057
|
+
const wel = findWelcomeSlice();
|
|
1058
|
+
if (!wel) return;
|
|
1059
|
+
S.welcomeOpened = true;
|
|
1060
|
+
// Mirror selectSlice() without an extra renderAll — boot is about to
|
|
1061
|
+
// call renderAll itself.
|
|
1062
|
+
const { scope } = scopeOf(wel.hcu);
|
|
1063
|
+
S.view = 'slice';
|
|
1064
|
+
S.currentScope = scope;
|
|
1065
|
+
S.currentPath = pathOf(wel.hcu);
|
|
1066
|
+
S.selectedHcu = wel.hcu;
|
|
1067
|
+
S.searchQuery = '';
|
|
1068
|
+
S.raw = false;
|
|
928
1069
|
}
|
|
929
1070
|
|
|
930
1071
|
function renderWelcomePanel() {
|
|
@@ -1045,13 +1186,22 @@ async function pollData() {
|
|
|
1045
1186
|
triggerLivePulse();
|
|
1046
1187
|
setTimeout(() => { S.newArrivals = new Set(); }, 2500);
|
|
1047
1188
|
}
|
|
1189
|
+
// Boot-race recovery: if install.sh opened us with ?welcome=1 before
|
|
1190
|
+
// the welcome push had synced down, the slice may show up here on the
|
|
1191
|
+
// first poll. Take the user to it as soon as it appears.
|
|
1192
|
+
maybeOpenWelcomeSlice();
|
|
1048
1193
|
renderAll();
|
|
1049
1194
|
}
|
|
1050
1195
|
|
|
1051
1196
|
function triggerLivePulse() {
|
|
1052
|
-
|
|
1197
|
+
// Live indicator was removed from the address bar; keep this as a
|
|
1198
|
+
// no-op so the polling loop doesn't crash and so we can re-introduce
|
|
1199
|
+
// a freshness indicator later without re-wiring callers.
|
|
1200
|
+
const el = $('live');
|
|
1201
|
+
if (!el) return;
|
|
1202
|
+
el.classList.add('pulse');
|
|
1053
1203
|
clearTimeout(triggerLivePulse._t);
|
|
1054
|
-
triggerLivePulse._t = setTimeout(() =>
|
|
1204
|
+
triggerLivePulse._t = setTimeout(() => el.classList.remove('pulse'), 4000);
|
|
1055
1205
|
}
|
|
1056
1206
|
|
|
1057
1207
|
function showAuth() {
|
|
@@ -1066,7 +1216,7 @@ async function submitAuth() {
|
|
|
1066
1216
|
sessionStorage.setItem('bitpub_key', key);
|
|
1067
1217
|
$('auth-error').classList.add('hidden');
|
|
1068
1218
|
const data = await fetchData();
|
|
1069
|
-
if (data) { $('auth-overlay').classList.add('hidden'); ingestData(data); renderAll(); }
|
|
1219
|
+
if (data) { $('auth-overlay').classList.add('hidden'); ingestData(data); maybeOpenWelcomeSlice(); renderAll(); }
|
|
1070
1220
|
else { $('auth-error').textContent = 'Invalid API key'; $('auth-error').classList.remove('hidden'); S.apiKey = null; sessionStorage.removeItem('bitpub_key'); }
|
|
1071
1221
|
}
|
|
1072
1222
|
|
|
@@ -1252,30 +1402,29 @@ function clearFilter() {
|
|
|
1252
1402
|
function renderAll() {
|
|
1253
1403
|
renderModeBadge();
|
|
1254
1404
|
renderAddress();
|
|
1255
|
-
renderLive();
|
|
1256
|
-
renderFilterStrip();
|
|
1257
1405
|
renderTree();
|
|
1258
1406
|
renderPanel();
|
|
1259
1407
|
$('clear-filter-btn').classList.toggle('hidden', !filterActive());
|
|
1260
1408
|
}
|
|
1261
1409
|
|
|
1262
1410
|
function renderModeBadge() {
|
|
1411
|
+
// The mode badge now lives inside the URL bar as a dot-only indicator
|
|
1412
|
+
// (the verbose label moved into a tooltip). Tinted private-purple in
|
|
1413
|
+
// local-decrypted mode to echo a browser's https indicator.
|
|
1263
1414
|
const el = $('mode-badge');
|
|
1264
1415
|
if (S.mode === 'local') {
|
|
1265
1416
|
el.className = 'mode-badge local';
|
|
1266
|
-
el.
|
|
1417
|
+
el.setAttribute('title', 'Local cache · private slices decrypted on this machine');
|
|
1267
1418
|
} else {
|
|
1268
1419
|
el.className = 'mode-badge';
|
|
1269
|
-
el.
|
|
1420
|
+
el.setAttribute('title', 'Remote · private slices appear as ciphertext');
|
|
1270
1421
|
}
|
|
1271
1422
|
}
|
|
1272
1423
|
|
|
1273
1424
|
function renderAddress() {
|
|
1274
1425
|
const el = $('address');
|
|
1275
1426
|
let html = '<span class="seg scheme">bitpub://</span>';
|
|
1276
|
-
if (S.
|
|
1277
|
-
html += '<span class="glob">**</span>';
|
|
1278
|
-
} else if (S.currentScope) {
|
|
1427
|
+
if (S.currentScope) {
|
|
1279
1428
|
html += `<span class="sep">/</span><span class="seg" onclick="navigateTo('${escAttr(S.currentScope)}', [])">${esc(S.currentScope)}</span>`;
|
|
1280
1429
|
const isSlice = S.view === 'slice';
|
|
1281
1430
|
S.currentPath.forEach((seg, i) => {
|
|
@@ -1290,15 +1439,18 @@ function renderAddress() {
|
|
|
1290
1439
|
html += `<span class="${className}" onclick="navigateTo('${escAttr(S.currentScope)}', ${JSON.stringify(subPath).replace(/"/g, '"')})">${esc(seg)}</span>`;
|
|
1291
1440
|
}
|
|
1292
1441
|
});
|
|
1293
|
-
if (!isSlice) html += '<span class="glob">/**</span>';
|
|
1294
1442
|
}
|
|
1295
1443
|
el.innerHTML = html;
|
|
1296
1444
|
}
|
|
1297
1445
|
|
|
1298
1446
|
function renderLive() {
|
|
1447
|
+
// Live indicator was removed from the address bar in the browser-shell
|
|
1448
|
+
// refactor; this is a no-op kept around so legacy callers don't crash.
|
|
1299
1449
|
const el = $('live');
|
|
1450
|
+
if (!el) return;
|
|
1300
1451
|
const label = S.lastUpdated ? `updated ${timeAgo(S.lastUpdated)}` : 'no activity';
|
|
1301
|
-
el.querySelector('.label')
|
|
1452
|
+
const lbl = el.querySelector('.label');
|
|
1453
|
+
if (lbl) lbl.textContent = label;
|
|
1302
1454
|
}
|
|
1303
1455
|
|
|
1304
1456
|
function renderFilterStrip() {
|
|
@@ -1481,6 +1633,233 @@ function onTreeFolderClick(ev, scope, path) {
|
|
|
1481
1633
|
navigateTo(scope, path);
|
|
1482
1634
|
}
|
|
1483
1635
|
|
|
1636
|
+
/* ══════════════════════════════════════════════════════
|
|
1637
|
+
Address bar — typeable URL input + history stack
|
|
1638
|
+
|
|
1639
|
+
Navigation history is browser-style: pushHistory() snapshots the
|
|
1640
|
+
minimum nav state needed to re-render the right view, and back/
|
|
1641
|
+
forward replay snapshots without re-pushing. Initial load lands on
|
|
1642
|
+
"overview" with history[0] = root, so Back from the first navigation
|
|
1643
|
+
takes the user home rather than dead-ending.
|
|
1644
|
+
══════════════════════════════════════════════════════ */
|
|
1645
|
+
S.history = [];
|
|
1646
|
+
S.historyIndex = -1;
|
|
1647
|
+
S.historyMuted = false; // true while replaying back/forward
|
|
1648
|
+
|
|
1649
|
+
function snapshotNav() {
|
|
1650
|
+
return {
|
|
1651
|
+
view: S.view,
|
|
1652
|
+
currentScope: S.currentScope,
|
|
1653
|
+
currentPath: S.currentPath.slice(),
|
|
1654
|
+
selectedHcu: S.selectedHcu,
|
|
1655
|
+
searchQuery: S.searchQuery,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function applyNav(snap) {
|
|
1660
|
+
S.view = snap.view;
|
|
1661
|
+
S.currentScope = snap.currentScope;
|
|
1662
|
+
S.currentPath = snap.currentPath.slice();
|
|
1663
|
+
S.selectedHcu = snap.selectedHcu;
|
|
1664
|
+
S.searchQuery = snap.searchQuery || '';
|
|
1665
|
+
S.raw = false;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function navSnapEq(a, b) {
|
|
1669
|
+
return a.view === b.view
|
|
1670
|
+
&& a.currentScope === b.currentScope
|
|
1671
|
+
&& arrayEq(a.currentPath, b.currentPath)
|
|
1672
|
+
&& a.selectedHcu === b.selectedHcu
|
|
1673
|
+
&& (a.searchQuery || '') === (b.searchQuery || '');
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function pushHistory() {
|
|
1677
|
+
if (S.historyMuted) return;
|
|
1678
|
+
if (S.historyIndex < S.history.length - 1) {
|
|
1679
|
+
// Branching off a back-state truncates forward history, same as a browser.
|
|
1680
|
+
S.history = S.history.slice(0, S.historyIndex + 1);
|
|
1681
|
+
}
|
|
1682
|
+
const snap = snapshotNav();
|
|
1683
|
+
const top = S.history[S.history.length - 1];
|
|
1684
|
+
if (top && navSnapEq(top, snap)) return; // collapse no-op pushes
|
|
1685
|
+
S.history.push(snap);
|
|
1686
|
+
S.historyIndex = S.history.length - 1;
|
|
1687
|
+
updateNavButtons();
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function updateNavButtons() {
|
|
1691
|
+
const back = $('nav-back');
|
|
1692
|
+
const fwd = $('nav-forward');
|
|
1693
|
+
if (back) back.disabled = S.historyIndex <= 0;
|
|
1694
|
+
if (fwd) fwd.disabled = S.historyIndex >= S.history.length - 1;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function goBack() {
|
|
1698
|
+
if (S.historyIndex <= 0) return;
|
|
1699
|
+
S.historyIndex--;
|
|
1700
|
+
S.historyMuted = true;
|
|
1701
|
+
applyNav(S.history[S.historyIndex]);
|
|
1702
|
+
renderAll();
|
|
1703
|
+
updateNavButtons();
|
|
1704
|
+
S.historyMuted = false;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function goForward() {
|
|
1708
|
+
if (S.historyIndex >= S.history.length - 1) return;
|
|
1709
|
+
S.historyIndex++;
|
|
1710
|
+
S.historyMuted = true;
|
|
1711
|
+
applyNav(S.history[S.historyIndex]);
|
|
1712
|
+
renderAll();
|
|
1713
|
+
updateNavButtons();
|
|
1714
|
+
S.historyMuted = false;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Pick the "your private root" scope for leading-slash resolution. We
|
|
1718
|
+
// take the most common private scope in the local cache. If the user
|
|
1719
|
+
// has no private slices yet, leading-slash input falls through to search.
|
|
1720
|
+
function ownerScope() {
|
|
1721
|
+
const counts = {};
|
|
1722
|
+
for (const s of S.slices) {
|
|
1723
|
+
const sc = scopeOf(s.hcu);
|
|
1724
|
+
if (sc.type === 'private') counts[sc.scope] = (counts[sc.scope] || 0) + 1;
|
|
1725
|
+
}
|
|
1726
|
+
let best = null, bestN = 0;
|
|
1727
|
+
for (const k of Object.keys(counts)) {
|
|
1728
|
+
if (counts[k] > bestN) { best = k; bestN = counts[k]; }
|
|
1729
|
+
}
|
|
1730
|
+
return best;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// Build the canonical bitpub:// string for the current view, used as
|
|
1734
|
+
// the initial value of the typeable input and the target of Copy URI.
|
|
1735
|
+
// We don't append wildcard suffixes here — pasting the URL back into
|
|
1736
|
+
// the bar still works because submitAddress strips trailing '*' chars
|
|
1737
|
+
// before resolving.
|
|
1738
|
+
function currentAddressString() {
|
|
1739
|
+
if (S.view === 'overview' || !S.currentScope) return 'bitpub://';
|
|
1740
|
+
const path = S.currentPath.join('/');
|
|
1741
|
+
return `bitpub://${S.currentScope}` + (path ? '/' + path : '');
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function enterAddressEdit() {
|
|
1745
|
+
const bar = $('url-bar');
|
|
1746
|
+
const inp = $('address-input');
|
|
1747
|
+
if (!bar || !inp) return;
|
|
1748
|
+
bar.classList.add('editing');
|
|
1749
|
+
$('address').classList.add('hidden');
|
|
1750
|
+
inp.classList.remove('hidden');
|
|
1751
|
+
inp.value = currentAddressString();
|
|
1752
|
+
// setTimeout lets the click-that-triggered-edit finish before we steal focus.
|
|
1753
|
+
setTimeout(() => { inp.focus(); inp.select(); }, 0);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function exitAddressEdit() {
|
|
1757
|
+
const bar = $('url-bar');
|
|
1758
|
+
const inp = $('address-input');
|
|
1759
|
+
if (!bar || !inp) return;
|
|
1760
|
+
bar.classList.remove('editing');
|
|
1761
|
+
inp.classList.add('hidden');
|
|
1762
|
+
$('address').classList.remove('hidden');
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// Copies the canonical bitpub:// URL for whatever the user is currently
|
|
1766
|
+
// viewing. Briefly swaps the button's icon for a checkmark as feedback;
|
|
1767
|
+
// no-ops on the overview where the URL would just be "bitpub://".
|
|
1768
|
+
function copyAddressUri(btn) {
|
|
1769
|
+
const uri = currentAddressString();
|
|
1770
|
+
if (!uri || uri === 'bitpub://') return;
|
|
1771
|
+
const finish = () => {
|
|
1772
|
+
if (!btn) return;
|
|
1773
|
+
btn.classList.add('copied');
|
|
1774
|
+
btn.innerHTML = ICON.check;
|
|
1775
|
+
clearTimeout(copyAddressUri._t);
|
|
1776
|
+
copyAddressUri._t = setTimeout(() => {
|
|
1777
|
+
btn.classList.remove('copied');
|
|
1778
|
+
btn.innerHTML = ICON.copyLg;
|
|
1779
|
+
}, 1200);
|
|
1780
|
+
};
|
|
1781
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1782
|
+
navigator.clipboard.writeText(uri).then(finish).catch(() => {
|
|
1783
|
+
// Clipboard API can fail in non-secure contexts; fall back to
|
|
1784
|
+
// a textarea-based copy so the button still gives feedback.
|
|
1785
|
+
try {
|
|
1786
|
+
const ta = document.createElement('textarea');
|
|
1787
|
+
ta.value = uri; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
1788
|
+
document.body.appendChild(ta); ta.select();
|
|
1789
|
+
document.execCommand('copy');
|
|
1790
|
+
document.body.removeChild(ta);
|
|
1791
|
+
finish();
|
|
1792
|
+
} catch (_) { /* swallow */ }
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function onAddressKeyDown(e) {
|
|
1798
|
+
if (e.key === 'Enter') {
|
|
1799
|
+
e.preventDefault();
|
|
1800
|
+
submitAddress($('address-input').value);
|
|
1801
|
+
} else if (e.key === 'Escape') {
|
|
1802
|
+
e.preventDefault();
|
|
1803
|
+
exitAddressEdit();
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Resolve a typed URL-bar string and navigate. Accepts:
|
|
1809
|
+
* - full `bitpub://...` URLs (verbatim)
|
|
1810
|
+
* - leading-slash paths (resolves against your private root)
|
|
1811
|
+
* - short names that match a slice tail in cache
|
|
1812
|
+
* - any other text → falls through to search
|
|
1813
|
+
*
|
|
1814
|
+
* Trailing wildcards (`**`, `*`) and slashes are stripped before
|
|
1815
|
+
* resolution; that's a convenience for users round-tripping pasted
|
|
1816
|
+
* breadcrumb strings from `currentAddressString()`.
|
|
1817
|
+
*/
|
|
1818
|
+
function submitAddress(raw) {
|
|
1819
|
+
const value = (raw || '').trim();
|
|
1820
|
+
exitAddressEdit();
|
|
1821
|
+
if (!value || value === 'bitpub://') { goRoot(); return; }
|
|
1822
|
+
|
|
1823
|
+
const fullMatch = value.match(/^bitpub:\/\/([^/]+)(?:\/(.*))?$/);
|
|
1824
|
+
if (fullMatch) {
|
|
1825
|
+
const scope = fullMatch[1];
|
|
1826
|
+
const rest = (fullMatch[2] || '').replace(/\*+$/, '').replace(/\/+$/, '');
|
|
1827
|
+
const segs = rest ? rest.split('/').filter(Boolean) : [];
|
|
1828
|
+
const full = `bitpub://${scope}${segs.length ? '/' + segs.join('/') : ''}`;
|
|
1829
|
+
const exact = S.slices.find(s => s.hcu === full);
|
|
1830
|
+
if (exact) { selectSlice(exact.hcu); return; }
|
|
1831
|
+
navigateTo(scope, segs);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
if (value.startsWith('/')) {
|
|
1836
|
+
const owner = ownerScope();
|
|
1837
|
+
if (owner) {
|
|
1838
|
+
const rest = value.replace(/^\/+/, '').replace(/\*+$/, '').replace(/\/+$/, '');
|
|
1839
|
+
const segs = rest ? rest.split('/').filter(Boolean) : [];
|
|
1840
|
+
const full = `bitpub://${owner}${segs.length ? '/' + segs.join('/') : ''}`;
|
|
1841
|
+
const exact = S.slices.find(s => s.hcu === full);
|
|
1842
|
+
if (exact) { selectSlice(exact.hcu); return; }
|
|
1843
|
+
navigateTo(owner, segs);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// Try to match a slice by its URL tail (the "load forgivingly" pattern).
|
|
1849
|
+
const tail = value.replace(/^\/+/, '');
|
|
1850
|
+
const exactTail = S.slices.find(s => s.hcu.endsWith('/' + tail));
|
|
1851
|
+
if (exactTail) { selectSlice(exactTail.hcu); return; }
|
|
1852
|
+
|
|
1853
|
+
// Fall through: treat as search.
|
|
1854
|
+
S.view = 'overview';
|
|
1855
|
+
S.currentScope = null;
|
|
1856
|
+
S.currentPath = [];
|
|
1857
|
+
S.selectedHcu = null;
|
|
1858
|
+
S.searchQuery = value;
|
|
1859
|
+
pushHistory();
|
|
1860
|
+
renderAll();
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1484
1863
|
/* ══════════════════════════════════════════════════════
|
|
1485
1864
|
Navigation
|
|
1486
1865
|
══════════════════════════════════════════════════════ */
|
|
@@ -1490,7 +1869,7 @@ function goRoot() {
|
|
|
1490
1869
|
S.currentPath = [];
|
|
1491
1870
|
S.selectedHcu = null;
|
|
1492
1871
|
S.searchQuery = '';
|
|
1493
|
-
|
|
1872
|
+
pushHistory();
|
|
1494
1873
|
renderAll();
|
|
1495
1874
|
}
|
|
1496
1875
|
|
|
@@ -1499,6 +1878,8 @@ function navigateTo(scope, path) {
|
|
|
1499
1878
|
S.currentScope = scope;
|
|
1500
1879
|
S.currentPath = Array.isArray(path) ? path : [];
|
|
1501
1880
|
S.selectedHcu = null;
|
|
1881
|
+
S.searchQuery = '';
|
|
1882
|
+
pushHistory();
|
|
1502
1883
|
renderAll();
|
|
1503
1884
|
}
|
|
1504
1885
|
|
|
@@ -1510,7 +1891,9 @@ function selectSlice(hcu) {
|
|
|
1510
1891
|
S.currentScope = scope;
|
|
1511
1892
|
S.currentPath = pathOf(hcu);
|
|
1512
1893
|
S.selectedHcu = hcu;
|
|
1894
|
+
S.searchQuery = '';
|
|
1513
1895
|
S.raw = false;
|
|
1896
|
+
pushHistory();
|
|
1514
1897
|
renderAll();
|
|
1515
1898
|
}
|
|
1516
1899
|
|
|
@@ -1829,10 +2212,7 @@ function renderNamespace() {
|
|
|
1829
2212
|
node = node.children[seg];
|
|
1830
2213
|
}
|
|
1831
2214
|
|
|
1832
|
-
const pathStr = S.currentPath.length ? '/' + S.currentPath.join('/') : '';
|
|
1833
|
-
const fullUri = `bitpub://${scope}${pathStr}/**`;
|
|
1834
2215
|
const { type } = scopeOf(`bitpub://${scope}`);
|
|
1835
|
-
const isPriv = type === 'private';
|
|
1836
2216
|
|
|
1837
2217
|
// Collect slices within this subtree
|
|
1838
2218
|
const subSlices = [];
|
|
@@ -1843,13 +2223,9 @@ function renderNamespace() {
|
|
|
1843
2223
|
|
|
1844
2224
|
let html = '<div class="panel">';
|
|
1845
2225
|
|
|
1846
|
-
//
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
${isPriv ? `<span class="lock">${ICON.lock}</span>` : ''}
|
|
1850
|
-
<span class="uri"><span class="scheme">bitpub://</span>${esc(scope)}${esc(pathStr)}<span class="glob">/**</span></span>
|
|
1851
|
-
<button class="copy-btn" onclick="copyText('${escAttr(fullUri)}', this)">${ICON.copy}<span>Copy</span></button>
|
|
1852
|
-
</div>`;
|
|
2226
|
+
// (The URI + copy button used to live here as an `.hcu-hero`, but the
|
|
2227
|
+
// same info is now shown in the address bar at the top of the window
|
|
2228
|
+
// — keeping it in the panel was redundant.)
|
|
1853
2229
|
|
|
1854
2230
|
// Stats strip
|
|
1855
2231
|
const tagsInSubtree = new Set();
|
|
@@ -1954,13 +2330,9 @@ function renderSlice(sl) {
|
|
|
1954
2330
|
|
|
1955
2331
|
let html = '<div class="panel blob-wrap">';
|
|
1956
2332
|
|
|
1957
|
-
//
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
${isPriv ? `<span class="lock">${ICON.lock}</span>` : ''}
|
|
1961
|
-
<span class="uri"><span class="scheme">bitpub://</span>${esc(sl.hcu.replace(/^bitpub:\/\//, ''))}</span>
|
|
1962
|
-
<button class="copy-btn" onclick="copyText('${escAttr(sl.hcu)}', this)">${ICON.copy}<span>Copy URI</span></button>
|
|
1963
|
-
</div>`;
|
|
2333
|
+
// (The URI + copy button used to live here as an `.hcu-hero`, but the
|
|
2334
|
+
// same info is now shown in the address bar at the top of the window
|
|
2335
|
+
// — keeping it in the panel was redundant.)
|
|
1964
2336
|
|
|
1965
2337
|
// Meta strip — identity + mutability signal
|
|
1966
2338
|
html += '<div class="blob-meta">';
|
|
@@ -1997,20 +2369,44 @@ function renderSlice(sl) {
|
|
|
1997
2369
|
<span class="caveat">${ICON.info}<span>Next push to this address replaces this slice in place.</span></span>
|
|
1998
2370
|
</div>`;
|
|
1999
2371
|
|
|
2000
|
-
//
|
|
2372
|
+
// Content-type dispatcher. The payload's declared format is the
|
|
2373
|
+
// truthiest signal; we fall back to lightweight content sniffing for
|
|
2374
|
+
// slices written before format declarations were standardised.
|
|
2375
|
+
const kind = detectContentKind(sl, content);
|
|
2376
|
+
// Trusted-only: render HTML inline (in a sandboxed iframe) only for
|
|
2377
|
+
// private + group scopes. Public HTML falls back to escaped source
|
|
2378
|
+
// until we ship the scoped bridge + permission model. This matches
|
|
2379
|
+
// the v1 cut from product discussion.
|
|
2380
|
+
const canRenderAsApp = kind === 'html' && (type === 'private' || type === 'group');
|
|
2381
|
+
|
|
2001
2382
|
html += '<div class="blob-container has-banner">';
|
|
2002
2383
|
html += '<div class="blob-toolbar">';
|
|
2003
2384
|
html += '<div class="btn-group">';
|
|
2004
|
-
|
|
2005
|
-
|
|
2385
|
+
const previewLabel = canRenderAsApp ? 'App' : 'Preview';
|
|
2386
|
+
const sourceLabel = (kind === 'html') ? 'Source' : 'Raw';
|
|
2387
|
+
html += `<button class="btn ${!S.raw ? 'active' : ''}" onclick="toggleRaw(false)">${previewLabel}</button>`;
|
|
2388
|
+
html += `<button class="btn ${S.raw ? 'active' : ''}" onclick="toggleRaw(true)">${sourceLabel}</button>`;
|
|
2006
2389
|
html += '</div>';
|
|
2007
2390
|
html += '<div class="spacer"></div>';
|
|
2391
|
+
if (canRenderAsApp && !S.raw) {
|
|
2392
|
+
html += `<span class="app-badge" title="HTML is rendered in a sandboxed iframe with no network or storage access">${ICON.lock}<span>sandboxed</span></span>`;
|
|
2393
|
+
}
|
|
2008
2394
|
html += `<button class="btn" onclick="copyContent(this)">${ICON.copy}<span>Copy content</span></button>`;
|
|
2009
2395
|
html += '</div>';
|
|
2396
|
+
|
|
2010
2397
|
if (S.raw) {
|
|
2011
2398
|
html += `<pre class="content-raw">${esc(content)}</pre>`;
|
|
2012
|
-
} else {
|
|
2399
|
+
} else if (canRenderAsApp) {
|
|
2400
|
+
html += renderHtmlAppFrame(content);
|
|
2401
|
+
} else if (kind === 'html') {
|
|
2402
|
+
html += `<div class="app-public-note">${ICON.info}
|
|
2403
|
+
<div>HTML apps from <code>public:</code> addresses are shown as source until the public sandbox model lands. Apps in <code>private:</code> and <code>group:</code> scopes render inline.</div>
|
|
2404
|
+
</div>`;
|
|
2405
|
+
html += `<pre class="content-raw">${esc(content)}</pre>`;
|
|
2406
|
+
} else if (kind === 'markdown') {
|
|
2013
2407
|
html += `<div class="content-body">${md(content)}</div>`;
|
|
2408
|
+
} else {
|
|
2409
|
+
html += `<pre class="content-raw">${esc(content)}</pre>`;
|
|
2014
2410
|
}
|
|
2015
2411
|
html += `<div class="blob-footer">${lines} line${lines !== 1 ? 's' : ''} · ${formatBytes(sizeBytes)} · ${esc(format)} · only the latest version is retained</div>`;
|
|
2016
2412
|
html += '</div>';
|
|
@@ -2019,6 +2415,71 @@ function renderSlice(sl) {
|
|
|
2019
2415
|
$('main').innerHTML = html;
|
|
2020
2416
|
}
|
|
2021
2417
|
|
|
2418
|
+
/**
|
|
2419
|
+
* Content-kind detection for the slice dispatcher. Returns one of
|
|
2420
|
+
* 'html' | 'markdown' | 'text'
|
|
2421
|
+
* Order of signals (most authoritative first):
|
|
2422
|
+
* 1. Declared payload format (e.g. text/html, text/markdown)
|
|
2423
|
+
* 2. Slice address tail (.html / .md hints — common in Pack layouts)
|
|
2424
|
+
* 3. Content sniffing (doctype, leading <html>, leading #-heading)
|
|
2425
|
+
*/
|
|
2426
|
+
function detectContentKind(sl, content) {
|
|
2427
|
+
const fmt = (sl.payload.format || '').toLowerCase();
|
|
2428
|
+
if (/html/.test(fmt)) return 'html';
|
|
2429
|
+
if (/markdown|^text\/md$|md(?:$|;)/.test(fmt)) return 'markdown';
|
|
2430
|
+
|
|
2431
|
+
const tail = (pathOf(sl.hcu).pop() || '').toLowerCase();
|
|
2432
|
+
if (/\.html?$/.test(tail)) return 'html';
|
|
2433
|
+
if (/\.md$|\.markdown$/.test(tail)) return 'markdown';
|
|
2434
|
+
|
|
2435
|
+
if (/^\s*(<!doctype\s+html|<html[\s>]|<body[\s>])/i.test(content)) return 'html';
|
|
2436
|
+
if (/^\s*#\s/m.test(content)) return 'markdown';
|
|
2437
|
+
|
|
2438
|
+
return 'text';
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* Render an HTML payload as a sandboxed app frame.
|
|
2443
|
+
*
|
|
2444
|
+
* Security floor (v1 trusted-only model):
|
|
2445
|
+
* - sandbox="allow-scripts" only. Notably NOT allow-same-origin, so
|
|
2446
|
+
* the iframe runs in a unique opaque origin: no parent DOM access,
|
|
2447
|
+
* no cookies, no localStorage carried over.
|
|
2448
|
+
* - Strict CSP injected into <head> via <meta http-equiv="...">.
|
|
2449
|
+
* default-src 'none' blocks all network — no fetch, no XHR, no
|
|
2450
|
+
* WebSocket, no images from arbitrary hosts. Inline scripts and
|
|
2451
|
+
* styles are permitted (the app's code is embedded in srcdoc).
|
|
2452
|
+
* - <base target="_blank"> so any link the app tries to follow opens
|
|
2453
|
+
* in a new tab instead of breaking the user out of the Browser.
|
|
2454
|
+
* - referrerpolicy="no-referrer" so even allowed sub-resources can't
|
|
2455
|
+
* leak the user's location.
|
|
2456
|
+
*
|
|
2457
|
+
* No bridge to bitpub is exposed yet — apps in v1 can render but cannot
|
|
2458
|
+
* read from or write to the user's namespaces. The bridge is the next
|
|
2459
|
+
* milestone and will be opt-in per Pack via a declared manifest.
|
|
2460
|
+
*/
|
|
2461
|
+
function renderHtmlAppFrame(html) {
|
|
2462
|
+
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'">`;
|
|
2463
|
+
const baseTag = `<base target="_blank">`;
|
|
2464
|
+
const headInject = `${csp}${baseTag}`;
|
|
2465
|
+
|
|
2466
|
+
let doc;
|
|
2467
|
+
if (/<head[\s>]/i.test(html)) {
|
|
2468
|
+
// Insert our policy at the very start of the existing <head> so it
|
|
2469
|
+
// applies before any of the app's own resources are referenced.
|
|
2470
|
+
doc = html.replace(/<head([\s>])/i, `<head$1${headInject}`);
|
|
2471
|
+
} else if (/<html[\s>]/i.test(html)) {
|
|
2472
|
+
doc = html.replace(/<html([\s>][^>]*)>/i, `<html$1><head>${headInject}</head>`);
|
|
2473
|
+
} else {
|
|
2474
|
+
doc = `<!doctype html><html><head>${headInject}</head><body>${html}</body></html>`;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// srcdoc unescapes &, <, >, ", ' before parsing the inner document,
|
|
2478
|
+
// so we only need to escape what would otherwise break the attribute.
|
|
2479
|
+
const srcdoc = doc.replace(/&/g, '&').replace(/"/g, '"');
|
|
2480
|
+
return `<iframe class="app-frame" sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${srcdoc}"></iframe>`;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2022
2483
|
function renderWriteGuide(sl) {
|
|
2023
2484
|
const addr = sl.hcu;
|
|
2024
2485
|
const ver = sl.metadata.version || 1;
|
|
@@ -2243,13 +2704,22 @@ function timeAgo(ts) {
|
|
|
2243
2704
|
Keyboard
|
|
2244
2705
|
══════════════════════════════════════════════════════ */
|
|
2245
2706
|
document.addEventListener('keydown', e => {
|
|
2707
|
+
// `/` focuses the URL bar — same affordance as the old search box, now
|
|
2708
|
+
// it's the address bar. Ignored when the user is already typing.
|
|
2246
2709
|
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
|
2247
|
-
e.preventDefault();
|
|
2710
|
+
e.preventDefault();
|
|
2711
|
+
enterAddressEdit();
|
|
2248
2712
|
}
|
|
2713
|
+
// Browser-style Alt+Left / Alt+Right for back/forward.
|
|
2714
|
+
if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); goBack(); }
|
|
2715
|
+
if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); goForward(); }
|
|
2716
|
+
|
|
2249
2717
|
if (e.key === 'Escape') {
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2718
|
+
// Order: bail out of address-edit > clear search > clear filter > go home.
|
|
2719
|
+
if (!$('address-input').classList.contains('hidden')) { exitAddressEdit(); return; }
|
|
2720
|
+
if (S.searchQuery) { S.searchQuery = ''; renderPanel(); return; }
|
|
2721
|
+
if (filterActive()) { clearFilter(); return; }
|
|
2722
|
+
if (S.view === 'slice' || S.view === 'namespace') { goRoot(); }
|
|
2253
2723
|
}
|
|
2254
2724
|
});
|
|
2255
2725
|
|
|
@@ -2257,6 +2727,11 @@ document.addEventListener('keydown', e => {
|
|
|
2257
2727
|
Boot
|
|
2258
2728
|
══════════════════════════════════════════════════════ */
|
|
2259
2729
|
$('auth-key').addEventListener('keydown', e => { if (e.key === 'Enter') submitAuth(); });
|
|
2730
|
+
$('address-input').addEventListener('keydown', onAddressKeyDown);
|
|
2731
|
+
$('address-input').addEventListener('blur', exitAddressEdit);
|
|
2732
|
+
// Prime history with the initial overview so the first navigation has
|
|
2733
|
+
// somewhere to come back to.
|
|
2734
|
+
pushHistory();
|
|
2260
2735
|
boot();
|
|
2261
2736
|
</script>
|
|
2262
2737
|
</body>
|