| 1 | diff --git a/AGENTS.md b/AGENTS.md |
| 2 | index c3c6a4d..74f1ada 100644 |
| 3 | --- a/AGENTS.md |
| 4 | +++ b/AGENTS.md |
| 5 | @@ -1,3 +1,3 @@ |
| 6 | - The middle dot character (U+00B7) is disallowed in project text and interfaces. |
| 7 | - Write all documentation in AP style, with clear, concise organization. Documentation is a user-facing surface; omit incidental implementation notes. |
| 8 | -- Web UI markup carries no class attributes. Style ids, elements and custom element names in `internal/webui/style.css`, and render repeated or large data through the elements in `internal/webui/components.js` (`rpc-form`, `signed-form`, `publish-list`, `json-view`, `nostr-key`) and the `nostrEvent` and `jsonView` template partials. |
| 9 | +- Web UI markup carries no class attributes. Style ids, elements and custom element names in `internal/webui/style.css`, and render repeated or large data through the elements in `internal/webui/components.js` (`rpc-form`, `signed-form`, `publish-list`, `json-view`, `nostr-key`) and the `nostrEvent` and `jsonView` template partials. The shell is a rail, a content column, a panel and a command footer; `page.html` owns the rail and panel dispatch, each page template renders one `<section>` of content, and every page must render without JavaScript. |
| 10 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 11 | index e1ac15f..3bb2a07 100644 |
| 12 | --- a/internal/webui/bridge.js |
| 13 | +++ b/internal/webui/bridge.js |
| 14 | @@ -49,6 +49,25 @@ |
| 15 | } |
| 16 | |
| 17 | const signedSession = path => signedFetch(path, "POST", ""); |
| 18 | + |
| 19 | + // Shell: theme toggle, the phone menu, and the installable app worker. |
| 20 | + const root_ = document.documentElement; |
| 21 | + const themeButton = document.getElementById("theme"); |
| 22 | + const currentTheme = () => root_.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); |
| 23 | + const savedTheme = localStorage.getItem("tiny.theme"); |
| 24 | + if (savedTheme) root_.dataset.theme = savedTheme; |
| 25 | + if (themeButton) { |
| 26 | + const label = () => { themeButton.textContent = currentTheme() === "dark" ? "light" : "dark"; }; |
| 27 | + label(); |
| 28 | + themeButton.addEventListener("click", () => { |
| 29 | + root_.dataset.theme = currentTheme() === "dark" ? "light" : "dark"; |
| 30 | + localStorage.setItem("tiny.theme", root_.dataset.theme); |
| 31 | + label(); |
| 32 | + }); |
| 33 | + } |
| 34 | + document.getElementById("menu")?.addEventListener("click", () => { root_.dataset.menu = root_.dataset.menu === "open" ? "" : "open"; }); |
| 35 | + document.getElementById("rail")?.addEventListener("click", event => { if (event.target.closest("a")) root_.dataset.menu = ""; }); |
| 36 | + if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {}); |
| 37 | window.tiny = {root, localPath, sha256hex, authorization, signedFetch}; |
| 38 | window.tinySignedFetch = signedFetch; |
| 39 | |
| 40 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 41 | index dc33e52..39192ed 100644 |
| 42 | --- a/internal/webui/browse.html |
| 43 | +++ b/internal/webui/browse.html |
| 44 | @@ -1,52 +1,57 @@ |
| 45 | -{{define "repos"}}<main> |
| 46 | -<h2>Repositories</h2> |
| 47 | -<p><a href="/repos">Refresh</a> | <a href="/manage/connect">Connect a repository relay</a></p> |
| 48 | +{{define "repos"}}<section> |
| 49 | +<h1>Repositories</h1> |
| 50 | {{if .Error}}{{template "alert" .Error}} |
| 51 | -{{else if .Feed}}<scroll-box><table id="repo-list"> |
| 52 | -<thead><tr><th scope="col">Repository</th><th scope="col">Owner</th><th scope="col">Head</th><th scope="col">Private</th></tr></thead> |
| 53 | +{{else if .Feed}}<table id="rows"> |
| 54 | +<thead><tr><th>owner</th><th>repository</th><th>head</th></tr></thead> |
| 55 | <tbody> |
| 56 | -{{range .Feed}}{{$r := asMap .}}<tr><td><a href="/repo?owner={{urlquery (str (index $r "owner"))}}&repo={{urlquery (str (index $r "identifier"))}}">{{str (index $r "identifier")}}</a></td><td>{{template "nostrKey" (str (index $r "owner"))}}</td><td><code>{{shortID (index $r "head")}}</code></td><td>{{if index $r "private"}}yes{{else}}no{{end}}</td></tr> |
| 57 | +{{range .Feed}}{{$r := asMap .}}<tr><td>{{template "nostrKey" (str (index $r "owner"))}}</td><td><a href="/repo?owner={{urlquery (str (index $r "owner"))}}&repo={{urlquery (str (index $r "identifier"))}}&view=home">{{str (index $r "identifier")}}</a>{{if index $r "private"}} <small>private</small>{{end}}</td><td>{{shortID (index $r "head")}}</td></tr> |
| 58 | {{end}}</tbody> |
| 59 | -</table></scroll-box> |
| 60 | +</table> |
| 61 | {{else}}<p>No repositories were reported.</p> |
| 62 | -{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/repos" $.Query "cursor" .}}">Next page</a></p> |
| 63 | -{{end}}</main> |
| 64 | +{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/repos" $.Query "cursor" .}}">next page</a></p> |
| 65 | +{{end}}</section> |
| 66 | {{end}} |
| 67 | |
| 68 | -{{define "files"}}<main> |
| 69 | -<h2>Files</h2> |
| 70 | -<p><a href="/files">Refresh</a> | <a href="/manage/data">Upload or manage blobs</a></p> |
| 71 | +{{define "files"}}<section> |
| 72 | +<h1>Files</h1> |
| 73 | {{if .Error}}{{template "alert" .Error}} |
| 74 | -{{else if .Feed}}<scroll-box><table id="file-list"> |
| 75 | -<thead><tr><th scope="col">Hash</th><th scope="col">Type</th><th scope="col">Size</th><th scope="col">Uploaded</th></tr></thead> |
| 76 | +{{else if .Feed}}<table id="rows"> |
| 77 | +<thead><tr><th>hash</th><th>type</th><th>uploaded</th></tr></thead> |
| 78 | <tbody> |
| 79 | -{{range .Feed}}{{$f := asMap .}}<tr><td><a href="/file?sha={{str (index $f "sha256")}}"><code>{{str (index $f "sha256")}}</code></a></td><td>{{str (index $f "type")}}</td><td>{{str (index $f "size")}}</td><td>{{str (index $f "uploaded")}}</td></tr> |
| 80 | +{{range .Feed}}{{$f := asMap .}}<tr><td><a href="/file?sha={{str (index $f "sha256")}}">{{shortID (index $f "sha256")}}</a></td><td>{{str (index $f "type")}} <small>{{str (index $f "size")}} B</small></td><td>{{str (index $f "uploaded")}}</td></tr> |
| 81 | {{end}}</tbody> |
| 82 | -</table></scroll-box> |
| 83 | +</table> |
| 84 | {{else}}<p>No files were reported.</p> |
| 85 | -{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/files" $.Query "cursor" .}}">Next page</a></p> |
| 86 | -{{end}}</main> |
| 87 | +{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/files" $.Query "cursor" .}}">next page</a></p> |
| 88 | +{{end}}</section> |
| 89 | {{end}} |
| 90 | |
| 91 | -{{define "file"}}<main> |
| 92 | -<h2>File</h2> |
| 93 | -<p><a href="/files">All files</a></p> |
| 94 | +{{define "file"}}{{if .Error}}<section><h1>File</h1>{{template "alert" .Error}}</section> |
| 95 | +{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}<section> |
| 96 | +<h1>File</h1> |
| 97 | +<table id="facts"> |
| 98 | +<tr><th>sha256</th><td><code>{{str (index $file "sha256")}}</code></td></tr> |
| 99 | +<tr><th>type</th><td>{{str (index $file "type")}}</td></tr> |
| 100 | +<tr><th>size</th><td>{{str (index $file "size")}} bytes</td></tr> |
| 101 | +{{with index $file "blossom_uri"}}<tr><th>blossom</th><td><code>{{.}}</code></td></tr>{{end}} |
| 102 | +<tr><th>download</th><td><a href="/files/raw?hash={{str (index $file "sha256")}}">raw</a></td></tr> |
| 103 | +</table> |
| 104 | +</section> |
| 105 | +{{if index $file "binary"}}<section><p>This is a binary file. Download it to view its contents.</p></section> |
| 106 | +{{else}}{{sourceHTML .Event}} |
| 107 | +{{end}}{{if .Actor}}<section><details><summary>Manage file</summary><rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form></details></section>{{end}} |
| 108 | +{{end}}{{end}} |
| 109 | + |
| 110 | +{{define "sites"}}<section> |
| 111 | +<h1>Sites</h1> |
| 112 | +<p>Static sites published to this relay as signed manifests. Each site is served on its own host.</p> |
| 113 | {{if .Error}}{{template "alert" .Error}} |
| 114 | -{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}} |
| 115 | -<p><a href="/files/raw?hash={{str (index $file "sha256")}}">Raw/download</a></p> |
| 116 | -<scroll-box><table id="file-meta"> |
| 117 | +{{else if .Feed}}<table id="rows"> |
| 118 | +<thead><tr><th>author</th><th>site</th><th>updated</th></tr></thead> |
| 119 | <tbody> |
| 120 | -<tr><th scope="row">SHA-256</th><td><code>{{str (index $file "sha256")}}</code></td></tr> |
| 121 | -<tr><th scope="row">Type</th><td>{{str (index $file "type")}}</td></tr> |
| 122 | -<tr><th scope="row">Size</th><td>{{str (index $file "size")}} bytes</td></tr> |
| 123 | -{{with index $file "blossom_uri"}}<tr><th scope="row">Blossom URI</th><td><code>{{.}}</code></td></tr> |
| 124 | +{{range .Feed}}{{$s := asMap .}}<tr><td>{{template "nostrKey" (str (index $s "author"))}}</td><td><a href="{{str (index $s "url")}}">{{str (index $s "label")}}</a> <small>{{str (index $s "paths")}} paths</small></td><td>{{when (index $s "created_at")}}</td></tr> |
| 125 | {{end}}</tbody> |
| 126 | -</table></scroll-box> |
| 127 | -{{if index $file "binary"}}<p>This is a binary file. Download it to view its contents.</p> |
| 128 | -{{else}}{{sourceHTML .Event}} |
| 129 | -{{end}}<details> |
| 130 | -<summary>Manage file</summary> |
| 131 | -<rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form> |
| 132 | -</details> |
| 133 | -{{end}}</main> |
| 134 | +</table> |
| 135 | +{{else}}<p>No sites are published.</p> |
| 136 | +{{end}}</section> |
| 137 | {{end}} |
| 138 | diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go |
| 139 | index fb71664..a3a9d3f 100644 |
| 140 | --- a/internal/webui/browse_test.go |
| 141 | +++ b/internal/webui/browse_test.go |
| 142 | @@ -99,7 +99,7 @@ func TestBrowseRendersTypedGitRelayPage(t *testing.T) { |
| 143 | recorder := httptest.NewRecorder() |
| 144 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=alice&repo=notes&view=file", nil)) |
| 145 | body := recorder.Body.String() |
| 146 | - if recorder.Code != http.StatusOK || !strings.Contains(body, "<script>") || !strings.Contains(body, "line two") || !strings.Contains(body, "Raw/download") { |
| 147 | + if recorder.Code != http.StatusOK || !strings.Contains(body, "<script>") || !strings.Contains(body, "line two") || !strings.Contains(body, ">raw</a>") { |
| 148 | t.Fatalf("typed browse page status=%d body=%s", recorder.Code, body) |
| 149 | } |
| 150 | } |
| 151 | diff --git a/internal/webui/icon-mono.svg b/internal/webui/icon-mono.svg |
| 152 | new file mode 100644 |
| 153 | index 0000000..b1e3a2e |
| 154 | --- /dev/null |
| 155 | +++ b/internal/webui/icon-mono.svg |
| 156 | @@ -0,0 +1 @@ |
| 157 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M14 46 L32 14 L50 46 Z" fill="#000"/><rect x="18" y="49" width="28" height="4" fill="#000"/></svg> |
| 158 | diff --git a/internal/webui/icon.svg b/internal/webui/icon.svg |
| 159 | new file mode 100644 |
| 160 | index 0000000..4eb49a4 |
| 161 | --- /dev/null |
| 162 | +++ b/internal/webui/icon.svg |
| 163 | @@ -0,0 +1 @@ |
| 164 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#fbfaf7"/><path d="M14 46 L32 14 L50 46 Z" fill="#2f6b3a"/><rect x="18" y="49" width="28" height="4" fill="#8a5a1f"/></svg> |
| 165 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 166 | index 3b99402..ad9c83b 100644 |
| 167 | --- a/internal/webui/manage.html |
| 168 | +++ b/internal/webui/manage.html |
| 169 | @@ -1,6 +1,5 @@ |
| 170 | -{{define "people"}}<main> |
| 171 | -<h2>People</h2> |
| 172 | -{{template "manageNav" .}} |
| 173 | +{{define "people"}}<section> |
| 174 | +<h1>People</h1> |
| 175 | <h3>Members</h3> |
| 176 | <rpc-form method="listmembers"><button>List members</button></rpc-form> |
| 177 | <rpc-form method="setmember" compose="member"> |
| 178 | @@ -27,12 +26,11 @@ |
| 179 | <h3>Lists</h3> |
| 180 | <rpc-form method="listallowedpubkeys"><button>Allowed pubkeys</button></rpc-form> |
| 181 | <rpc-form method="listbannedpubkeys"><button>Banned pubkeys</button></rpc-form> |
| 182 | -</main> |
| 183 | +</section> |
| 184 | {{end}} |
| 185 | |
| 186 | -{{define "moderation"}}<main> |
| 187 | -<h2>Moderation</h2> |
| 188 | -{{template "manageNav" .}} |
| 189 | +{{define "moderation"}}<section> |
| 190 | +<h1>Moderation</h1> |
| 191 | <h3>Reports</h3> |
| 192 | <rpc-form method="listreports"><button>List reports</button></rpc-form> |
| 193 | <rpc-form method="resolvereport"> |
| 194 | @@ -52,12 +50,11 @@ |
| 195 | <rpc-form method="unpinevent"><label>Event ID or address <input name="param" required></label><button>Unpin</button></rpc-form> |
| 196 | <h3>Pubkeys</h3> |
| 197 | <rpc-form method="banpubkey"><label>Pubkey <input name="param" required></label><button>Ban</button></rpc-form> |
| 198 | -</main> |
| 199 | +</section> |
| 200 | {{end}} |
| 201 | |
| 202 | -{{define "rules"}}<main> |
| 203 | -<h2>Rules</h2> |
| 204 | -{{template "manageNav" .}} |
| 205 | +{{define "rules"}}<section> |
| 206 | +<h1>Rules</h1> |
| 207 | <h3>Policy</h3> |
| 208 | <rpc-form method="getpolicy"><button>Load policy</button></rpc-form> |
| 209 | <rpc-form method="setpolicy" compose="memberInvites"> |
| 210 | @@ -75,12 +72,11 @@ |
| 211 | <label>Keep days <input name="param" type="number" min="0" required></label> |
| 212 | <button>Save retention</button> |
| 213 | </rpc-form> |
| 214 | -</main> |
| 215 | +</section> |
| 216 | {{end}} |
| 217 | |
| 218 | -{{define "identity"}}<main> |
| 219 | -<h2>Identity</h2> |
| 220 | -{{template "manageNav" .}} |
| 221 | +{{define "identity"}}<section> |
| 222 | +<h1>Identity</h1> |
| 223 | <h3>Profile</h3> |
| 224 | <rpc-form method="changerelayname"><label>Name <input name="param" value="{{.Policy.Name}}"></label><button>Save name</button></rpc-form> |
| 225 | <rpc-form method="changerelaydescription"><label>Description <textarea name="param">{{.Policy.Description}}</textarea></label><button>Save description</button></rpc-form> |
| 226 | @@ -98,14 +94,13 @@ |
| 227 | </rpc-form> |
| 228 | <h3>Ownership</h3> |
| 229 | <rpc-form method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button></rpc-form> |
| 230 | -</main> |
| 231 | +</section> |
| 232 | {{end}} |
| 233 | |
| 234 | -{{define "connect"}}<main> |
| 235 | -<h2>Connect</h2> |
| 236 | +{{define "connect"}}<section> |
| 237 | +<h1>Connect</h1> |
| 238 | {{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p> |
| 239 | -{{end}}{{template "manageNav" .}} |
| 240 | -<h3>Connections</h3> |
| 241 | +{{end}}<h3>Connections</h3> |
| 242 | <p><button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button></p> |
| 243 | <scroll-box><table id="connections-preview" aria-live="polite"><tbody><tr><td>No preview loaded.</td></tr></tbody></table></scroll-box> |
| 244 | <rpc-form method="listconnections"><button>List connections</button></rpc-form> |
| 245 | @@ -123,12 +118,11 @@ |
| 246 | </publish-list> |
| 247 | <h3>Sign in</h3> |
| 248 | {{template "signerControls" .}} |
| 249 | -</main> |
| 250 | +</section> |
| 251 | {{end}} |
| 252 | |
| 253 | -{{define "data"}}<main> |
| 254 | -<h2>Data</h2> |
| 255 | -{{template "manageNav" .}} |
| 256 | +{{define "data"}}<section> |
| 257 | +<h1>Data</h1> |
| 258 | <h3>Files</h3> |
| 259 | <signed-form action="/upload" method="PUT"><label>Upload file <input type="file" name="file" required></label><button>Upload</button></signed-form> |
| 260 | <rpc-form method="listblobs"><button>List blobs</button></rpc-form> |
| 261 | @@ -155,12 +149,11 @@ |
| 262 | <rpc-form method="deletebackup"><label>Backup name <input name="param" required></label><button>Delete backup</button></rpc-form> |
| 263 | <h3>Import</h3> |
| 264 | <signed-form action="/import" method="PUT"><label>Event JSONL <input type="file" name="file" accept=".jsonl,.json,application/json" required></label><button>Import events</button></signed-form> |
| 265 | -</main> |
| 266 | +</section> |
| 267 | {{end}} |
| 268 | |
| 269 | -{{define "sync"}}<main> |
| 270 | -<h2>Sync and delivery</h2> |
| 271 | -{{template "manageNav" .}} |
| 272 | +{{define "sync"}}<section> |
| 273 | +<h1>Sync and delivery</h1> |
| 274 | <h3>Jobs</h3> |
| 275 | <p><button id="watch-jobs" fx-action="/manage/jobs/status" fx-method="get" fx-trigger="click" fx-target="#job-status" fx-swap="innerHTML">Watch job status</button></p> |
| 276 | <scroll-box><table id="job-status"><tbody><tr><td>Job status is idle.</td></tr></tbody></table></scroll-box> |
| 277 | @@ -177,38 +170,34 @@ |
| 278 | <rpc-form method="pullstatus"><button>Pull status</button></rpc-form> |
| 279 | <rpc-form method="deliverystatus"><button>Delivery status</button></rpc-form> |
| 280 | <rpc-form method="backfill"><label>JSON backfill plan <input name="param" value="{}"></label><button>Backfill</button></rpc-form> |
| 281 | -</main> |
| 282 | +</section> |
| 283 | {{end}} |
| 284 | |
| 285 | -{{define "views"}}<main> |
| 286 | -<h2>Views</h2> |
| 287 | -{{template "manageNav" .}} |
| 288 | +{{define "views"}}<section> |
| 289 | +<h1>Views</h1> |
| 290 | <rpc-form method="listviews"><button>List views</button></rpc-form> |
| 291 | <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form> |
| 292 | -</main> |
| 293 | +</section> |
| 294 | {{end}} |
| 295 | |
| 296 | -{{define "health"}}<main> |
| 297 | -<h2>Health</h2> |
| 298 | -{{template "manageNav" .}} |
| 299 | +{{define "health"}}<section> |
| 300 | +<h1>Health</h1> |
| 301 | <rpc-form method="stats"><button>Relay stats</button></rpc-form> |
| 302 | <rpc-form method="listaudit"><label>Before sequence <input name="param" type="number" value="0"></label><button>Audit log</button></rpc-form> |
| 303 | <rpc-form method="supportedmethods"><button>Supported methods</button></rpc-form> |
| 304 | -</main> |
| 305 | +</section> |
| 306 | {{end}} |
| 307 | |
| 308 | -{{define "status"}}<main> |
| 309 | -<h2>Service status</h2> |
| 310 | -{{template "manageNav" .}} |
| 311 | +{{define "status"}}<section> |
| 312 | +<h1>Service status</h1> |
| 313 | {{if .Error}}{{template "alert" .Error}} |
| 314 | {{else}}{{template "jsonView" .Event}} |
| 315 | <p><a href="/manage/sync">Jobs and delivery</a> | <a href="/manage/data">Storage and backups</a> | <a href="/manage/health">Relay health</a></p> |
| 316 | -{{end}}</main> |
| 317 | +{{end}}</section> |
| 318 | {{end}} |
| 319 | |
| 320 | -{{define "owner"}}<main> |
| 321 | -<h2>Owner operations</h2> |
| 322 | -{{template "manageNav" .}} |
| 323 | +{{define "owner"}}<section> |
| 324 | +<h1>Owner operations</h1> |
| 325 | <p>Destructive actions ask the signer. The browser never receives or submits a private key.</p> |
| 326 | <h3>Configuration</h3> |
| 327 | <rpc-form method="exportconfig"><button>Export configuration</button></rpc-form> |
| 328 | @@ -231,5 +220,5 @@ |
| 329 | <label>JSON parameters <input name="param" value="[]"></label> |
| 330 | <button>Run method</button> |
| 331 | </rpc-form> |
| 332 | -</main> |
| 333 | +</section> |
| 334 | {{end}} |
| 335 | diff --git a/internal/webui/manifest.webmanifest b/internal/webui/manifest.webmanifest |
| 336 | new file mode 100644 |
| 337 | index 0000000..264d641 |
| 338 | --- /dev/null |
| 339 | +++ b/internal/webui/manifest.webmanifest |
| 340 | @@ -0,0 +1,14 @@ |
| 341 | +{ |
| 342 | + "name": "NAME", |
| 343 | + "short_name": "NAME", |
| 344 | + "description": "A tiny Nostr relay", |
| 345 | + "start_url": "BASE/", |
| 346 | + "scope": "BASE/", |
| 347 | + "display": "standalone", |
| 348 | + "background_color": "#fbfaf7", |
| 349 | + "theme_color": "#f4f2ec", |
| 350 | + "icons": [ |
| 351 | + {"src": "BASE/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}, |
| 352 | + {"src": "BASE/icon-mono.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "monochrome"} |
| 353 | + ] |
| 354 | +} |
| 355 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 356 | index adbb52c..33f492d 100644 |
| 357 | --- a/internal/webui/page.html |
| 358 | +++ b/internal/webui/page.html |
| 359 | @@ -3,26 +3,22 @@ |
| 360 | <head> |
| 361 | <meta charset="utf-8"> |
| 362 | <meta name="viewport" content="width=device-width,initial-scale=1"> |
| 363 | +<meta name="color-scheme" content="light dark"> |
| 364 | +<meta name="theme-color" content="#f4f2ec" media="(prefers-color-scheme: light)"> |
| 365 | +<meta name="theme-color" content="#1e1d1a" media="(prefers-color-scheme: dark)"> |
| 366 | <title>{{.Title}}</title> |
| 367 | +<link rel="manifest" href="/manifest.webmanifest"> |
| 368 | +<link rel="icon" href="/icon.svg" type="image/svg+xml"> |
| 369 | <style>{{stylesheet}}</style> |
| 370 | <script src="/fixi.js"></script> |
| 371 | <script src="/signer.js"></script> |
| 372 | <script src="/webmcp.js"></script> |
| 373 | </head> |
| 374 | <body> |
| 375 | -<table id="layout" aria-label="tiny relay"><tr><td> |
| 376 | -<header> |
| 377 | -{{if .Private}}<h1>Private relay</h1> |
| 378 | -<p>Sign in with a Nostr signer to continue.</p> |
| 379 | -{{else}}<h1><a href="/">{{.Slug}}</a></h1> |
| 380 | -<p>{{.Policy.Description}}</p> |
| 381 | -<p><code>{{.URL}}</code> | identity {{template "nostrKey" .Identity}}</p> |
| 382 | -<nav aria-label="Site"> |
| 383 | -<a href="/">Home</a> | <a href="/repos">Repositories</a> | <a href="/files">Files</a> | <a href="/inbox">Inbox</a> | <a href="/outbox">Outbox</a> | <a href="/search">Search</a> | <a href="/manage/people">Manage</a> | |
| 384 | -{{if .Actor}}<a href="/signin" title="{{.Actor}}">{{shortID .Actor}}</a> <button id="session-logout" type="button">Sign out</button>{{else}}<a href="/signin">Sign in</a>{{end}} |
| 385 | -</nav> |
| 386 | -<p id="session-status" role="status"></p> |
| 387 | -{{end}}</header> |
| 388 | +<header id="topbar"><button id="menu" type="button">menu</button> <span><b>{{.Slug}}</b> » {{prompt .Path .Query}}</span></header> |
| 389 | +<table id="layout" aria-label="{{.Slug}}"><tbody><tr> |
| 390 | +<td id="rail"><div id="railbox">{{template "rail" .}}</div></td> |
| 391 | +<td id="main"><div id="content"> |
| 392 | {{if .Private}}{{template "signin" .}} |
| 393 | {{else if eq .Tab "home"}}{{template "home" .}} |
| 394 | {{else if eq .Tab "inbox"}}{{template "inbox" .}} |
| 395 | @@ -32,7 +28,7 @@ |
| 396 | {{else if eq .Tab "private"}}{{template "private" .}} |
| 397 | {{else if eq .Tab "chat"}}{{template "chat" .}} |
| 398 | {{else if eq .Tab "media"}}{{template "media" .}} |
| 399 | -{{else if eq .Tab "site"}}{{template "site" .}} |
| 400 | +{{else if eq .Tab "sites"}}{{template "sites" .}} |
| 401 | {{else if eq .Tab "marmot"}}{{template "marmot" .}} |
| 402 | {{else if eq .Tab "grasp"}}{{template "grasp" .}} |
| 403 | {{else if eq .Tab "terms"}}{{template "terms" .}} |
| 404 | @@ -57,27 +53,77 @@ |
| 405 | {{else if eq .Tab "view"}}{{template "view" .}} |
| 406 | {{else if eq .Tab "invite"}}{{template "invite" .}} |
| 407 | {{else}}{{template "home" .}} |
| 408 | -{{end}} |
| 409 | -<footer><a href="/.well-known/nostr.json">NIP-05</a> | <a href="/terms">Terms</a></footer> |
| 410 | -</td></tr></table> |
| 411 | +{{end}}</div></td> |
| 412 | +<td id="aside"><div id="panel">{{template "panel" .}}</div></td> |
| 413 | +</tr></tbody></table> |
| 414 | +<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> » {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><button id="theme" type="button" aria-label="Switch theme">theme</button></td></tr></table></footer> |
| 415 | <script>{{script "bridge.js"}}</script> |
| 416 | <script>{{script "components.js"}}</script> |
| 417 | </body> |
| 418 | </html> |
| 419 | {{end}} |
| 420 | |
| 421 | -{{/* Shared partials. Each renders one reusable element so pages never repeat markup for the same kind of data. */}} |
| 422 | +{{/* The rail is the sitemap for the current context: the relay, one repository, or management. */}} |
| 423 | +{{define "rail"}}{{$kind := railKind .Tab}}{{if .Private}}<header><span><a id="mark" href="/"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header> |
| 424 | +<nav><a href="/signin" aria-current="page">/signin</a></nav> |
| 425 | +<p>Private relay. Sign in with a Nostr signer to continue.</p> |
| 426 | +{{else if eq $kind "repo"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/repos" title="{{.Query.Get "owner"}}">{{shortID (npub (.Query.Get "owner"))}}</a>/<b>{{.Query.Get "repo"}}</b></span></header> |
| 427 | +<nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav> |
| 428 | +<p>← <a href="/repos">all repositories</a></p> |
| 429 | +{{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header> |
| 430 | +<nav>{{range $item := manageItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav> |
| 431 | +<p>← <a href="/">relay</a></p> |
| 432 | +{{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header> |
| 433 | +<nav>{{range $item := relayItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav> |
| 434 | +{{end}}{{end}} |
| 435 | + |
| 436 | +{{/* The panel holds facts and filters for the current page. */}} |
| 437 | +{{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}} |
| 438 | +{{else if eq .Tab "repo"}}{{template "panel-repo" .}} |
| 439 | +{{else if eq $kind "manage"}}{{template "panel-manage" .}} |
| 440 | +{{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}} |
| 441 | +{{else if eq .Tab "repos"}}{{template "panel-repos" .}} |
| 442 | +{{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}} |
| 443 | +{{else if eq .Tab "signin"}}{{template "panel-signin" .}} |
| 444 | +{{else}}{{template "panel-relay" .}} |
| 445 | +{{end}}{{end}} |
| 446 | + |
| 447 | +{{define "panel-relay"}}<section><h4>About</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Identity}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>search</th><td>{{.Policy.Features.Search}}</td></tr>{{if .Policy.Features.Grasp}}<tr><th>git</th><td>GRASP-01</td></tr>{{end}}{{if .Policy.Features.Files}}<tr><th>files</th><td>Blossom</td></tr>{{end}}</table></section> |
| 448 | +<section><h4>Actions</h4><ul>{{if not .Actor}}<li><a href="/signin">sign in</a></li>{{end}}<li><a href="/manage/people">manage</a></li><li><a href="/.well-known/nostr.json">NIP-05 names</a></li>{{if .Policy.Features.Pages}}<li><a href="/feed.xml">Atom feed</a></li>{{end}}<li><a href="/terms">terms</a></li></ul></section> |
| 449 | +<section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}} |
| 450 | + |
| 451 | +{{define "panel-signin"}}<section><h4>Session</h4><table><tr><th>status</th><td>{{if .Actor}}signed in{{else}}guest{{end}}</td></tr><tr><th>method</th><td>NIP-98</td></tr></table></section> |
| 452 | +<section><h4>After sign in</h4><ul><li>read your inbox</li><li>manage members and policy</li><li>upload files</li><li>push repositories</li></ul></section>{{end}} |
| 453 | + |
| 454 | +{{define "panel-repos"}}<section><h4>Hosting</h4><table><tr><th>server</th><td><code>{{.URL}}</code></td></tr><tr><th>protocol</th><td>GRASP-01</td></tr></table></section> |
| 455 | +<section><h4>Publish</h4><p><small>Announce with ngit and push with plain git. Every push needs the owner's signed state.</small></p></section>{{end}} |
| 456 | + |
| 457 | +{{define "panel-files"}}<section><h4>Storage</h4><table><tr><th>protocol</th><td>Blossom</td></tr>{{if .Policy.Features.Files}}<tr><th>uploads</th><td>members</td></tr>{{end}}</table></section> |
| 458 | +{{if .Actor}}<section><h4>Upload</h4><signed-form action="/upload" method="PUT"><label>file<input type="file" name="file" required></label><button>upload</button></signed-form></section>{{end}}{{end}} |
| 459 | + |
| 460 | +{{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>apply</button></form></section> |
| 461 | +{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}} |
| 462 | + |
| 463 | +{{define "panel-repo"}}{{$r := asMap .Event}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section> |
| 464 | +<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section> |
| 465 | +{{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>go</button></form></section>{{end}}{{end}} |
| 466 | + |
| 467 | +{{define "panel-manage"}}<section><h4>Policy</h4><table><tr><th>name</th><td>{{.Policy.Name}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>owner</th><td>{{template "nostrKey" .Policy.Owner}}</td></tr></table></section> |
| 468 | +<section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section> |
| 469 | +<section><h4>Result</h4><p><small>Each form shows its result below the form. Every call is signed by your connected signer.</small></p></section>{{end}} |
| 470 | + |
| 471 | +{{/* Shared partials. */}} |
| 472 | |
| 473 | {{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}} |
| 474 | |
| 475 | -{{define "nostrKey"}}<nostr-key hex="{{.}}">{{shortID .}}</nostr-key>{{end}} |
| 476 | +{{define "nostrKey"}}<nostr-key hex="{{.}}" title="{{.}}">{{shortID .}}</nostr-key>{{end}} |
| 477 | |
| 478 | {{define "jsonView"}}<json-view><pre>{{json .}}</pre></json-view>{{end}} |
| 479 | |
| 480 | {{define "nostrEvent"}}{{$e := asMap .}}<nostr-event id="event-{{str (index $e "id")}}"> |
| 481 | -<p><a href="/e/{{str (index $e "id")}}"><code>{{shortID (index $e "id")}}</code></a> | kind {{str (index $e "kind")}} | {{template "nostrKey" (str (index $e "pubkey"))}} | <time datetime="{{datetime (index $e "created_at")}}">{{when (index $e "created_at")}}</time></p> |
| 482 | +<header><a href="/e/{{str (index $e "id")}}">{{shortID (index $e "id")}}</a> | kind {{str (index $e "kind")}} | {{template "nostrKey" (str (index $e "pubkey"))}}<small><time datetime="{{datetime (index $e "created_at")}}">{{when (index $e "created_at")}}</time></small></header> |
| 483 | <pre>{{str (index $e "content")}}</pre> |
| 484 | -<details><summary>Event JSON</summary>{{template "jsonView" .}}</details> |
| 485 | +<details><summary>event JSON</summary>{{template "jsonView" .}}</details> |
| 486 | </nostr-event>{{end}} |
| 487 | |
| 488 | {{define "eventList"}}{{if .Feed}}<ol id="events"> |
| 489 | @@ -85,8 +131,3 @@ |
| 490 | {{end}}</ol> |
| 491 | {{else}}<p>No events.</p> |
| 492 | {{end}}{{end}} |
| 493 | - |
| 494 | -{{define "manageNav"}}<nav id="manage-nav" aria-label="Manage"> |
| 495 | -<a href="/manage/people">People</a> | <a href="/manage/moderation">Moderation</a> | <a href="/manage/rules">Rules</a> | <a href="/manage/identity">Identity</a> | <a href="/manage/connect">Connect</a> | <a href="/manage/data">Data</a> | <a href="/manage/sync">Sync</a> | <a href="/manage/views">Views</a> | <a href="/manage/health">Health</a> | <a href="/manage/owner">Owner</a> | <a href="/manage/status">Status</a> | <a href="/tools">Tools</a> |
| 496 | -</nav> |
| 497 | -{{end}} |
| 498 | diff --git a/internal/webui/private.go b/internal/webui/private.go |
| 499 | index dfdaef8..2125cfe 100644 |
| 500 | --- a/internal/webui/private.go |
| 501 | +++ b/internal/webui/private.go |
| 502 | @@ -29,7 +29,7 @@ func privateShellPath(path string) bool { |
| 503 | } |
| 504 | |
| 505 | func privateProtectedPath(path string) bool { |
| 506 | - if privateShellPath(path) || path == "/webmcp.js" || path == "/signer.js" || path == "/fixi.js" || path == "/qr.svg" { |
| 507 | + if privateShellPath(path) || path == "/webmcp.js" || path == "/signer.js" || path == "/fixi.js" || path == "/qr.svg" || path == "/sw.js" || path == "/manifest.webmanifest" || path == "/icon.svg" || path == "/icon-mono.svg" { |
| 508 | return false |
| 509 | } |
| 510 | return true |
| 511 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 512 | index fa96ad5..b422be5 100644 |
| 513 | --- a/internal/webui/public.html |
| 514 | +++ b/internal/webui/public.html |
| 515 | @@ -1,109 +1,113 @@ |
| 516 | -{{define "home"}}<main> |
| 517 | -<h2>About this relay</h2> |
| 518 | -<p>Browse notes, repositories and files. Connect your Nostr signer to publish and manage your relay.</p> |
| 519 | -<dl> |
| 520 | -<dt>Reads</dt><dd>{{.Policy.Reads}}</dd> |
| 521 | -<dt>Writes</dt><dd>{{.Policy.Writes}}</dd> |
| 522 | -<dt>Search</dt><dd>{{.Policy.Features.Search}}</dd> |
| 523 | -<dt>Files</dt><dd>{{.Policy.Features.Files}}</dd> |
| 524 | -</dl> |
| 525 | -<p>Relay URL: <code>{{.URL}}</code></p> |
| 526 | -</main> |
| 527 | -{{end}} |
| 528 | - |
| 529 | -{{define "signin"}}<main> |
| 530 | -<h2>{{if .Actor}}Your account{{else}}Sign in{{end}}</h2> |
| 531 | +{{define "home"}}<section id="identity"> |
| 532 | +<header>{{if .Policy.Icon}}<img alt="" src="{{.Policy.Icon}}">{{end}}<h1>{{or .Policy.Name .Slug}}</h1><small><code>{{.URL}}</code> | {{template "nostrKey" .Identity}}</small></header> |
| 533 | +<table id="facts"> |
| 534 | +{{if .Policy.Contact}}<tr><th>contact</th><td>{{.Policy.Contact}}</td></tr>{{end}} |
| 535 | +{{if .Policy.Tags}}<tr><th>tags</th><td id="tags">{{range .Policy.Tags}}<a href="/search?q={{urlquery .}}">#{{.}}</a>{{end}}</td></tr>{{end}} |
| 536 | +{{if .Policy.LanguageTags}}<tr><th>languages</th><td>{{join .Policy.LanguageTags ", "}}</td></tr>{{end}} |
| 537 | +{{if .Policy.RelayCountries}}<tr><th>countries</th><td>{{join .Policy.RelayCountries ", "}}</td></tr>{{end}} |
| 538 | +<tr><th>policies</th><td>{{if .Policy.PostingPolicy}}<a href="{{.Policy.PostingPolicy}}">posting</a> | {{end}}{{if .Policy.PrivacyPolicy}}<a href="{{.Policy.PrivacyPolicy}}">privacy</a> | {{end}}<a href="/terms">terms</a></td></tr> |
| 539 | +<tr><th>reads</th><td>{{.Policy.Reads}}</td></tr> |
| 540 | +<tr><th>writes</th><td>{{.Policy.Writes}}</td></tr> |
| 541 | +</table> |
| 542 | +{{if .Policy.Description}}<h2>About</h2> |
| 543 | +{{markdown .Policy.Description}} |
| 544 | +{{end}}<h2>Recent activity</h2> |
| 545 | +{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}} |
| 546 | +</section> |
| 547 | +{{end}} |
| 548 | + |
| 549 | +{{define "signin"}}<section> |
| 550 | +<h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1> |
| 551 | {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p> |
| 552 | <p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a></p> |
| 553 | -{{else}}<p>Use a browser extension or connect Amber on your phone.</p> |
| 554 | +{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p> |
| 555 | {{end}}{{template "signerControls" .}} |
| 556 | -</main> |
| 557 | +</section> |
| 558 | {{end}} |
| 559 | |
| 560 | {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p> |
| 561 | -<h3>Phone signer</h3> |
| 562 | +<h2>Phone signer</h2> |
| 563 | <p>Scan the QR code with Amber and approve the connection and sign-in request. This page will open your relay when you finish.</p> |
| 564 | <p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p> |
| 565 | -<p><img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240"></p> |
| 566 | +<img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240"> |
| 567 | <p id="signer-status" role="status"></p> |
| 568 | -<details> |
| 569 | -<summary>Use a bunker URL</summary> |
| 570 | +<h2>Bunker URL</h2> |
| 571 | <form id="bunker"> |
| 572 | <label>Bunker URL <input id="bunker-url" type="url" placeholder="bunker://…" required></label> |
| 573 | <button>Connect remote signer</button> |
| 574 | </form> |
| 575 | -</details> |
| 576 | {{end}} |
| 577 | |
| 578 | -{{define "inbox"}}<main> |
| 579 | -<h2>Inbox</h2> |
| 580 | +{{define "inbox"}}<section> |
| 581 | +<h1>Inbox</h1> |
| 582 | <p>Events addressed to this signed-in key, subject to the same private-event gates as websocket queries.</p> |
| 583 | {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}} |
| 584 | -</main> |
| 585 | +</section> |
| 586 | {{end}} |
| 587 | |
| 588 | -{{define "outbox"}}<main> |
| 589 | -<h2>Outbox</h2> |
| 590 | +{{define "outbox"}}<section> |
| 591 | +<h1>Outbox</h1> |
| 592 | <p>Events authored by this signed-in key.</p> |
| 593 | {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}} |
| 594 | -</main> |
| 595 | +</section> |
| 596 | {{end}} |
| 597 | |
| 598 | -{{define "search"}}<main> |
| 599 | -<h2>Search</h2> |
| 600 | +{{define "search"}}<section> |
| 601 | +<h1>Search</h1> |
| 602 | <form id="search" action="/search" method="get"> |
| 603 | <label>Terms <input name="q" type="search" value="{{.Query.Get "q"}}" required></label> |
| 604 | +<input type="hidden" name="kinds" value="{{.Query.Get "kinds"}}"><input type="hidden" name="author" value="{{.Query.Get "author"}}"><input type="hidden" name="since" value="{{.Query.Get "since"}}"> |
| 605 | <button>Search</button> |
| 606 | </form> |
| 607 | {{if .Error}}{{template "alert" .Error}} |
| 608 | -{{else if .Feed}}<h3>Results</h3> |
| 609 | +{{else if .Feed}}<h2>Results</h2> |
| 610 | {{template "eventList" .}} |
| 611 | {{else if .Query.Get "q"}}<p>No matching events.</p> |
| 612 | -{{end}}</main> |
| 613 | +{{end}}</section> |
| 614 | {{end}} |
| 615 | |
| 616 | -{{define "articles"}}<main> |
| 617 | -<h2>Articles</h2> |
| 618 | +{{define "articles"}}<section> |
| 619 | +<h1>Articles</h1> |
| 620 | <p>Long-form NIP-23 events published on this relay.</p> |
| 621 | {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}} |
| 622 | -<p><a href="/feed.xml">Atom/RSS feed</a> | <a href="/articles.json">JSON feed</a></p> |
| 623 | -</main> |
| 624 | +<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p> |
| 625 | +</section> |
| 626 | {{end}} |
| 627 | |
| 628 | -{{define "event"}}<main> |
| 629 | -<h2>Event</h2> |
| 630 | -<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p> |
| 631 | +{{define "event"}}<section> |
| 632 | +<h1>Event</h1> |
| 633 | {{if .Feed}}{{template "eventList" .}}{{else}}{{template "nostrEvent" .Event}}{{end}} |
| 634 | -</main> |
| 635 | +<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p> |
| 636 | +</section> |
| 637 | {{end}} |
| 638 | |
| 639 | -{{define "view"}}<main> |
| 640 | -<h2>View: {{.View}}</h2> |
| 641 | +{{define "view"}}<section> |
| 642 | +<h1>View: {{.View}}</h1> |
| 643 | {{template "jsonView" .Event}} |
| 644 | -</main> |
| 645 | +</section> |
| 646 | {{end}} |
| 647 | |
| 648 | -{{define "invite"}}<main> |
| 649 | -<h2>Join this relay</h2> |
| 650 | +{{define "invite"}}<section> |
| 651 | +<h1>Join this relay</h1> |
| 652 | <p>This invite is claimed with one signature from your Nostr signer. The relay never receives a private key.</p> |
| 653 | -{{if .Policy.JoinTerms}}<h3>Before you join</h3> |
| 654 | +{{if .Policy.JoinTerms}}<h2>Before you join</h2> |
| 655 | <pre>{{.Policy.JoinTerms}}</pre> |
| 656 | {{end}}<rpc-form method="claiminvite" action="/api/invites/claim" terms="{{.Policy.JoinTerms}}"> |
| 657 | <input type="hidden" name="param" value="{{.Invite}}"> |
| 658 | {{if .Policy.JoinTerms}}<label><input type="checkbox" name="termsAgreement" required> I agree to these relay terms.</label> |
| 659 | {{end}}<button>Join relay</button> |
| 660 | </rpc-form> |
| 661 | -</main> |
| 662 | +</section> |
| 663 | {{end}} |
| 664 | |
| 665 | -{{define "terms"}}<main> |
| 666 | -<h2>Terms</h2> |
| 667 | -<p>{{.Policy.JoinTerms}}</p> |
| 668 | -</main> |
| 669 | +{{define "terms"}}<section> |
| 670 | +<h1>Terms</h1> |
| 671 | +{{if .Policy.JoinTerms}}{{markdown .Policy.JoinTerms}}{{else}}<p>This relay has not published terms.</p>{{end}} |
| 672 | +</section> |
| 673 | {{end}} |
| 674 | |
| 675 | -{{define "tools"}}<main> |
| 676 | -<h2>Browser tools</h2> |
| 677 | +{{define "tools"}}<section> |
| 678 | +<h1>Browser tools</h1> |
| 679 | <p>Use a browser agent to browse repositories and files, check relay health, manage jobs and create backups. Connect your signer to manage the relay.</p> |
| 680 | <p id="webmcp-status" role="status">Checking browser support...</p> |
| 681 | <ul id="webmcp-tools"></ul> |
| 682 | @@ -129,41 +133,35 @@ |
| 683 | update(); |
| 684 | window.tinyWebMCP?.ready.then(update); |
| 685 | })();</script> |
| 686 | -</main> |
| 687 | +</section> |
| 688 | {{end}} |
| 689 | |
| 690 | -{{define "private"}}<main> |
| 691 | -<h2>Private messages</h2> |
| 692 | +{{define "private"}}<section> |
| 693 | +<h1>Private messages</h1> |
| 694 | <p>Sign in with a Nostr client to view addressed private events.</p> |
| 695 | -</main> |
| 696 | +</section> |
| 697 | {{end}} |
| 698 | |
| 699 | -{{define "chat"}}<main> |
| 700 | -<h2>Chat</h2> |
| 701 | +{{define "chat"}}<section> |
| 702 | +<h1>Chat</h1> |
| 703 | <p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p> |
| 704 | -</main> |
| 705 | +</section> |
| 706 | {{end}} |
| 707 | |
| 708 | -{{define "media"}}<main> |
| 709 | -<h2>Media</h2> |
| 710 | +{{define "media"}}<section> |
| 711 | +<h1>Media</h1> |
| 712 | <p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p> |
| 713 | -</main> |
| 714 | -{{end}} |
| 715 | - |
| 716 | -{{define "site"}}<main> |
| 717 | -<h2>Sites</h2> |
| 718 | -<p>This relay serves signed site manifests and snapshots when enabled by policy.</p> |
| 719 | -</main> |
| 720 | +</section> |
| 721 | {{end}} |
| 722 | |
| 723 | -{{define "marmot"}}<main> |
| 724 | -<h2>Marmot</h2> |
| 725 | +{{define "marmot"}}<section> |
| 726 | +<h1>Marmot</h1> |
| 727 | <p>Marmot group state is served when the feature is enabled.</p> |
| 728 | -</main> |
| 729 | +</section> |
| 730 | {{end}} |
| 731 | |
| 732 | -{{define "grasp"}}<main> |
| 733 | -<h2>GRASP</h2> |
| 734 | +{{define "grasp"}}<section> |
| 735 | +<h1>GRASP</h1> |
| 736 | <p>Repository state and relay capabilities are served through the GRASP endpoints.</p> |
| 737 | -</main> |
| 738 | +</section> |
| 739 | {{end}} |
| 740 | diff --git a/internal/webui/public_pages.go b/internal/webui/public_pages.go |
| 741 | index 7ccef30..7d42160 100644 |
| 742 | --- a/internal/webui/public_pages.go |
| 743 | +++ b/internal/webui/public_pages.go |
| 744 | @@ -7,8 +7,12 @@ import ( |
| 745 | "html/template" |
| 746 | "net/http" |
| 747 | "net/url" |
| 748 | + "strconv" |
| 749 | "strings" |
| 750 | "time" |
| 751 | + |
| 752 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 753 | + "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 754 | ) |
| 755 | |
| 756 | func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Values) ([]any, error) { |
| 757 | @@ -29,9 +33,14 @@ func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Valu |
| 758 | filter = map[string]any{"authors": []string{actor}, "limit": 50} |
| 759 | case "articles": |
| 760 | filter = map[string]any{"kinds": []int{30023}, "limit": 50} |
| 761 | + case "home": |
| 762 | + filter = map[string]any{"kinds": []int{1, 30023}, "limit": 12} |
| 763 | + case "sites": |
| 764 | + filter = map[string]any{"kinds": []int{sites.KindSite, sites.KindNamedSite}, "limit": 100} |
| 765 | default: |
| 766 | return nil, nil |
| 767 | } |
| 768 | + applyFeedFilters(filter, values) |
| 769 | raw, err := json.Marshal(filter) |
| 770 | if err != nil { |
| 771 | return nil, err |
| 772 | @@ -51,6 +60,62 @@ func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Valu |
| 773 | return feed, nil |
| 774 | } |
| 775 | |
| 776 | +// applyFeedFilters narrows a feed by the panel filters: kinds, author, since. |
| 777 | +// "all" removes the kind restriction; unknown values are ignored. |
| 778 | +func applyFeedFilters(filter map[string]any, values url.Values) { |
| 779 | + switch kinds := strings.TrimSpace(values.Get("kinds")); { |
| 780 | + case kinds == "all": |
| 781 | + delete(filter, "kinds") |
| 782 | + case kinds != "": |
| 783 | + var list []int |
| 784 | + for _, part := range strings.Split(kinds, ",") { |
| 785 | + if kind, err := strconv.Atoi(strings.TrimSpace(part)); err == nil { |
| 786 | + list = append(list, kind) |
| 787 | + } |
| 788 | + } |
| 789 | + if len(list) > 0 { |
| 790 | + filter["kinds"] = list |
| 791 | + } |
| 792 | + } |
| 793 | + if author := strings.ToLower(strings.TrimSpace(values.Get("author"))); len(author) == 64 { |
| 794 | + filter["authors"] = []string{author} |
| 795 | + } |
| 796 | + if since := strings.TrimSpace(values.Get("since")); since != "" { |
| 797 | + if parsed, err := time.Parse("2006-01-02", since); err == nil { |
| 798 | + filter["since"] = parsed.Unix() |
| 799 | + } |
| 800 | + } |
| 801 | +} |
| 802 | + |
| 803 | +// siteRows turns site manifest events into rows with the host each site is |
| 804 | +// served from, using the same labels the daemon uses for site hosts. |
| 805 | +func (a *App) siteRows(feed []any) []any { |
| 806 | + base, err := url.Parse(a.backend.URL()) |
| 807 | + if err != nil { |
| 808 | + return nil |
| 809 | + } |
| 810 | + rows := make([]any, 0, len(feed)) |
| 811 | + for _, item := range feed { |
| 812 | + encoded, _ := json.Marshal(item) |
| 813 | + var e event.Event |
| 814 | + if json.Unmarshal(encoded, &e) != nil { |
| 815 | + continue |
| 816 | + } |
| 817 | + label := sites.SiteLabel(e) |
| 818 | + if label == "" { |
| 819 | + continue |
| 820 | + } |
| 821 | + scheme := base.Scheme |
| 822 | + if scheme == "" || scheme == "ws" { |
| 823 | + scheme = "http" |
| 824 | + } else if scheme == "wss" { |
| 825 | + scheme = "https" |
| 826 | + } |
| 827 | + rows = append(rows, map[string]any{"id": e.ID, "kind": e.Kind, "author": e.PubKey, "label": label, "paths": len(sites.SitePaths(e)), "created_at": e.CreatedAt, "url": scheme + "://" + label + "." + base.Hostname()}) |
| 828 | + } |
| 829 | + return rows |
| 830 | +} |
| 831 | + |
| 832 | type publicAuthError struct{} |
| 833 | |
| 834 | func (*publicAuthError) Error() string { return "authentication required" } |
| 835 | diff --git a/internal/webui/repo.html b/internal/webui/repo.html |
| 836 | index 32e778b..18f4f19 100644 |
| 837 | --- a/internal/webui/repo.html |
| 838 | +++ b/internal/webui/repo.html |
| 839 | @@ -1,57 +1,39 @@ |
| 840 | -{{define "repo"}}<main> |
| 841 | -<p><a href="/repos">All repositories</a></p> |
| 842 | -{{if .Error}}<h2>{{.Query.Get "repo"}}</h2> |
| 843 | -{{template "alert" .Error}} |
| 844 | -{{else}}{{$repo := asMap .Event}}{{$view := .Query.Get "view"}} |
| 845 | -<section id="repo-head"> |
| 846 | -<h2>{{str (index $repo "identifier")}}</h2> |
| 847 | -<p><small>{{if index $repo "private"}}Private{{else}}Public{{end}} repository | Owner {{template "nostrKey" (str (index $repo "owner"))}}{{with index $repo "commit"}} | Commit <code title="{{.}}">{{shortID .}}</code>{{end}}</small></p> |
| 848 | -<nav id="repo-tabs" aria-label="Repository views"> |
| 849 | -<a href="{{repoURL .Query "tree" ""}}"{{if or (eq $view "tree") (eq $view "file")}} aria-current="page"{{end}}>Code</a> |
| 850 | -<a href="{{repoURL .Query "history" ""}}"{{if or (eq $view "history") (eq $view "commit")}} aria-current="page"{{end}}>History</a> |
| 851 | -<a href="{{repoURL .Query "activity" ""}}"{{if eq $view "activity"}} aria-current="page"{{end}}>Activity</a> |
| 852 | -</nav> |
| 853 | -<form id="repo-ref" method="get" action="/repo"> |
| 854 | -<input type="hidden" name="owner" value="{{.Query.Get "owner"}}"> |
| 855 | -<input type="hidden" name="repo" value="{{.Query.Get "repo"}}"> |
| 856 | -<input type="hidden" name="path" value="{{.Query.Get "path"}}"> |
| 857 | -<input type="hidden" name="view" value="{{if eq $view "commit"}}tree{{else}}{{$view}}{{end}}"> |
| 858 | -<label>Branch or tag <select name="ref"><option value="">Default branch</option>{{range $name, $oid := index $repo "refs"}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select></label> |
| 859 | -<button>Go</button> |
| 860 | -</form> |
| 861 | -<details id="repo-clone"> |
| 862 | -<summary>Clone</summary> |
| 863 | -{{range index $repo "clone"}}<p><input aria-label="Clone URL" readonly value="{{.}}" onclick="this.select()"></p> |
| 864 | -{{else}}<p>No clone URL advertised.</p> |
| 865 | -{{end}}</details> |
| 866 | +{{define "repo"}}{{if .Error}}<section><h1>{{.Query.Get "repo"}}</h1>{{template "alert" .Error}}</section> |
| 867 | +{{else}}{{$repo := asMap .Event}}{{$view := repoView .Query}}{{if eq $view "home"}}<section id="readme"> |
| 868 | +{{if .Readme}}{{.Readme}}{{else}}<h1>{{str (index $repo "identifier")}}</h1><p>This repository has no README at its default branch.</p>{{end}} |
| 869 | </section> |
| 870 | -{{if or (eq $view "tree") (eq $view "file")}}<nav id="repo-breadcrumbs" aria-label="File path">{{range $index, $crumb := repoBreadcrumbs .Query}}{{if $index}} / {{end}}{{if $crumb.URL}}<a href="{{$crumb.URL}}">{{$crumb.Label}}</a>{{else}}<strong>{{$crumb.Label}}</strong>{{end}}{{end}}</nav> |
| 871 | -{{end}}{{if eq $view "file"}}<p id="file-heading"><strong>{{.Query.Get "path"}}</strong> <a href="/repo/raw?owner={{urlquery (.Query.Get "owner")}}&repo={{urlquery (.Query.Get "repo")}}&ref={{urlquery (.Query.Get "ref")}}&path={{urlquery (.Query.Get "path")}}">Raw/download</a></p> |
| 872 | +{{else if or (eq $view "tree") (eq $view "file")}}<table id="split"><tbody><tr><td id="tree"><header><a href="{{repoURL .Query "tree" ""}}">{{str (index $repo "identifier")}}/</a>{{range $index, $crumb := repoBreadcrumbs .Query}}{{if $index}}{{if $crumb.URL}}<a href="{{$crumb.URL}}">{{$crumb.Label}}</a>{{else}}<b>{{$crumb.Label}}</b>{{end}}/{{end}}{{end}}</header> |
| 873 | +{{template "repoTree" .}}</td> |
| 874 | +<td id="preview">{{if eq $view "file"}}<header>{{.Query.Get "path"}} <a href="/repo/raw?owner={{urlquery (.Query.Get "owner")}}&repo={{urlquery (.Query.Get "repo")}}&ref={{urlquery (.Query.Get "ref")}}&path={{urlquery (.Query.Get "path")}}">raw</a></header> |
| 875 | {{if index $repo "binary"}}<p>This is a binary file. Download it to view its contents.</p> |
| 876 | {{else if index $repo "content"}}{{sourceHTML .Event}} |
| 877 | {{else}}<p>This file is empty.</p> |
| 878 | -{{end}}{{else if eq $view "tree"}}<scroll-box><table id="repo-tree"> |
| 879 | -<thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Object</th></tr></thead> |
| 880 | +{{end}}{{else}}<p>select a file to preview</p>{{end}}</td></tr></tbody></table> |
| 881 | +{{else if eq $view "history"}}<section> |
| 882 | +<table id="rows"> |
| 883 | <tbody> |
| 884 | -{{range index $repo "entries"}}{{$entry := asMap .}}<tr><td>{{if eq (str (index $entry "type")) "tree"}}<small aria-label="Directory">dir</small> <a href="{{repoURL $.Query "tree" (str (index $entry "path"))}}">{{str (index $entry "name")}}/</a>{{else if eq (str (index $entry "type")) "commit"}}<small>submodule</small> {{str (index $entry "name")}}{{else}}<small aria-label="File">file</small> <a href="{{repoURL $.Query "file" (str (index $entry "path"))}}">{{str (index $entry "name")}}</a>{{end}}</td><td>{{if eq (str (index $entry "type")) "blob"}}{{str (index $entry "size")}} B{{else}}-{{end}}</td><td><code title="{{str (index $entry "oid")}}">{{shortID (index $entry "oid")}}</code></td></tr> |
| 885 | -{{else}}<tr><td colspan="3">No published files in this directory.</td></tr> |
| 886 | -{{end}}</tbody> |
| 887 | -</table></scroll-box> |
| 888 | -{{else if eq $view "history"}}<scroll-box><table id="repo-history"> |
| 889 | -<thead><tr><th scope="col">Commit</th><th scope="col">Message</th><th scope="col">Author and date</th></tr></thead> |
| 890 | -<tbody> |
| 891 | -{{range index $repo "commits"}}{{$commit := asMap .}}<tr><td><a href="{{repoCommitURL $.Query (str (index $commit "oid"))}}"><code>{{shortID (index $commit "oid")}}</code></a></td><td>{{str (index $commit "subject")}}</td><td>{{str (index $commit "author")}}<br><small>{{repoDate (index $commit "time")}}</small></td></tr> |
| 892 | +{{range index $repo "commits"}}{{$commit := asMap .}}<tr><td>{{str (index $commit "author")}}</td><td><a href="{{repoCommitURL $.Query (str (index $commit "oid"))}}">{{str (index $commit "subject")}}</a></td><td>{{shortID (index $commit "oid")}} {{short (index $commit "time")}}</td></tr> |
| 893 | {{else}}<tr><td colspan="3">No commits.</td></tr> |
| 894 | {{end}}</tbody> |
| 895 | -</table></scroll-box> |
| 896 | -{{else if eq $view "commit"}}<h3>Commit <code>{{shortID (index $repo "commit")}}</code></h3> |
| 897 | -<p><a href="{{repoURL .Query "tree" ""}}">Browse files at this commit</a></p> |
| 898 | +</table> |
| 899 | +<p id="pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query "history" ""}}">first page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{browsePageURL "/repo" .Query "offset" (index $repo "next_offset")}}">next page</a>{{end}}</p> |
| 900 | +</section> |
| 901 | +{{else if eq $view "commit"}}<section> |
| 902 | +<h1>Commit <code>{{shortID (index $repo "commit")}}</code></h1> |
| 903 | +<p><a href="{{repoURL .Query "tree" ""}}">browse files at this commit</a> | <a href="{{repoURL .Query "history" ""}}">history</a></p> |
| 904 | +</section> |
| 905 | {{diffHTML .Event}} |
| 906 | -{{else if eq $view "activity"}}{{with index $repo "events"}}<ol id="events"> |
| 907 | +{{else if eq $view "activity"}}<section> |
| 908 | +{{with index $repo "events"}}<ol id="events"> |
| 909 | {{range .}}<li>{{template "nostrEvent" .}}</li> |
| 910 | {{end}}</ol> |
| 911 | {{else}}<p>No repository activity.</p> |
| 912 | -{{end}}{{end}} |
| 913 | -{{if or (eq $view "tree") (eq $view "history")}}<p id="repo-pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query $view (.Query.Get "path")}}">First page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{browsePageURL "/repo" .Query "offset" (index $repo "next_offset")}}">Next page</a>{{end}}</p> |
| 914 | -{{end}}{{end}}</main> |
| 915 | +{{end}}</section> |
| 916 | +{{end}}{{end}}{{end}} |
| 917 | + |
| 918 | +{{define "repoTree"}}{{$repo := asMap .Event}}{{$entries := index $repo "entries"}}{{if .Tree}}{{$entries = .Tree}}{{end}}<ul> |
| 919 | +{{range $i, $e := $entries}}{{$entry := asMap $e}}{{$last := eq (add $i 1) (len $entries)}}<li><span>{{if $last}}└─ {{else}}├─ {{end}}</span>{{if eq (str (index $entry "type")) "tree"}}<a href="{{repoURL $.Query "tree" (str (index $entry "path"))}}">{{str (index $entry "name")}}/</a>{{else if eq (str (index $entry "type")) "commit"}}{{str (index $entry "name")}} <span>(submodule)</span>{{else}}<a href="{{repoURL $.Query "file" (str (index $entry "path"))}}"{{if eq (str (index $entry "path")) ($.Query.Get "path")}} aria-current="page"{{end}}>{{str (index $entry "name")}}</a>{{end}}</li> |
| 920 | +{{else}}<li><span> </span>no published files here</li> |
| 921 | +{{end}}</ul> |
| 922 | +<p id="pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query "tree" (.Query.Get "path")}}">first page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{browsePageURL "/repo" .Query "offset" (index $repo "next_offset")}}">more</a>{{end}}</p> |
| 923 | {{end}} |
| 924 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 925 | new file mode 100644 |
| 926 | index 0000000..83004d2 |
| 927 | --- /dev/null |
| 928 | +++ b/internal/webui/shell_test.go |
| 929 | @@ -0,0 +1,137 @@ |
| 930 | +package webui |
| 931 | + |
| 932 | +import ( |
| 933 | + "context" |
| 934 | + "encoding/json" |
| 935 | + "net/http" |
| 936 | + "net/http/httptest" |
| 937 | + "net/url" |
| 938 | + "strings" |
| 939 | + "testing" |
| 940 | + |
| 941 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 942 | +) |
| 943 | + |
| 944 | +type siteBackend struct { |
| 945 | + fakeBackend |
| 946 | + filter map[string]any |
| 947 | +} |
| 948 | + |
| 949 | +func (b *siteBackend) Query(_ context.Context, method string, params []json.RawMessage, _ string) (any, error) { |
| 950 | + if method != "queryevents" { |
| 951 | + return nil, nil |
| 952 | + } |
| 953 | + _ = json.Unmarshal(params[0], &b.filter) |
| 954 | + return []any{map[string]any{"id": strings.Repeat("1", 64), "pubkey": "0629c45c1b2820eb649f44a0ab5dd39a16497208eebbe1210b641855c809dbd7", "kind": 15128, "created_at": float64(1757200000), "content": "", "tags": [][]string{{"path", "/index.html", strings.Repeat("c", 64)}}}}, nil |
| 955 | +} |
| 956 | + |
| 957 | +func TestShellRendersRailPanelAndPrompt(t *testing.T) { |
| 958 | + backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))} |
| 959 | + app, err := New(backend, Options{}) |
| 960 | + if err != nil { |
| 961 | + t.Fatal(err) |
| 962 | + } |
| 963 | + for path, wants := range map[string][]string{ |
| 964 | + "/": {`id="mark"`, `href="/search"`, `<b>demo</b> » home`, `id="panel"`, `id="topbar"`, `rel="manifest"`}, |
| 965 | + "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</span>`}, |
| 966 | + "/repo?owner=aa&repo=notes&view=history": {`/history</a>`, `repos/notes/history`}, |
| 967 | + } { |
| 968 | + recorder := httptest.NewRecorder() |
| 969 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) |
| 970 | + body := recorder.Body.String() |
| 971 | + for _, want := range wants { |
| 972 | + if !strings.Contains(body, want) { |
| 973 | + t.Errorf("%s missing %q", path, want) |
| 974 | + } |
| 975 | + } |
| 976 | + if strings.Contains(body, "class=") { |
| 977 | + t.Errorf("%s carries a class attribute", path) |
| 978 | + } |
| 979 | + } |
| 980 | +} |
| 981 | + |
| 982 | +func TestRepositoryHomeRendersReadmeAsMarkdown(t *testing.T) { |
| 983 | + b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 984 | + app, err := New(b, Options{}) |
| 985 | + if err != nil { |
| 986 | + t.Fatal(err) |
| 987 | + } |
| 988 | + recorder := httptest.NewRecorder() |
| 989 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=alice&repo=notes&view=home", nil)) |
| 990 | + body := recorder.Body.String() |
| 991 | + if recorder.Code != http.StatusOK || !strings.Contains(body, `id="readme"`) || !strings.Contains(body, "<p>one two</p>") { |
| 992 | + t.Fatalf("repository home did not render the README: %d %s", recorder.Code, body) |
| 993 | + } |
| 994 | +} |
| 995 | + |
| 996 | +func TestSitesPageListsHostedSites(t *testing.T) { |
| 997 | + backend := &siteBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 998 | + app, err := New(backend, Options{}) |
| 999 | + if err != nil { |
| 1000 | + t.Fatal(err) |
| 1001 | + } |
| 1002 | + recorder := httptest.NewRecorder() |
| 1003 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/sites", nil)) |
| 1004 | + body := recorder.Body.String() |
| 1005 | + if recorder.Code != http.StatusOK || !strings.Contains(body, `href="http://npub1`) || !strings.Contains(body, ".relay.example") || !strings.Contains(body, "1 paths") { |
| 1006 | + t.Fatalf("sites page did not list the site: %d %s", recorder.Code, body) |
| 1007 | + } |
| 1008 | + if kinds, _ := backend.filter["kinds"].([]any); len(kinds) != 2 { |
| 1009 | + t.Fatalf("sites query kinds = %v", backend.filter["kinds"]) |
| 1010 | + } |
| 1011 | +} |
| 1012 | + |
| 1013 | +func TestFeedFiltersNarrowTheQuery(t *testing.T) { |
| 1014 | + filter := map[string]any{"kinds": []int{1, 30023}, "limit": 12} |
| 1015 | + applyFeedFilters(filter, url.Values{"kinds": {"7"}, "author": {strings.Repeat("b", 64)}, "since": {"2026-09-01"}}) |
| 1016 | + if kinds, _ := filter["kinds"].([]int); len(kinds) != 1 || kinds[0] != 7 { |
| 1017 | + t.Fatalf("kinds = %v", filter["kinds"]) |
| 1018 | + } |
| 1019 | + if authors, _ := filter["authors"].([]string); len(authors) != 1 { |
| 1020 | + t.Fatalf("authors = %v", filter["authors"]) |
| 1021 | + } |
| 1022 | + if since, _ := filter["since"].(int64); since != 1788220800 { |
| 1023 | + t.Fatalf("since = %v", filter["since"]) |
| 1024 | + } |
| 1025 | + applyFeedFilters(filter, url.Values{"kinds": {"all"}, "author": {"short"}}) |
| 1026 | + if _, ok := filter["kinds"]; ok { |
| 1027 | + t.Fatal("kinds were not cleared by all") |
| 1028 | + } |
| 1029 | +} |
| 1030 | + |
| 1031 | +func TestManifestCarriesTenantPrefixAndRelayName(t *testing.T) { |
| 1032 | + p := policy.Defaults(strings.Repeat("a", 64)) |
| 1033 | + p.Name = "my relay" |
| 1034 | + backend := &fakeBackend{policy: p} |
| 1035 | + app, err := New(backend, Options{}) |
| 1036 | + if err != nil { |
| 1037 | + t.Fatal(err) |
| 1038 | + } |
| 1039 | + recorder := httptest.NewRecorder() |
| 1040 | + request := httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil) |
| 1041 | + request.RequestURI = "/r/alice/manifest.webmanifest" |
| 1042 | + app.ServeHTTP(recorder, request) |
| 1043 | + body := recorder.Body.String() |
| 1044 | + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Header().Get("content-type"), "manifest+json") || !strings.Contains(body, `"start_url": "/r/alice/"`) || !strings.Contains(body, `"name": "my relay"`) || !strings.Contains(body, `"purpose": "monochrome"`) { |
| 1045 | + t.Fatalf("manifest: %d %s", recorder.Code, body) |
| 1046 | + } |
| 1047 | + for _, path := range []string{"/sw.js", "/icon.svg", "/icon-mono.svg"} { |
| 1048 | + recorder := httptest.NewRecorder() |
| 1049 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) |
| 1050 | + if recorder.Code != http.StatusOK { |
| 1051 | + t.Errorf("%s: %d", path, recorder.Code) |
| 1052 | + } |
| 1053 | + } |
| 1054 | +} |
| 1055 | + |
| 1056 | +func TestPromptPathSpellsOutRepositoriesAndFiles(t *testing.T) { |
| 1057 | + if got := promptPath("/", nil); got != "home" { |
| 1058 | + t.Fatalf("home prompt = %q", got) |
| 1059 | + } |
| 1060 | + if got := promptPath("/repo", url.Values{"repo": {"notes"}, "view": {"file"}}); got != "repos/notes/file" { |
| 1061 | + t.Fatalf("repo prompt = %q", got) |
| 1062 | + } |
| 1063 | + if got := promptPath("/manage/people", nil); got != "manage/people" { |
| 1064 | + t.Fatalf("manage prompt = %q", got) |
| 1065 | + } |
| 1066 | +} |
| 1067 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 1068 | index f7a99ac..2ece0bb 100644 |
| 1069 | --- a/internal/webui/style.css |
| 1070 | +++ b/internal/webui/style.css |
| 1071 | @@ -1,105 +1,179 @@ |
| 1072 | -html,body{margin:0;padding:0} |
| 1073 | -body{font-family:system-ui,sans-serif;line-height:1.4;color:#202124} |
| 1074 | -#layout{border-collapse:collapse;table-layout:fixed;width:960px;margin:0 auto} |
| 1075 | -#layout>tbody>tr>td{padding:12px;vertical-align:top;overflow-wrap:anywhere} |
| 1076 | -#layout>tbody>tr>td>header{border-bottom:1px solid #bbb} |
| 1077 | -#layout>tbody>tr>td>footer{border-top:1px solid #bbb;margin-top:1rem;padding-top:.5rem} |
| 1078 | -h1{font-size:1.6rem} |
| 1079 | -h2{font-size:1.3rem} |
| 1080 | -h3{font-size:1.05rem;margin:1.5rem 0 .5rem;border-bottom:1px solid #e2e2e2} |
| 1081 | -input,textarea,button,select{max-width:100%;box-sizing:border-box;font:inherit} |
| 1082 | -button{padding:.3rem .65rem} |
| 1083 | -textarea{width:100%;min-height:5em;vertical-align:top} |
| 1084 | +/* Palette: paper and ink with forest accents. The dark variant follows the |
| 1085 | + system preference or the footer toggle, which sets data-theme on html. */ |
| 1086 | +:root{--paper:#fbfaf7;--ink:#1d1c1a;--muted:#6f6a60;--rail:#f4f2ec;--line:#e5e1d7;--accent:#2f6b3a;--soft:#e4efdf;--hover:#f0ede5;--add:#e4f0dd;--del:#f6e6df;--alert:#a63a2b;--g0:#edece7;--g1:#cfe3c4;--g2:#9cc48a;--g3:#5f9a4e;--g4:#2f6b3a;--bark:#8a5a1f;--w1:#7a4f1d;--w2:#5f9a4e;--w3:#2f6b3a;--w4:#b5643c;--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",sans-serif} |
| 1087 | +html[data-theme=dark]{--paper:#171614;--ink:#d9d6cf;--muted:#8d887c;--rail:#1e1d1a;--line:#2c2a26;--accent:#8fc27a;--soft:#22301f;--hover:#242320;--add:#223521;--del:#3a2a24;--alert:#e08a7a;--g0:#26251f;--g1:#2a3d24;--g2:#3b5a2f;--g3:#4f7d3d;--g4:#8fc27a;--bark:#d3a15c;--w1:#d3a15c;--w2:#9cc48a;--w3:#6ea55a;--w4:#d98a5c} |
| 1088 | +@media(prefers-color-scheme:dark){html:not([data-theme=light]){--paper:#171614;--ink:#d9d6cf;--muted:#8d887c;--rail:#1e1d1a;--line:#2c2a26;--accent:#8fc27a;--soft:#22301f;--hover:#242320;--add:#223521;--del:#3a2a24;--alert:#e08a7a;--g0:#26251f;--g1:#2a3d24;--g2:#3b5a2f;--g3:#4f7d3d;--g4:#8fc27a;--bark:#d3a15c;--w1:#d3a15c;--w2:#9cc48a;--w3:#6ea55a;--w4:#d98a5c}} |
| 1089 | + |
| 1090 | +html,body{margin:0;padding:0;height:100%;overflow:hidden;overscroll-behavior:none} |
| 1091 | +body{font:14px/1.45 var(--sans);color:var(--ink);background:var(--paper)} |
| 1092 | +a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} |
| 1093 | +code,kbd,samp,pre{font-family:var(--mono);font-size:13px} |
| 1094 | +h1{font-size:24px;margin:0 0 12px}h2{font-size:17px;margin:22px 0 8px}h3{font-size:14px;margin:18px 0 6px} |
| 1095 | +p{margin:0 0 10px} |
| 1096 | +small{color:var(--muted)} |
| 1097 | +[role=alert]{color:var(--alert)} |
| 1098 | +[role=status]{color:var(--muted)} |
| 1099 | +input,select,textarea,button{font:inherit;color:inherit;background:var(--paper);border:1px solid var(--line);padding:4px 8px;box-sizing:border-box;max-width:100%} |
| 1100 | +button{cursor:pointer;background:var(--rail)}button:hover{background:var(--hover)}button:disabled{cursor:default;color:var(--muted)} |
| 1101 | +textarea{width:100%;min-height:5em;font-family:var(--mono);font-size:13px} |
| 1102 | pre{white-space:pre-wrap;overflow-wrap:anywhere} |
| 1103 | -code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace} |
| 1104 | -[role=alert]{color:#a40000} |
| 1105 | -[role=status]{color:#444} |
| 1106 | -small{color:#666} |
| 1107 | -nav a{white-space:nowrap} |
| 1108 | -#manage-nav{margin:.5rem 0 1rem} |
| 1109 | - |
| 1110 | -/* Data tables inside the page body. Wide tables sit in a scroll-box and |
| 1111 | - scroll sideways instead of squeezing columns; cells never break mid-word. */ |
| 1112 | +img{max-width:100%} |
| 1113 | + |
| 1114 | +/* Selection: chrome is not selectable, content and values are. */ |
| 1115 | +#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-event>header,diff-view caption small{user-select:none;-webkit-user-select:none} |
| 1116 | + |
| 1117 | +/* Shell: a table with three columns; each column scrolls on its own. */ |
| 1118 | +#topbar{display:none} |
| 1119 | +#layout{border-collapse:collapse;table-layout:fixed;width:100%;height:calc(100vh - 28px)} |
| 1120 | +#layout>tbody>tr>td{vertical-align:top;padding:0;position:relative;height:100%} |
| 1121 | +#railbox,#content,#panel{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;overscroll-behavior:contain} |
| 1122 | +#rail{width:240px;background:var(--rail);border-right:1px solid var(--line)} |
| 1123 | +#aside{width:280px;border-left:1px solid var(--line)} |
| 1124 | +#content>section{padding:14px 22px 30px} |
| 1125 | +#footer{position:fixed;left:0;right:0;bottom:0;height:28px;border-top:1px solid var(--line);background:var(--rail);font:13px var(--mono);padding:0 2ch 0 2.5ch;box-sizing:border-box} |
| 1126 | +#footer table{width:100%;border-collapse:collapse;height:27px}#footer td{border:0;padding:0;vertical-align:middle} |
| 1127 | +#footer td:last-child{text-align:right} |
| 1128 | +#prompt{color:var(--muted)}#prompt b{color:var(--ink);font-weight:500} |
| 1129 | +#who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)} |
| 1130 | +#who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline} |
| 1131 | +#theme{border:0;background:none;padding:0 0 0 8px;color:var(--muted);font:inherit} |
| 1132 | + |
| 1133 | +/* Rail */ |
| 1134 | +#rail header{height:60px;box-sizing:border-box;border-bottom:1px solid var(--line);font:13px var(--mono);display:table;width:100%} |
| 1135 | +#rail header>span{display:table-cell;vertical-align:middle;line-height:1.3;padding:0 2ch 0 2.5ch;overflow-wrap:break-word} |
| 1136 | +#rail header a{color:var(--muted)}#rail header b{font-weight:500} |
| 1137 | +#mark{display:inline-block;font:800 26px/1 var(--sans);letter-spacing:-.04em;text-decoration:underline;text-decoration-color:var(--accent);text-decoration-thickness:4px;text-underline-offset:5px} |
| 1138 | +#rail header a#mark{color:var(--ink)}#mark:hover{text-decoration-color:var(--ink)} |
| 1139 | +#mark i{font-style:normal}#mark i:nth-child(1){color:var(--w1)}#mark i:nth-child(2){color:var(--w4)}#mark i:nth-child(3){color:var(--w3)}#mark i:nth-child(4){color:var(--w2)} |
| 1140 | +#rail nav a{display:block;box-sizing:border-box;height:36px;padding:0 2ch 0 2.5ch;border-bottom:1px solid var(--line);font:13px/35px var(--mono);color:var(--ink)} |
| 1141 | +#rail nav a:hover{background:var(--hover);text-decoration:none} |
| 1142 | +#rail nav a[aria-current=page]{background:var(--soft)} |
| 1143 | +#rail p{padding:10px 2ch 10px 2.5ch;margin:0;font:12px var(--mono);color:var(--muted)} |
| 1144 | + |
| 1145 | +/* Panel */ |
| 1146 | +#panel section{padding:10px 14px;border-bottom:1px solid var(--line);font:12px var(--mono)} |
| 1147 | +#panel h4{margin:0 0 6px;font:12px var(--mono);color:var(--muted)} |
| 1148 | +#panel table{width:100%;border-collapse:collapse;margin:0} |
| 1149 | +#panel th{text-align:left;font-weight:normal;color:var(--muted);padding:2px 1ch 2px 0;border:0;white-space:nowrap;vertical-align:top} |
| 1150 | +#panel td{text-align:right;padding:2px 0;border:0;overflow-wrap:anywhere} |
| 1151 | +#panel ul{list-style:none;margin:0;padding:0}#panel li{line-height:24px;overflow-wrap:anywhere} |
| 1152 | +#panel li small{float:right} |
| 1153 | +#panel label{display:block;color:var(--muted);margin:8px 0 2px;font-size:11px;text-transform:uppercase;letter-spacing:.04em} |
| 1154 | +#panel input,#panel select{width:100%;font:12px var(--mono)} |
| 1155 | +#panel form button{margin-top:8px} |
| 1156 | +#panel json-view{margin-top:4px} |
| 1157 | + |
| 1158 | +/* Rows: the list rhythm used by repositories, files, history, and activity. */ |
| 1159 | +#rows{width:100%;border-collapse:collapse} |
| 1160 | +#rows td{height:36px;border-bottom:1px solid var(--line);padding:0 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:middle} |
| 1161 | +#rows td:first-child{width:120px;font:13px var(--mono);color:var(--accent)} |
| 1162 | +#rows td:last-child{width:170px;text-align:right;font:12px var(--mono);color:var(--muted)} |
| 1163 | +#rows tr:hover td{background:var(--hover)} |
| 1164 | +#rows th{text-align:left;font:12px var(--mono);color:var(--muted);font-weight:normal;padding:0 10px;height:28px;border-bottom:1px solid var(--line)} |
| 1165 | +#rows td a{color:inherit}#rows td:first-child a{color:var(--accent)} |
| 1166 | +#pages{font:12px var(--mono)} |
| 1167 | + |
| 1168 | +/* Data tables inside content, including json-view results. */ |
| 1169 | scroll-box{display:block;overflow-x:auto;max-width:100%} |
| 1170 | -main table{width:100%;border-collapse:collapse;margin:.5rem 0} |
| 1171 | -main th,main td{padding:.35rem .5rem;border-bottom:1px solid #e2e2e2;text-align:left;vertical-align:top;overflow-wrap:normal} |
| 1172 | -main th,main td code,main td nostr-key,main td time,main td a{white-space:nowrap} |
| 1173 | -main th[scope=row]{width:9em} |
| 1174 | -dl{margin:.5rem 0} |
| 1175 | -dt{font-weight:600} |
| 1176 | -dd{margin:0 0 .35rem 1rem} |
| 1177 | - |
| 1178 | -/* rpc-form: one signed management call. Labels wrap like inline text so short forms stay on one line. */ |
| 1179 | -rpc-form,signed-form,publish-list{display:block;margin:.6rem 0} |
| 1180 | -rpc-form label,signed-form label,publish-list label{display:inline-block;margin:0 .6rem .35rem 0;vertical-align:top} |
| 1181 | +scroll-box table,json-view table{width:100%;border-collapse:collapse;margin:.5rem 0} |
| 1182 | +scroll-box th,scroll-box td,json-view th,json-view td{padding:.35rem .5rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top;overflow-wrap:normal} |
| 1183 | +scroll-box th,json-view th,scroll-box td code,scroll-box td nostr-key,scroll-box td time,json-view td code,json-view td nostr-key,json-view td time{white-space:nowrap} |
| 1184 | +dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem} |
| 1185 | +#facts{border-collapse:collapse;font:13px var(--mono);width:auto} |
| 1186 | +#facts th{text-align:left;font-weight:normal;color:var(--muted);width:9em;padding:3px 0;border:0}#facts td{padding:3px 0;border:0} |
| 1187 | + |
| 1188 | +/* Identity: the relay entry page. */ |
| 1189 | +#identity{max-width:720px}#identity>header{border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:12px} |
| 1190 | +#identity>header img{width:56px;height:56px;border-radius:6px;vertical-align:middle;margin-right:12px;background:var(--soft);border:1px solid var(--line);object-fit:cover} |
| 1191 | +#identity>header h1{display:inline-block;vertical-align:middle;margin:0} |
| 1192 | +#identity>header small{display:block;margin-top:4px;font-family:var(--mono)} |
| 1193 | +#identity>header small code{font-size:12px} |
| 1194 | +#tags a{display:inline-block;font:12px var(--mono);margin-right:8px} |
| 1195 | + |
| 1196 | +/* README and other rendered markdown. */ |
| 1197 | +#readme{max-width:720px}#readme h1{font-size:24px;border-bottom:1px solid var(--line);padding-bottom:8px}#readme h2{font-size:17px} |
| 1198 | +#readme pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre} |
| 1199 | +#readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px} |
| 1200 | + |
| 1201 | +/* rpc-form, signed-form, publish-list: one signed call each. */ |
| 1202 | +rpc-form,signed-form,publish-list{display:block;border-bottom:1px solid var(--line);padding:10px 0} |
| 1203 | +rpc-form label,signed-form label,publish-list label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:12px var(--mono);color:var(--muted)} |
| 1204 | +rpc-form label input,rpc-form label select,signed-form label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px} |
| 1205 | rpc-form label:has(textarea),publish-list label:has(textarea){display:block} |
| 1206 | -rpc-form output,signed-form output,publish-list output{display:block;margin:.3rem 0;color:#444} |
| 1207 | -rpc-form output[data-error]{color:#a40000} |
| 1208 | - |
| 1209 | -/* json-view: structured rendering of any JSON payload, with the raw text kept behind a details toggle. */ |
| 1210 | -json-view{display:block} |
| 1211 | -json-view>details>summary{cursor:pointer;color:#666} |
| 1212 | -json-view>details>pre{margin:.3rem 0;padding:.5rem;border:1px solid #e2e2e2;background:#fafafa;font-size:13px} |
| 1213 | -json-view table{font-size:14px} |
| 1214 | -json-view td pre{margin:0;white-space:pre;max-width:40em;overflow:auto} |
| 1215 | -json-view dl dl{margin:.2rem 0 .2rem 1rem} |
| 1216 | -json-view ul{margin:.2rem 0;padding-left:1.2rem} |
| 1217 | -json-view code{overflow-wrap:anywhere} |
| 1218 | - |
| 1219 | -/* nostr-event: a single event in feeds, activity, and detail pages. */ |
| 1220 | +rpc-form output,signed-form output,publish-list output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px} |
| 1221 | +rpc-form output[data-error]{color:var(--alert)} |
| 1222 | +#content>section>h3{border-bottom:0;margin-top:22px;font-size:15px} |
| 1223 | + |
| 1224 | +/* json-view: structured rendering of any JSON payload. */ |
| 1225 | +json-view{display:block;font:12px var(--mono)} |
| 1226 | +json-view>details>summary{cursor:pointer;color:var(--muted)} |
| 1227 | +json-view>details>pre{margin:.3rem 0;padding:.5rem;border:1px solid var(--line);background:var(--rail);font-size:12px} |
| 1228 | +json-view table{font-size:12px}json-view td pre{margin:0;white-space:pre;max-width:40em;overflow:auto} |
| 1229 | +json-view dl dl{margin:.2rem 0 .2rem 1rem}json-view ul{margin:.2rem 0;padding-left:1.2rem}json-view code{overflow-wrap:anywhere} |
| 1230 | + |
| 1231 | +/* nostr-event: one event in feeds, activity, and detail pages. */ |
| 1232 | #events{list-style:none;margin:0;padding:0} |
| 1233 | -nostr-event{display:block;padding:.6rem 0;border-bottom:1px solid #e2e2e2} |
| 1234 | -nostr-event>p{margin:0 0 .3rem;color:#555} |
| 1235 | -nostr-event>pre{margin:0} |
| 1236 | -nostr-event>details{margin-top:.3rem} |
| 1237 | -nostr-event>details>summary{cursor:pointer;color:#666} |
| 1238 | -nostr-key{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;cursor:copy} |
| 1239 | - |
| 1240 | -/* Repository browsing. */ |
| 1241 | -#repo-head{border:1px solid #d0d0d0;padding:12px;background:#fafafa} |
| 1242 | -#repo-head h2{margin:0 0 6px} |
| 1243 | -#repo-head p{margin:0 0 10px} |
| 1244 | -#repo-tabs{display:block;border-bottom:1px solid #bbb;margin:1rem 0} |
| 1245 | -#repo-tabs a{display:inline-block;padding:.45rem .7rem;text-decoration:none} |
| 1246 | -#repo-tabs a[aria-current=page]{border:1px solid #bbb;border-bottom-color:white;margin-bottom:-1px;background:white;font-weight:600} |
| 1247 | -#repo-ref{display:inline-block;margin:0 .5rem 0 0;vertical-align:middle} |
| 1248 | -#repo-clone{display:inline-block;vertical-align:middle} |
| 1249 | -#repo-clone input{width:40em;font:13px ui-monospace,monospace} |
| 1250 | -#repo-breadcrumbs{margin:16px 0;font:14px ui-monospace,monospace} |
| 1251 | -#file-heading{padding:8px 12px;margin:0;border:1px solid #ccc;border-bottom:0;background:#f5f5f5} |
| 1252 | -#file-heading a{float:right} |
| 1253 | -#repo-tree small{display:inline-block;width:5.5em;color:#777;font:12px ui-monospace,monospace} |
| 1254 | -#repo-tree td:last-child,#repo-tree th:last-child{font-family:ui-monospace,monospace} |
| 1255 | -#repo-pages a{margin-right:16px} |
| 1256 | +nostr-event{display:block;border-bottom:1px solid var(--line);padding:10px 0} |
| 1257 | +nostr-event>header{font:12px var(--mono);color:var(--muted);margin-bottom:4px} |
| 1258 | +nostr-event>header small{float:right} |
| 1259 | +nostr-event>pre{margin:0;white-space:pre-wrap;font:14px/1.45 var(--sans)} |
| 1260 | +nostr-event>details{margin-top:6px;font:12px var(--mono)}nostr-event>details>summary{cursor:pointer;color:var(--muted)} |
| 1261 | +nostr-key{font-family:var(--mono);cursor:copy} |
| 1262 | + |
| 1263 | +/* Repository: tree with a preview pane. */ |
| 1264 | +#split{width:100%;border-collapse:collapse;table-layout:fixed;margin:0} |
| 1265 | +#split>tbody>tr>td{vertical-align:top;padding:0;border:0} |
| 1266 | +#tree{width:300px;overflow:auto;border-right:1px solid var(--line);font:13px/24px var(--mono);padding:8px 0 20px} |
| 1267 | +#tree header{padding:0 12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#tree header small{float:right} |
| 1268 | +#tree ul{list-style:none;margin:0;padding:0 0 0 12px} |
| 1269 | +#tree li{white-space:nowrap}#tree li>span{color:var(--muted)} |
| 1270 | +#tree a{color:var(--ink)}#tree a[aria-current=page]{background:var(--soft);padding:0 4px} |
| 1271 | +#preview{padding:0;overflow:hidden} |
| 1272 | +#preview header{padding:0 12px;height:36px;line-height:36px;border-bottom:1px solid var(--line);font:13px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} |
| 1273 | +#preview header a{float:right} |
| 1274 | +#preview p{color:var(--muted);padding:24px 12px;font:13px var(--mono)} |
| 1275 | +#preview>section{padding:12px} |
| 1276 | |
| 1277 | /* source-view and diff-view wrap the tables emitted by the source renderer. */ |
| 1278 | -source-view,diff-view{display:block;overflow:auto;border:1px solid #ccc;background:#fafafa} |
| 1279 | -source-view table,diff-view table{border-collapse:collapse;width:100%;margin:0;font:13px/20px ui-monospace,SFMono-Regular,Consolas,monospace} |
| 1280 | -source-view td,diff-view td{padding:0 .75rem;border:0;vertical-align:top;white-space:pre;overflow-wrap:normal;word-break:normal} |
| 1281 | -source-view td:first-child,diff-view td:first-child{color:#888;text-align:right;user-select:none;border-right:1px solid #ddd} |
| 1282 | -source-view td:first-child a,diff-view td:first-child a{text-decoration:none;color:#777} |
| 1283 | +source-view,diff-view{display:block;overflow:auto} |
| 1284 | +diff-view{border:1px solid var(--line);margin:0 0 14px} |
| 1285 | +source-view table,diff-view table{border-collapse:collapse;width:100%;margin:0;font:13px/20px var(--mono)} |
| 1286 | +source-view td,diff-view td{padding:0 12px;border:0;white-space:pre;vertical-align:top;tab-size:4;overflow-wrap:normal;word-break:normal} |
| 1287 | +source-view td:first-child,diff-view td:first-child{color:var(--muted);text-align:right;user-select:none;width:1%;padding-right:8px} |
| 1288 | +source-view td:first-child a,diff-view td:first-child a{text-decoration:none;color:var(--muted)} |
| 1289 | source-view td:last-child,diff-view td:last-child{width:100%} |
| 1290 | -source-view tr:target,diff-view tr:target{background:#fff1bc} |
| 1291 | +source-view tr:target,diff-view tr:target{background:var(--soft)} |
| 1292 | source-view code,diff-view code{font:inherit} |
| 1293 | -source-view b{font-weight:600} |
| 1294 | -source-view i{font-style:normal;color:#777} |
| 1295 | -source-view q{quotes:none;color:#146b2d} |
| 1296 | -source-view q::before,source-view q::after{content:none} |
| 1297 | -source-view var{font-style:normal;color:#7a3e00} |
| 1298 | -source-view tfoot td,diff-view tfoot td{white-space:normal;color:#777;font-family:system-ui,sans-serif} |
| 1299 | -diff-view ins,diff-view del{text-decoration:none} |
| 1300 | -diff-view tr:has(ins){background:#e9f7e9} |
| 1301 | -diff-view tr:has(del){background:#fbeaea} |
| 1302 | -diff-view b{font-weight:600;color:#555} |
| 1303 | - |
| 1304 | -@media(max-width:979px){ |
| 1305 | -#layout{width:360px;max-width:100%;margin:0} |
| 1306 | -#layout>tbody>tr>td{padding:8px} |
| 1307 | -nav{line-height:2} |
| 1308 | -#repo-tabs{overflow-x:auto} |
| 1309 | -#repo-tabs a{white-space:nowrap} |
| 1310 | -#repo-tree th:nth-child(3),#repo-tree td:nth-child(3){display:none} |
| 1311 | -#repo-clone input{width:100%} |
| 1312 | -source-view table,diff-view table{font-size:12px} |
| 1313 | -source-view td,diff-view td{padding:0 .5rem} |
| 1314 | +source-view b{font-weight:600}source-view i{font-style:normal;color:var(--muted)}source-view q{quotes:none;color:var(--g4)}source-view q::before,source-view q::after{content:none}source-view var{font-style:normal;color:var(--bark)} |
| 1315 | +source-view tfoot td,diff-view tfoot td{white-space:normal;color:var(--muted);font-family:var(--sans)} |
| 1316 | +diff-view ins,diff-view del{text-decoration:none}diff-view tr:has(ins){background:var(--add)}diff-view tr:has(del){background:var(--del)}diff-view b{color:var(--muted);font-weight:600} |
| 1317 | +diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-bottom:1px solid var(--line);caption-side:top} |
| 1318 | + |
| 1319 | +/* Activity grid. */ |
| 1320 | +#grid{border-collapse:separate;border-spacing:2px;margin:0 0 8px;width:auto} |
| 1321 | +#grid td{width:10px;height:10px;padding:0;border:0;background:var(--g0)} |
| 1322 | +#grid td[data-l="1"]{background:var(--g1)}#grid td[data-l="2"]{background:var(--g2)}#grid td[data-l="3"]{background:var(--g3)}#grid td[data-l="4"]{background:var(--g4)} |
| 1323 | +#grid th{font:11px var(--mono);color:var(--muted);font-weight:normal;text-align:left;padding:0;border:0} |
| 1324 | +#gridhead{font:12px var(--mono);color:var(--muted);margin:0 0 6px} |
| 1325 | + |
| 1326 | +/* Sign in. */ |
| 1327 | +#signer-qr{display:block;margin:8px 0;background:#fff;padding:8px;border:1px solid var(--line)} |
| 1328 | + |
| 1329 | +/* Phones: a top bar with a menu, the rail slides in, the panel stacks under the content. */ |
| 1330 | +@media(max-width:720px){ |
| 1331 | +html,body{overflow:auto} |
| 1332 | +#topbar{display:block;position:fixed;top:0;left:0;right:0;height:40px;padding:0 2.5ch;background:var(--rail);border-bottom:1px solid var(--line);font:13px/40px var(--mono);color:var(--muted);z-index:3;box-sizing:border-box;white-space:nowrap;overflow:hidden} |
| 1333 | +#topbar b{color:var(--ink);font-weight:500}#menu{border:1px solid var(--line);padding:2px 10px;margin-right:10px;line-height:1.4} |
| 1334 | +#layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto} |
| 1335 | +#layout{padding:40px 0 28px} |
| 1336 | +#railbox,#content,#panel{position:static;overflow:visible} |
| 1337 | +#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;border-right:1px solid var(--line);transform:translateX(-102%);transition:transform .15s;z-index:2;overflow:auto} |
| 1338 | +html[data-menu=open] #layout>tbody>tr>td#rail{transform:none;box-shadow:8px 0 24px rgba(0,0,0,.15)} |
| 1339 | +#layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)} |
| 1340 | +#content>section{padding:14px 2.5ch 20px} |
| 1341 | +#prompt{display:none}#footer td:last-child{text-align:left} |
| 1342 | +#split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto} |
| 1343 | +#tree{border-right:0;border-bottom:1px solid var(--line)} |
| 1344 | +#rows td{white-space:normal;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px} |
| 1345 | +#grid,#gridhead,h2:has(+#gridhead){display:none} |
| 1346 | } |
| 1347 | diff --git a/internal/webui/sw.js b/internal/webui/sw.js |
| 1348 | new file mode 100644 |
| 1349 | index 0000000..6baf324 |
| 1350 | --- /dev/null |
| 1351 | +++ b/internal/webui/sw.js |
| 1352 | @@ -0,0 +1,9 @@ |
| 1353 | +// Service worker: makes the relay installable and keeps navigation working |
| 1354 | +// when the network fails by serving the last shell seen. Relay data is never |
| 1355 | +// cached; every request goes to the network first. |
| 1356 | +self.addEventListener("install", () => self.skipWaiting()); |
| 1357 | +self.addEventListener("activate", event => event.waitUntil(self.clients.claim())); |
| 1358 | +self.addEventListener("fetch", event => { |
| 1359 | + if (event.request.method !== "GET") return; |
| 1360 | + event.respondWith(fetch(event.request).catch(() => caches.match(event.request))); |
| 1361 | +}); |
| 1362 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 1363 | index 9f9578f..566a446 100644 |
| 1364 | --- a/internal/webui/templates.go |
| 1365 | +++ b/internal/webui/templates.go |
| 1366 | @@ -32,6 +32,72 @@ var bridgeJS string |
| 1367 | //go:embed components.js |
| 1368 | var componentsJS string |
| 1369 | |
| 1370 | +// Installable app assets. The manifest is a template: NAME and BASE are |
| 1371 | +// replaced per request so tenant prefixes and relay names stay correct. |
| 1372 | +// |
| 1373 | +//go:embed manifest.webmanifest |
| 1374 | +var manifestTemplate string |
| 1375 | + |
| 1376 | +//go:embed sw.js |
| 1377 | +var serviceWorkerJS []byte |
| 1378 | + |
| 1379 | +//go:embed icon.svg |
| 1380 | +var iconSVG []byte |
| 1381 | + |
| 1382 | +//go:embed icon-mono.svg |
| 1383 | +var iconMonoSVG []byte |
| 1384 | + |
| 1385 | +// navItem is one rail entry. Tab matches PageData.Tab for the active state. |
| 1386 | +type navItem struct{ Label, Href, Tab string } |
| 1387 | + |
| 1388 | +var relayNav = []navItem{{"/home", "/", "home"}, {"/search", "/search", "search"}, {"/repos", "/repos", "repos"}, {"/files", "/files", "files"}, {"/sites", "/sites", "sites"}, {"/inbox", "/inbox", "inbox"}, {"/outbox", "/outbox", "outbox"}, {"/articles", "/articles", "articles"}, {"/manage", "/manage/people", "manage"}} |
| 1389 | + |
| 1390 | +var manageNav = []navItem{{"/people", "/manage/people", "people"}, {"/moderation", "/manage/moderation", "moderation"}, {"/rules", "/manage/rules", "rules"}, {"/identity", "/manage/identity", "identity"}, {"/connect", "/manage/connect", "connect"}, {"/data", "/manage/data", "data"}, {"/sync", "/manage/sync", "sync"}, {"/views", "/manage/views", "views"}, {"/health", "/manage/health", "health"}, {"/owner", "/manage/owner", "owner"}, {"/status", "/manage/status", "status"}, {"/tools", "/tools", "tools"}} |
| 1391 | + |
| 1392 | +// railKind picks the sitemap for a tab: one repository, management, or the relay. |
| 1393 | +func railKind(tab string) string { |
| 1394 | + if tab == "repo" { |
| 1395 | + return "repo" |
| 1396 | + } |
| 1397 | + for _, item := range manageNav { |
| 1398 | + if item.Tab == tab { |
| 1399 | + return "manage" |
| 1400 | + } |
| 1401 | + } |
| 1402 | + return "relay" |
| 1403 | +} |
| 1404 | + |
| 1405 | +// promptPath renders the footer prompt: the path after the relay name, with |
| 1406 | +// repository pages spelled out as repos/<name>/<view>. |
| 1407 | +func promptPath(path string, query url.Values) string { |
| 1408 | + path = strings.Trim(path, "/") |
| 1409 | + switch { |
| 1410 | + case path == "": |
| 1411 | + return "home" |
| 1412 | + case path == "repo" && query.Get("repo") != "": |
| 1413 | + return "repos/" + query.Get("repo") + "/" + repoView(query) |
| 1414 | + case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""): |
| 1415 | + return "files/" + shortID(query.Get("sha")+query.Get("hash")) |
| 1416 | + } |
| 1417 | + return path |
| 1418 | +} |
| 1419 | + |
| 1420 | +// short renders a Unix timestamp as a compact UTC stamp for list rows. |
| 1421 | +func short(value any) string { |
| 1422 | + seconds := unixSeconds(value) |
| 1423 | + if seconds <= 0 { |
| 1424 | + return "" |
| 1425 | + } |
| 1426 | + return time.Unix(seconds, 0).UTC().Format("Jan 2 15:04") |
| 1427 | +} |
| 1428 | + |
| 1429 | +func repoView(query url.Values) string { |
| 1430 | + if view := query.Get("view"); view != "" { |
| 1431 | + return view |
| 1432 | + } |
| 1433 | + return "tree" |
| 1434 | +} |
| 1435 | + |
| 1436 | func parseTemplates() (*template.Template, error) { |
| 1437 | scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS} |
| 1438 | funcs := template.FuncMap{ |
| 1439 | @@ -50,18 +116,21 @@ func parseTemplates() (*template.Template, error) { |
| 1440 | } |
| 1441 | return string(encoded) |
| 1442 | }, |
| 1443 | - "join": strings.Join, |
| 1444 | - "urlquery": url.QueryEscape, |
| 1445 | - "asMap": valueMap, |
| 1446 | - "str": plainString, |
| 1447 | - "datetime": datetime, |
| 1448 | - "when": when, |
| 1449 | - "selectedView": func(query url.Values, view string) string { |
| 1450 | - if query.Get("view") == view || (query.Get("view") == "" && view == "tree") { |
| 1451 | - return " selected" |
| 1452 | - } |
| 1453 | - return "" |
| 1454 | - }, |
| 1455 | + "join": strings.Join, |
| 1456 | + "urlquery": url.QueryEscape, |
| 1457 | + "asMap": valueMap, |
| 1458 | + "str": plainString, |
| 1459 | + "datetime": datetime, |
| 1460 | + "when": when, |
| 1461 | + "markdown": renderMarkdown, |
| 1462 | + "npub": identityNpub, |
| 1463 | + "short": short, |
| 1464 | + "prompt": promptPath, |
| 1465 | + "railKind": railKind, |
| 1466 | + "repoView": repoView, |
| 1467 | + "relayItems": func() []navItem { return relayNav }, |
| 1468 | + "manageItems": func() []navItem { return manageNav }, |
| 1469 | + "add": func(a, b int) int { return a + b }, |
| 1470 | "repoCommitURL": repoCommitURL, |
| 1471 | "hasNextOffset": hasNextOffset, |
| 1472 | "repoURL": repoURL, |
| 1473 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 1474 | index 61be34d..ef0d3ab 100644 |
| 1475 | --- a/internal/webui/webui.go |
| 1476 | +++ b/internal/webui/webui.go |
| 1477 | @@ -76,6 +76,9 @@ type PageData struct { |
| 1478 | Base string |
| 1479 | Query url.Values |
| 1480 | Private bool |
| 1481 | + Path string |
| 1482 | + Readme template.HTML |
| 1483 | + Tree []any |
| 1484 | } |
| 1485 | |
| 1486 | func New(backend Backend, options Options) (*App, error) { |
| 1487 | @@ -191,6 +194,25 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 1488 | _, _ = writer.Write(fixiJS) |
| 1489 | return |
| 1490 | } |
| 1491 | + if request.URL.Path == "/sw.js" { |
| 1492 | + writer.Header().Set("content-type", "application/javascript; charset=utf-8") |
| 1493 | + writer.Header().Set("service-worker-allowed", "/") |
| 1494 | + _, _ = writer.Write(serviceWorkerJS) |
| 1495 | + return |
| 1496 | + } |
| 1497 | + if request.URL.Path == "/icon.svg" || request.URL.Path == "/icon-mono.svg" { |
| 1498 | + writer.Header().Set("content-type", "image/svg+xml; charset=utf-8") |
| 1499 | + if request.URL.Path == "/icon.svg" { |
| 1500 | + _, _ = writer.Write(iconSVG) |
| 1501 | + } else { |
| 1502 | + _, _ = writer.Write(iconMonoSVG) |
| 1503 | + } |
| 1504 | + return |
| 1505 | + } |
| 1506 | + if request.URL.Path == "/manifest.webmanifest" { |
| 1507 | + a.manifest(writer, request) |
| 1508 | + return |
| 1509 | + } |
| 1510 | if request.URL.Path == "/qr.svg" { |
| 1511 | a.qrImage(writer, request.URL.Query().Get("text")) |
| 1512 | return |
| 1513 | @@ -481,7 +503,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 1514 | switch method { |
| 1515 | case "browserepo": |
| 1516 | view := request.URL.Query().Get("view") |
| 1517 | - if view == "" { |
| 1518 | + if view == "" || view == "home" { |
| 1519 | view = "tree" |
| 1520 | } |
| 1521 | offset := 0 |
| 1522 | @@ -499,6 +521,10 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 1523 | } |
| 1524 | raw, _ := json.Marshal(query) |
| 1525 | params = append(params, raw) |
| 1526 | + var tree []any |
| 1527 | + if method == "browserepo" && request.URL.Query().Get("view") == "file" { |
| 1528 | + tree = a.siblings(request.Context(), actor, request.URL.Query()) |
| 1529 | + } |
| 1530 | result, err := a.backend.Query(request.Context(), method, params, actor) |
| 1531 | if err != nil { |
| 1532 | a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()}) |
| 1533 | @@ -509,9 +535,48 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 1534 | pageQuery.Set("view", "tree") |
| 1535 | } |
| 1536 | data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery} |
| 1537 | + if method == "browserepo" && pageQuery.Get("view") == "home" { |
| 1538 | + data.Readme = a.readme(request.Context(), actor, pageQuery) |
| 1539 | + } |
| 1540 | + data.Tree = tree |
| 1541 | a.render(writer, request, data) |
| 1542 | } |
| 1543 | |
| 1544 | +// readme renders README.md from the requested ref for the repository entry |
| 1545 | +// page. A missing or binary README simply yields no content. |
| 1546 | +func (a *App) readme(ctx context.Context, actor string, query url.Values) template.HTML { |
| 1547 | + for _, name := range []string{"README.md", "readme.md", "README"} { |
| 1548 | + raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": name, "view": "file", "limit": 1}) |
| 1549 | + result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor) |
| 1550 | + if err != nil { |
| 1551 | + continue |
| 1552 | + } |
| 1553 | + page := valueMap(result) |
| 1554 | + content, _ := page["content"].(string) |
| 1555 | + if binary, _ := page["binary"].(bool); binary || content == "" { |
| 1556 | + continue |
| 1557 | + } |
| 1558 | + return renderMarkdown(content) |
| 1559 | + } |
| 1560 | + return "" |
| 1561 | +} |
| 1562 | + |
| 1563 | +// siblings lists the directory that holds the file being previewed so the |
| 1564 | +// tree pane stays populated beside the file. |
| 1565 | +func (a *App) siblings(ctx context.Context, actor string, query url.Values) []any { |
| 1566 | + dir := "" |
| 1567 | + if slash := strings.LastIndex(query.Get("path"), "/"); slash >= 0 { |
| 1568 | + dir = query.Get("path")[:slash] |
| 1569 | + } |
| 1570 | + raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": dir, "view": "tree", "limit": 100}) |
| 1571 | + result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor) |
| 1572 | + if err != nil { |
| 1573 | + return nil |
| 1574 | + } |
| 1575 | + entries, _ := valueMap(result)["entries"].([]any) |
| 1576 | + return entries |
| 1577 | +} |
| 1578 | + |
| 1579 | func browseTab(path string) string { |
| 1580 | switch path { |
| 1581 | case "/repos": |
| 1582 | @@ -564,6 +629,7 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag |
| 1583 | if data.Query == nil { |
| 1584 | data.Query = request.URL.Query() |
| 1585 | } |
| 1586 | + data.Path = strings.TrimPrefix(request.URL.Path, data.Base) |
| 1587 | a.privatePageData(&data, request, actor) |
| 1588 | writer.Header().Set("Cache-Control", "private, no-store") |
| 1589 | writer.Header().Set("content-type", "text/html; charset=utf-8") |
| 1590 | @@ -633,6 +699,19 @@ func bech32Polymod(values []byte) uint64 { |
| 1591 | return checksum |
| 1592 | } |
| 1593 | |
| 1594 | +// manifest describes the installable app for this relay and tenant prefix. |
| 1595 | +func (a *App) manifest(writer http.ResponseWriter, request *http.Request) { |
| 1596 | + name := a.backend.Policy().Name |
| 1597 | + if name == "" || privatePolicyEnabled(a.backend.Policy()) { |
| 1598 | + name = a.backend.Slug() |
| 1599 | + } |
| 1600 | + encoded, _ := json.Marshal(name) |
| 1601 | + body := strings.ReplaceAll(manifestTemplate, `"NAME"`, string(encoded)) |
| 1602 | + body = strings.ReplaceAll(body, "BASE/", requestPrefix(request)+"/") |
| 1603 | + writer.Header().Set("content-type", "application/manifest+json; charset=utf-8") |
| 1604 | + _, _ = writer.Write([]byte(body)) |
| 1605 | +} |
| 1606 | + |
| 1607 | func (a *App) qrImage(writer http.ResponseWriter, text string) { |
| 1608 | if a.qr == nil || text == "" { |
| 1609 | http.Error(writer, "QR generation is not configured", http.StatusNotImplemented) |
| 1610 | @@ -654,10 +733,12 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) { |
| 1611 | actor = "" |
| 1612 | } |
| 1613 | data := PageData{Tab: tab, Query: request.URL.Query()} |
| 1614 | - if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" { |
| 1615 | + if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" || tab == "home" || tab == "sites" { |
| 1616 | feed, feedErr := a.publicFeed(request.Context(), tab, actor, request.URL.Query()) |
| 1617 | if feedErr != nil { |
| 1618 | data.Error = feedErr.Error() |
| 1619 | + } else if tab == "sites" { |
| 1620 | + data.Feed = a.siteRows(feed) |
| 1621 | } else { |
| 1622 | data.Feed = feed |
| 1623 | } |
| 1624 | @@ -790,7 +871,7 @@ func tabForPath(path string) string { |
| 1625 | return "files" |
| 1626 | case "file": |
| 1627 | return "file" |
| 1628 | - case "tools", "signin": |
| 1629 | + case "tools", "signin", "sites": |
| 1630 | return path |
| 1631 | case "people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner": |
| 1632 | return path |