clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

reorganize the web UI into a rail, content column, panel and prompt

Dami committed b55685d
Adopt the args.io scheme: a monospace path rail that changes with context
(relay, one repository, management), one content column, a panel of
facts and filters, and a command footer showing the relay name and path.
The relay entry page leads with its identity and description; the
repository entry page renders the README; files open in a preview pane
beside the tree; history and lists share one row rhythm. Add a sites
page, feed filters for kind, author and date, a forest palette with a
dark variant, per-column scrolling without rubber-banding, a phone
layout with a slide-in menu, and an installable app manifest with a
monochrome icon that Android tints with the system theme.
commit
b55685d47897e3286a57de564836054219b28b0c
parent
56c842a74b9eb8f56ad038b1667aef7eeff135ba
author
Dami <dami.etoile@gmail.com>
18 files changed, +826 -341
MAGENTS.md2+-
Minternal/webui/bridge.js19++
Minternal/webui/browse.html77+++++----
Minternal/webui/browse_test.go2+-
Ainternal/webui/icon-mono.svg1+
Ainternal/webui/icon.svg1+
Minternal/webui/manage.html79++++-----
Ainternal/webui/manifest.webmanifest14++
Minternal/webui/page.html93++++++++---
Minternal/webui/private.go2+-
Minternal/webui/public.html148++++++++--------
Minternal/webui/public_pages.go65+++++++
Minternal/webui/repo.html72+++-----
Ainternal/webui/shell_test.go137+++++++++++++++
Minternal/webui/style.css266+++++++++++++++++++-----------
Ainternal/webui/sw.js9+
Minternal/webui/templates.go93+++++++++-
Minternal/webui/webui.go87+++++++++-
M AGENTS.md +1 -1
M internal/webui/bridge.js +19 -0
M internal/webui/browse.html +41 -36
@@ -1,52 +1,57 @@
1-{{define "repos"}}<main>2-<h2>Repositories</h2>3-<p><a href="/repos">Refresh</a> | <a href="/manage/connect">Connect a repository relay</a></p>1+{{define "repos"}}<section>2+<h1>Repositories</h1>43 {{if .Error}}{{template "alert" .Error}}5-{{else if .Feed}}<scroll-box><table id="repo-list">6-<thead><tr><th scope="col">Repository</th><th scope="col">Owner</th><th scope="col">Head</th><th scope="col">Private</th></tr></thead>4+{{else if .Feed}}<table id="rows">5+<thead><tr><th>owner</th><th>repository</th><th>head</th></tr></thead>76 <tbody>8-{{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>7+{{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>98 {{end}}</tbody>10-</table></scroll-box>9+</table>1110 {{else}}<p>No repositories were reported.</p>12-{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/repos" $.Query "cursor" .}}">Next page</a></p>13-{{end}}</main>11+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/repos" $.Query "cursor" .}}">next page</a></p>12+{{end}}</section>1413 {{end}}1514 16-{{define "files"}}<main>17-<h2>Files</h2>18-<p><a href="/files">Refresh</a> | <a href="/manage/data">Upload or manage blobs</a></p>15+{{define "files"}}<section>16+<h1>Files</h1>1917 {{if .Error}}{{template "alert" .Error}}20-{{else if .Feed}}<scroll-box><table id="file-list">21-<thead><tr><th scope="col">Hash</th><th scope="col">Type</th><th scope="col">Size</th><th scope="col">Uploaded</th></tr></thead>18+{{else if .Feed}}<table id="rows">19+<thead><tr><th>hash</th><th>type</th><th>uploaded</th></tr></thead>2220 <tbody>23-{{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>21+{{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>2422 {{end}}</tbody>25-</table></scroll-box>23+</table>2624 {{else}}<p>No files were reported.</p>27-{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/files" $.Query "cursor" .}}">Next page</a></p>28-{{end}}</main>25+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/files" $.Query "cursor" .}}">next page</a></p>26+{{end}}</section>2927 {{end}}3028 31-{{define "file"}}<main>32-<h2>File</h2>33-<p><a href="/files">All files</a></p>29+{{define "file"}}{{if .Error}}<section><h1>File</h1>{{template "alert" .Error}}</section>30+{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}<section>31+<h1>File</h1>32+<table id="facts">33+<tr><th>sha256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>34+<tr><th>type</th><td>{{str (index $file "type")}}</td></tr>35+<tr><th>size</th><td>{{str (index $file "size")}} bytes</td></tr>36+{{with index $file "blossom_uri"}}<tr><th>blossom</th><td><code>{{.}}</code></td></tr>{{end}}37+<tr><th>download</th><td><a href="/files/raw?hash={{str (index $file "sha256")}}">raw</a></td></tr>38+</table>39+</section>40+{{if index $file "binary"}}<section><p>This is a binary file. Download it to view its contents.</p></section>41+{{else}}{{sourceHTML .Event}}42+{{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}}43+{{end}}{{end}}44+45+{{define "sites"}}<section>46+<h1>Sites</h1>47+<p>Static sites published to this relay as signed manifests. Each site is served on its own host.</p>3448 {{if .Error}}{{template "alert" .Error}}35-{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}36-<p><a href="/files/raw?hash={{str (index $file "sha256")}}">Raw/download</a></p>37-<scroll-box><table id="file-meta">49+{{else if .Feed}}<table id="rows">50+<thead><tr><th>author</th><th>site</th><th>updated</th></tr></thead>3851 <tbody>39-<tr><th scope="row">SHA-256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>40-<tr><th scope="row">Type</th><td>{{str (index $file "type")}}</td></tr>41-<tr><th scope="row">Size</th><td>{{str (index $file "size")}} bytes</td></tr>42-{{with index $file "blossom_uri"}}<tr><th scope="row">Blossom URI</th><td><code>{{.}}</code></td></tr>52+{{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>4353 {{end}}</tbody>44-</table></scroll-box>45-{{if index $file "binary"}}<p>This is a binary file. Download it to view its contents.</p>46-{{else}}{{sourceHTML .Event}}47-{{end}}<details>48-<summary>Manage file</summary>49-<rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form>50-</details>51-{{end}}</main>54+</table>55+{{else}}<p>No sites are published.</p>56+{{end}}</section>5257 {{end}}
M internal/webui/browse_test.go +1 -1
A internal/webui/icon-mono.svg +1 -0
A internal/webui/icon.svg +1 -0
M internal/webui/manage.html +34 -45
@@ -1,6 +1,5 @@
1-{{define "people"}}<main>2-<h2>People</h2>3-{{template "manageNav" .}}1+{{define "people"}}<section>2+<h1>People</h1>43 <h3>Members</h3>54 <rpc-form method="listmembers"><button>List members</button></rpc-form>65 <rpc-form method="setmember" compose="member">
@@ -27,12 +26,11 @@
2726 <h3>Lists</h3>2827 <rpc-form method="listallowedpubkeys"><button>Allowed pubkeys</button></rpc-form>2928 <rpc-form method="listbannedpubkeys"><button>Banned pubkeys</button></rpc-form>30-</main>29+</section>3130 {{end}}3231 33-{{define "moderation"}}<main>34-<h2>Moderation</h2>35-{{template "manageNav" .}}32+{{define "moderation"}}<section>33+<h1>Moderation</h1>3634 <h3>Reports</h3>3735 <rpc-form method="listreports"><button>List reports</button></rpc-form>3836 <rpc-form method="resolvereport">
@@ -52,12 +50,11 @@
5250 <rpc-form method="unpinevent"><label>Event ID or address <input name="param" required></label><button>Unpin</button></rpc-form>5351 <h3>Pubkeys</h3>5452 <rpc-form method="banpubkey"><label>Pubkey <input name="param" required></label><button>Ban</button></rpc-form>55-</main>53+</section>5654 {{end}}5755 58-{{define "rules"}}<main>59-<h2>Rules</h2>60-{{template "manageNav" .}}56+{{define "rules"}}<section>57+<h1>Rules</h1>6158 <h3>Policy</h3>6259 <rpc-form method="getpolicy"><button>Load policy</button></rpc-form>6360 <rpc-form method="setpolicy" compose="memberInvites">
@@ -75,12 +72,11 @@
7572 <label>Keep days <input name="param" type="number" min="0" required></label>7673 <button>Save retention</button>7774 </rpc-form>78-</main>75+</section>7976 {{end}}8077 81-{{define "identity"}}<main>82-<h2>Identity</h2>83-{{template "manageNav" .}}78+{{define "identity"}}<section>79+<h1>Identity</h1>8480 <h3>Profile</h3>8581 <rpc-form method="changerelayname"><label>Name <input name="param" value="{{.Policy.Name}}"></label><button>Save name</button></rpc-form>8682 <rpc-form method="changerelaydescription"><label>Description <textarea name="param">{{.Policy.Description}}</textarea></label><button>Save description</button></rpc-form>
@@ -98,14 +94,13 @@
9894 </rpc-form>9995 <h3>Ownership</h3>10096 <rpc-form method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button></rpc-form>101-</main>97+</section>10298 {{end}}10399 104-{{define "connect"}}<main>105-<h2>Connect</h2>100+{{define "connect"}}<section>101+<h1>Connect</h1>106102 {{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p>107-{{end}}{{template "manageNav" .}}108-<h3>Connections</h3>103+{{end}}<h3>Connections</h3>109104 <p><button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button></p>110105 <scroll-box><table id="connections-preview" aria-live="polite"><tbody><tr><td>No preview loaded.</td></tr></tbody></table></scroll-box>111106 <rpc-form method="listconnections"><button>List connections</button></rpc-form>
@@ -123,12 +118,11 @@
123118 </publish-list>124119 <h3>Sign in</h3>125120 {{template "signerControls" .}}126-</main>121+</section>127122 {{end}}128123 129-{{define "data"}}<main>130-<h2>Data</h2>131-{{template "manageNav" .}}124+{{define "data"}}<section>125+<h1>Data</h1>132126 <h3>Files</h3>133127 <signed-form action="/upload" method="PUT"><label>Upload file <input type="file" name="file" required></label><button>Upload</button></signed-form>134128 <rpc-form method="listblobs"><button>List blobs</button></rpc-form>
@@ -155,12 +149,11 @@
155149 <rpc-form method="deletebackup"><label>Backup name <input name="param" required></label><button>Delete backup</button></rpc-form>156150 <h3>Import</h3>157151 <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>158-</main>152+</section>159153 {{end}}160154 161-{{define "sync"}}<main>162-<h2>Sync and delivery</h2>163-{{template "manageNav" .}}155+{{define "sync"}}<section>156+<h1>Sync and delivery</h1>164157 <h3>Jobs</h3>165158 <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>166159 <scroll-box><table id="job-status"><tbody><tr><td>Job status is idle.</td></tr></tbody></table></scroll-box>
@@ -177,38 +170,34 @@
177170 <rpc-form method="pullstatus"><button>Pull status</button></rpc-form>178171 <rpc-form method="deliverystatus"><button>Delivery status</button></rpc-form>179172 <rpc-form method="backfill"><label>JSON backfill plan <input name="param" value="{}"></label><button>Backfill</button></rpc-form>180-</main>173+</section>181174 {{end}}182175 183-{{define "views"}}<main>184-<h2>Views</h2>185-{{template "manageNav" .}}176+{{define "views"}}<section>177+<h1>Views</h1>186178 <rpc-form method="listviews"><button>List views</button></rpc-form>187179 <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form>188-</main>180+</section>189181 {{end}}190182 191-{{define "health"}}<main>192-<h2>Health</h2>193-{{template "manageNav" .}}183+{{define "health"}}<section>184+<h1>Health</h1>194185 <rpc-form method="stats"><button>Relay stats</button></rpc-form>195186 <rpc-form method="listaudit"><label>Before sequence <input name="param" type="number" value="0"></label><button>Audit log</button></rpc-form>196187 <rpc-form method="supportedmethods"><button>Supported methods</button></rpc-form>197-</main>188+</section>198189 {{end}}199190 200-{{define "status"}}<main>201-<h2>Service status</h2>202-{{template "manageNav" .}}191+{{define "status"}}<section>192+<h1>Service status</h1>203193 {{if .Error}}{{template "alert" .Error}}204194 {{else}}{{template "jsonView" .Event}}205195 <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>206-{{end}}</main>196+{{end}}</section>207197 {{end}}208198 209-{{define "owner"}}<main>210-<h2>Owner operations</h2>211-{{template "manageNav" .}}199+{{define "owner"}}<section>200+<h1>Owner operations</h1>212201 <p>Destructive actions ask the signer. The browser never receives or submits a private key.</p>213202 <h3>Configuration</h3>214203 <rpc-form method="exportconfig"><button>Export configuration</button></rpc-form>
@@ -231,5 +220,5 @@
231220 <label>JSON parameters <input name="param" value="[]"></label>232221 <button>Run method</button>233222 </rpc-form>234-</main>223+</section>235224 {{end}}
A internal/webui/manifest.webmanifest +14 -0
M internal/webui/page.html +67 -26
@@ -3,26 +3,22 @@
33 <head>44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width,initial-scale=1">6+<meta name="color-scheme" content="light dark">7+<meta name="theme-color" content="#f4f2ec" media="(prefers-color-scheme: light)">8+<meta name="theme-color" content="#1e1d1a" media="(prefers-color-scheme: dark)">69 <title>{{.Title}}</title>10+<link rel="manifest" href="/manifest.webmanifest">11+<link rel="icon" href="/icon.svg" type="image/svg+xml">712 <style>{{stylesheet}}</style>813 <script src="/fixi.js"></script>914 <script src="/signer.js"></script>1015 <script src="/webmcp.js"></script>1116 </head>1217 <body>13-<table id="layout" aria-label="tiny relay"><tr><td>14-<header>15-{{if .Private}}<h1>Private relay</h1>16-<p>Sign in with a Nostr signer to continue.</p>17-{{else}}<h1><a href="/">{{.Slug}}</a></h1>18-<p>{{.Policy.Description}}</p>19-<p><code>{{.URL}}</code> | identity {{template "nostrKey" .Identity}}</p>20-<nav aria-label="Site">21-<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> |22-{{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}}23-</nav>24-<p id="session-status" role="status"></p>25-{{end}}</header>18+<header id="topbar"><button id="menu" type="button">menu</button> <span><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></header>19+<table id="layout" aria-label="{{.Slug}}"><tbody><tr>20+<td id="rail"><div id="railbox">{{template "rail" .}}</div></td>21+<td id="main"><div id="content">2622 {{if .Private}}{{template "signin" .}}2723 {{else if eq .Tab "home"}}{{template "home" .}}2824 {{else if eq .Tab "inbox"}}{{template "inbox" .}}
@@ -32,7 +28,7 @@
3228 {{else if eq .Tab "private"}}{{template "private" .}}3329 {{else if eq .Tab "chat"}}{{template "chat" .}}3430 {{else if eq .Tab "media"}}{{template "media" .}}35-{{else if eq .Tab "site"}}{{template "site" .}}31+{{else if eq .Tab "sites"}}{{template "sites" .}}3632 {{else if eq .Tab "marmot"}}{{template "marmot" .}}3733 {{else if eq .Tab "grasp"}}{{template "grasp" .}}3834 {{else if eq .Tab "terms"}}{{template "terms" .}}
@@ -57,27 +53,77 @@
5753 {{else if eq .Tab "view"}}{{template "view" .}}5854 {{else if eq .Tab "invite"}}{{template "invite" .}}5955 {{else}}{{template "home" .}}60-{{end}}61-<footer><a href="/.well-known/nostr.json">NIP-05</a> | <a href="/terms">Terms</a></footer>62-</td></tr></table>56+{{end}}</div></td>57+<td id="aside"><div id="panel">{{template "panel" .}}</div></td>58+</tr></tbody></table>59+<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{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>6360 <script>{{script "bridge.js"}}</script>6461 <script>{{script "components.js"}}</script>6562 </body>6663 </html>6764 {{end}}6865 69-{{/* Shared partials. Each renders one reusable element so pages never repeat markup for the same kind of data. */}}66+{{/* The rail is the sitemap for the current context: the relay, one repository, or management. */}}67+{{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>68+<nav><a href="/signin" aria-current="page">/signin</a></nav>69+<p>Private relay. Sign in with a Nostr signer to continue.</p>70+{{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>71+<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>72+<p>&larr; <a href="/repos">all repositories</a></p>73+{{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>74+<nav>{{range $item := manageItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>75+<p>&larr; <a href="/">relay</a></p>76+{{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>77+<nav>{{range $item := relayItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>78+{{end}}{{end}}79+80+{{/* The panel holds facts and filters for the current page. */}}81+{{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}82+{{else if eq .Tab "repo"}}{{template "panel-repo" .}}83+{{else if eq $kind "manage"}}{{template "panel-manage" .}}84+{{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}85+{{else if eq .Tab "repos"}}{{template "panel-repos" .}}86+{{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}87+{{else if eq .Tab "signin"}}{{template "panel-signin" .}}88+{{else}}{{template "panel-relay" .}}89+{{end}}{{end}}90+91+{{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>92+<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>93+<section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}}94+95+{{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>96+<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}}97+98+{{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>99+<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}}100+101+{{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>102+{{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}}103+104+{{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>105+{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}}106+107+{{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>108+<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>109+{{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}}110+111+{{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>112+<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>113+<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}}114+115+{{/* Shared partials. */}}70116 71117 {{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}}72118 73-{{define "nostrKey"}}<nostr-key hex="{{.}}">{{shortID .}}</nostr-key>{{end}}119+{{define "nostrKey"}}<nostr-key hex="{{.}}" title="{{.}}">{{shortID .}}</nostr-key>{{end}}74120 75121 {{define "jsonView"}}<json-view><pre>{{json .}}</pre></json-view>{{end}}76122 77123 {{define "nostrEvent"}}{{$e := asMap .}}<nostr-event id="event-{{str (index $e "id")}}">78-<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>124+<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>79125 <pre>{{str (index $e "content")}}</pre>80-<details><summary>Event JSON</summary>{{template "jsonView" .}}</details>126+<details><summary>event JSON</summary>{{template "jsonView" .}}</details>81127 </nostr-event>{{end}}82128 83129 {{define "eventList"}}{{if .Feed}}<ol id="events">
@@ -85,8 +131,3 @@
85131 {{end}}</ol>86132 {{else}}<p>No events.</p>87133 {{end}}{{end}}88-89-{{define "manageNav"}}<nav id="manage-nav" aria-label="Manage">90-<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>91-</nav>92-{{end}}
M internal/webui/private.go +1 -1
M internal/webui/public.html +73 -75
@@ -1,109 +1,113 @@
1-{{define "home"}}<main>2-<h2>About this relay</h2>3-<p>Browse notes, repositories and files. Connect your Nostr signer to publish and manage your relay.</p>4-<dl>5-<dt>Reads</dt><dd>{{.Policy.Reads}}</dd>6-<dt>Writes</dt><dd>{{.Policy.Writes}}</dd>7-<dt>Search</dt><dd>{{.Policy.Features.Search}}</dd>8-<dt>Files</dt><dd>{{.Policy.Features.Files}}</dd>9-</dl>10-<p>Relay URL: <code>{{.URL}}</code></p>11-</main>12-{{end}}13-14-{{define "signin"}}<main>15-<h2>{{if .Actor}}Your account{{else}}Sign in{{end}}</h2>1+{{define "home"}}<section id="identity">2+<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>3+<table id="facts">4+{{if .Policy.Contact}}<tr><th>contact</th><td>{{.Policy.Contact}}</td></tr>{{end}}5+{{if .Policy.Tags}}<tr><th>tags</th><td id="tags">{{range .Policy.Tags}}<a href="/search?q={{urlquery .}}">#{{.}}</a>{{end}}</td></tr>{{end}}6+{{if .Policy.LanguageTags}}<tr><th>languages</th><td>{{join .Policy.LanguageTags ", "}}</td></tr>{{end}}7+{{if .Policy.RelayCountries}}<tr><th>countries</th><td>{{join .Policy.RelayCountries ", "}}</td></tr>{{end}}8+<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>9+<tr><th>reads</th><td>{{.Policy.Reads}}</td></tr>10+<tr><th>writes</th><td>{{.Policy.Writes}}</td></tr>11+</table>12+{{if .Policy.Description}}<h2>About</h2>13+{{markdown .Policy.Description}}14+{{end}}<h2>Recent activity</h2>15+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}16+</section>17+{{end}}18+19+{{define "signin"}}<section>20+<h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1>1621 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>1722 <p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a></p>18-{{else}}<p>Use a browser extension or connect Amber on your phone.</p>23+{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>1924 {{end}}{{template "signerControls" .}}20-</main>25+</section>2126 {{end}}2227 2328 {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>24-<h3>Phone signer</h3>29+<h2>Phone signer</h2>2530 <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>2631 <p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p>27-<p><img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240"></p>32+<img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240">2833 <p id="signer-status" role="status"></p>29-<details>30-<summary>Use a bunker URL</summary>34+<h2>Bunker URL</h2>3135 <form id="bunker">3236 <label>Bunker URL <input id="bunker-url" type="url" placeholder="bunker://…" required></label>3337 <button>Connect remote signer</button>3438 </form>35-</details>3639 {{end}}3740 38-{{define "inbox"}}<main>39-<h2>Inbox</h2>41+{{define "inbox"}}<section>42+<h1>Inbox</h1>4043 <p>Events addressed to this signed-in key, subject to the same private-event gates as websocket queries.</p>4144 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}42-</main>45+</section>4346 {{end}}4447 45-{{define "outbox"}}<main>46-<h2>Outbox</h2>48+{{define "outbox"}}<section>49+<h1>Outbox</h1>4750 <p>Events authored by this signed-in key.</p>4851 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}49-</main>52+</section>5053 {{end}}5154 52-{{define "search"}}<main>53-<h2>Search</h2>55+{{define "search"}}<section>56+<h1>Search</h1>5457 <form id="search" action="/search" method="get">5558 <label>Terms <input name="q" type="search" value="{{.Query.Get "q"}}" required></label>59+<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"}}">5660 <button>Search</button>5761 </form>5862 {{if .Error}}{{template "alert" .Error}}59-{{else if .Feed}}<h3>Results</h3>63+{{else if .Feed}}<h2>Results</h2>6064 {{template "eventList" .}}6165 {{else if .Query.Get "q"}}<p>No matching events.</p>62-{{end}}</main>66+{{end}}</section>6367 {{end}}6468 65-{{define "articles"}}<main>66-<h2>Articles</h2>69+{{define "articles"}}<section>70+<h1>Articles</h1>6771 <p>Long-form NIP-23 events published on this relay.</p>6872 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}69-<p><a href="/feed.xml">Atom/RSS feed</a> | <a href="/articles.json">JSON feed</a></p>70-</main>73+<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p>74+</section>7175 {{end}}7276 73-{{define "event"}}<main>74-<h2>Event</h2>75-<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p>77+{{define "event"}}<section>78+<h1>Event</h1>7679 {{if .Feed}}{{template "eventList" .}}{{else}}{{template "nostrEvent" .Event}}{{end}}77-</main>80+<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p>81+</section>7882 {{end}}7983 80-{{define "view"}}<main>81-<h2>View: {{.View}}</h2>84+{{define "view"}}<section>85+<h1>View: {{.View}}</h1>8286 {{template "jsonView" .Event}}83-</main>87+</section>8488 {{end}}8589 86-{{define "invite"}}<main>87-<h2>Join this relay</h2>90+{{define "invite"}}<section>91+<h1>Join this relay</h1>8892 <p>This invite is claimed with one signature from your Nostr signer. The relay never receives a private key.</p>89-{{if .Policy.JoinTerms}}<h3>Before you join</h3>93+{{if .Policy.JoinTerms}}<h2>Before you join</h2>9094 <pre>{{.Policy.JoinTerms}}</pre>9195 {{end}}<rpc-form method="claiminvite" action="/api/invites/claim" terms="{{.Policy.JoinTerms}}">9296 <input type="hidden" name="param" value="{{.Invite}}">9397 {{if .Policy.JoinTerms}}<label><input type="checkbox" name="termsAgreement" required> I agree to these relay terms.</label>9498 {{end}}<button>Join relay</button>9599 </rpc-form>96-</main>100+</section>97101 {{end}}98102 99-{{define "terms"}}<main>100-<h2>Terms</h2>101-<p>{{.Policy.JoinTerms}}</p>102-</main>103+{{define "terms"}}<section>104+<h1>Terms</h1>105+{{if .Policy.JoinTerms}}{{markdown .Policy.JoinTerms}}{{else}}<p>This relay has not published terms.</p>{{end}}106+</section>103107 {{end}}104108 105-{{define "tools"}}<main>106-<h2>Browser tools</h2>109+{{define "tools"}}<section>110+<h1>Browser tools</h1>107111 <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>108112 <p id="webmcp-status" role="status">Checking browser support...</p>109113 <ul id="webmcp-tools"></ul>
@@ -129,41 +133,35 @@
129133 update();130134 window.tinyWebMCP?.ready.then(update);131135 })();</script>132-</main>136+</section>133137 {{end}}134138 135-{{define "private"}}<main>136-<h2>Private messages</h2>139+{{define "private"}}<section>140+<h1>Private messages</h1>137141 <p>Sign in with a Nostr client to view addressed private events.</p>138-</main>142+</section>139143 {{end}}140144 141-{{define "chat"}}<main>142-<h2>Chat</h2>145+{{define "chat"}}<section>146+<h1>Chat</h1>143147 <p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p>144-</main>148+</section>145149 {{end}}146150 147-{{define "media"}}<main>148-<h2>Media</h2>151+{{define "media"}}<section>152+<h1>Media</h1>149153 <p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p>150-</main>151-{{end}}152-153-{{define "site"}}<main>154-<h2>Sites</h2>155-<p>This relay serves signed site manifests and snapshots when enabled by policy.</p>156-</main>154+</section>157155 {{end}}158156 159-{{define "marmot"}}<main>160-<h2>Marmot</h2>157+{{define "marmot"}}<section>158+<h1>Marmot</h1>161159 <p>Marmot group state is served when the feature is enabled.</p>162-</main>160+</section>163161 {{end}}164162 165-{{define "grasp"}}<main>166-<h2>GRASP</h2>163+{{define "grasp"}}<section>164+<h1>GRASP</h1>167165 <p>Repository state and relay capabilities are served through the GRASP endpoints.</p>168-</main>166+</section>169167 {{end}}
M internal/webui/public_pages.go +65 -0
@@ -7,8 +7,12 @@ import (
77 "html/template"88 "net/http"99 "net/url"10+ "strconv"1011 "strings"1112 "time"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/sites"1216 )1317 1418 func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Values) ([]any, error) {
@@ -29,9 +33,14 @@ func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Valu
2933 filter = map[string]any{"authors": []string{actor}, "limit": 50}3034 case "articles":3135 filter = map[string]any{"kinds": []int{30023}, "limit": 50}36+ case "home":37+ filter = map[string]any{"kinds": []int{1, 30023}, "limit": 12}38+ case "sites":39+ filter = map[string]any{"kinds": []int{sites.KindSite, sites.KindNamedSite}, "limit": 100}3240 default:3341 return nil, nil3442 }43+ applyFeedFilters(filter, values)3544 raw, err := json.Marshal(filter)3645 if err != nil {3746 return nil, err
@@ -51,6 +60,62 @@ func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Valu
5160 return feed, nil5261 }5362 63+// applyFeedFilters narrows a feed by the panel filters: kinds, author, since.64+// "all" removes the kind restriction; unknown values are ignored.65+func applyFeedFilters(filter map[string]any, values url.Values) {66+ switch kinds := strings.TrimSpace(values.Get("kinds")); {67+ case kinds == "all":68+ delete(filter, "kinds")69+ case kinds != "":70+ var list []int71+ for _, part := range strings.Split(kinds, ",") {72+ if kind, err := strconv.Atoi(strings.TrimSpace(part)); err == nil {73+ list = append(list, kind)74+ }75+ }76+ if len(list) > 0 {77+ filter["kinds"] = list78+ }79+ }80+ if author := strings.ToLower(strings.TrimSpace(values.Get("author"))); len(author) == 64 {81+ filter["authors"] = []string{author}82+ }83+ if since := strings.TrimSpace(values.Get("since")); since != "" {84+ if parsed, err := time.Parse("2006-01-02", since); err == nil {85+ filter["since"] = parsed.Unix()86+ }87+ }88+}89+90+// siteRows turns site manifest events into rows with the host each site is91+// served from, using the same labels the daemon uses for site hosts.92+func (a *App) siteRows(feed []any) []any {93+ base, err := url.Parse(a.backend.URL())94+ if err != nil {95+ return nil96+ }97+ rows := make([]any, 0, len(feed))98+ for _, item := range feed {99+ encoded, _ := json.Marshal(item)100+ var e event.Event101+ if json.Unmarshal(encoded, &e) != nil {102+ continue103+ }104+ label := sites.SiteLabel(e)105+ if label == "" {106+ continue107+ }108+ scheme := base.Scheme109+ if scheme == "" || scheme == "ws" {110+ scheme = "http"111+ } else if scheme == "wss" {112+ scheme = "https"113+ }114+ 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()})115+ }116+ return rows117+}118+54119 type publicAuthError struct{}55120 56121 func (*publicAuthError) Error() string { return "authentication required" }
M internal/webui/repo.html +27 -45
@@ -1,57 +1,39 @@
1-{{define "repo"}}<main>2-<p><a href="/repos">All repositories</a></p>3-{{if .Error}}<h2>{{.Query.Get "repo"}}</h2>4-{{template "alert" .Error}}5-{{else}}{{$repo := asMap .Event}}{{$view := .Query.Get "view"}}6-<section id="repo-head">7-<h2>{{str (index $repo "identifier")}}</h2>8-<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>9-<nav id="repo-tabs" aria-label="Repository views">10-<a href="{{repoURL .Query "tree" ""}}"{{if or (eq $view "tree") (eq $view "file")}} aria-current="page"{{end}}>Code</a>11-<a href="{{repoURL .Query "history" ""}}"{{if or (eq $view "history") (eq $view "commit")}} aria-current="page"{{end}}>History</a>12-<a href="{{repoURL .Query "activity" ""}}"{{if eq $view "activity"}} aria-current="page"{{end}}>Activity</a>13-</nav>14-<form id="repo-ref" method="get" action="/repo">15-<input type="hidden" name="owner" value="{{.Query.Get "owner"}}">16-<input type="hidden" name="repo" value="{{.Query.Get "repo"}}">17-<input type="hidden" name="path" value="{{.Query.Get "path"}}">18-<input type="hidden" name="view" value="{{if eq $view "commit"}}tree{{else}}{{$view}}{{end}}">19-<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>20-<button>Go</button>21-</form>22-<details id="repo-clone">23-<summary>Clone</summary>24-{{range index $repo "clone"}}<p><input aria-label="Clone URL" readonly value="{{.}}" onclick="this.select()"></p>25-{{else}}<p>No clone URL advertised.</p>26-{{end}}</details>1+{{define "repo"}}{{if .Error}}<section><h1>{{.Query.Get "repo"}}</h1>{{template "alert" .Error}}</section>2+{{else}}{{$repo := asMap .Event}}{{$view := repoView .Query}}{{if eq $view "home"}}<section id="readme">3+{{if .Readme}}{{.Readme}}{{else}}<h1>{{str (index $repo "identifier")}}</h1><p>This repository has no README at its default branch.</p>{{end}}274 </section>28-{{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>29-{{end}}{{if eq $view "file"}}<p id="file-heading"><strong>{{.Query.Get "path"}}</strong> <a href="/repo/raw?owner={{urlquery (.Query.Get "owner")}}&amp;repo={{urlquery (.Query.Get "repo")}}&amp;ref={{urlquery (.Query.Get "ref")}}&amp;path={{urlquery (.Query.Get "path")}}">Raw/download</a></p>5+{{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>6+{{template "repoTree" .}}</td>7+<td id="preview">{{if eq $view "file"}}<header>{{.Query.Get "path"}} <a href="/repo/raw?owner={{urlquery (.Query.Get "owner")}}&amp;repo={{urlquery (.Query.Get "repo")}}&amp;ref={{urlquery (.Query.Get "ref")}}&amp;path={{urlquery (.Query.Get "path")}}">raw</a></header>308 {{if index $repo "binary"}}<p>This is a binary file. Download it to view its contents.</p>319 {{else if index $repo "content"}}{{sourceHTML .Event}}3210 {{else}}<p>This file is empty.</p>33-{{end}}{{else if eq $view "tree"}}<scroll-box><table id="repo-tree">34-<thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Object</th></tr></thead>11+{{end}}{{else}}<p>select a file to preview</p>{{end}}</td></tr></tbody></table>12+{{else if eq $view "history"}}<section>13+<table id="rows">3514 <tbody>36-{{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>37-{{else}}<tr><td colspan="3">No published files in this directory.</td></tr>38-{{end}}</tbody>39-</table></scroll-box>40-{{else if eq $view "history"}}<scroll-box><table id="repo-history">41-<thead><tr><th scope="col">Commit</th><th scope="col">Message</th><th scope="col">Author and date</th></tr></thead>42-<tbody>43-{{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>15+{{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>4416 {{else}}<tr><td colspan="3">No commits.</td></tr>4517 {{end}}</tbody>46-</table></scroll-box>47-{{else if eq $view "commit"}}<h3>Commit <code>{{shortID (index $repo "commit")}}</code></h3>48-<p><a href="{{repoURL .Query "tree" ""}}">Browse files at this commit</a></p>18+</table>19+<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>20+</section>21+{{else if eq $view "commit"}}<section>22+<h1>Commit <code>{{shortID (index $repo "commit")}}</code></h1>23+<p><a href="{{repoURL .Query "tree" ""}}">browse files at this commit</a> | <a href="{{repoURL .Query "history" ""}}">history</a></p>24+</section>4925 {{diffHTML .Event}}50-{{else if eq $view "activity"}}{{with index $repo "events"}}<ol id="events">26+{{else if eq $view "activity"}}<section>27+{{with index $repo "events"}}<ol id="events">5128 {{range .}}<li>{{template "nostrEvent" .}}</li>5229 {{end}}</ol>5330 {{else}}<p>No repository activity.</p>54-{{end}}{{end}}55-{{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>56-{{end}}{{end}}</main>31+{{end}}</section>32+{{end}}{{end}}{{end}}33+34+{{define "repoTree"}}{{$repo := asMap .Event}}{{$entries := index $repo "entries"}}{{if .Tree}}{{$entries = .Tree}}{{end}}<ul>35+{{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>36+{{else}}<li><span> </span>no published files here</li>37+{{end}}</ul>38+<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>5739 {{end}}
A internal/webui/shell_test.go +137 -0
@@ -0,0 +1,137 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "net/url"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+type siteBackend struct {16+ fakeBackend17+ filter map[string]any18+}19+20+func (b *siteBackend) Query(_ context.Context, method string, params []json.RawMessage, _ string) (any, error) {21+ if method != "queryevents" {22+ return nil, nil23+ }24+ _ = json.Unmarshal(params[0], &b.filter)25+ 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)}}}}, nil26+}27+28+func TestShellRendersRailPanelAndPrompt(t *testing.T) {29+ backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}30+ app, err := New(backend, Options{})31+ if err != nil {32+ t.Fatal(err)33+ }34+ for path, wants := range map[string][]string{35+ "/": {`id="mark"`, `href="/search"`, `<b>demo</b> &raquo; home`, `id="panel"`, `id="topbar"`, `rel="manifest"`},36+ "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</span>`},37+ "/repo?owner=aa&repo=notes&view=history": {`/history</a>`, `repos/notes/history`},38+ } {39+ recorder := httptest.NewRecorder()40+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))41+ body := recorder.Body.String()42+ for _, want := range wants {43+ if !strings.Contains(body, want) {44+ t.Errorf("%s missing %q", path, want)45+ }46+ }47+ if strings.Contains(body, "class=") {48+ t.Errorf("%s carries a class attribute", path)49+ }50+ }51+}52+53+func TestRepositoryHomeRendersReadmeAsMarkdown(t *testing.T) {54+ b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}55+ app, err := New(b, Options{})56+ if err != nil {57+ t.Fatal(err)58+ }59+ recorder := httptest.NewRecorder()60+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=alice&repo=notes&view=home", nil))61+ body := recorder.Body.String()62+ if recorder.Code != http.StatusOK || !strings.Contains(body, `id="readme"`) || !strings.Contains(body, "<p>one two</p>") {63+ t.Fatalf("repository home did not render the README: %d %s", recorder.Code, body)64+ }65+}66+67+func TestSitesPageListsHostedSites(t *testing.T) {68+ backend := &siteBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}69+ app, err := New(backend, Options{})70+ if err != nil {71+ t.Fatal(err)72+ }73+ recorder := httptest.NewRecorder()74+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/sites", nil))75+ body := recorder.Body.String()76+ if recorder.Code != http.StatusOK || !strings.Contains(body, `href="http://npub1`) || !strings.Contains(body, ".relay.example") || !strings.Contains(body, "1 paths") {77+ t.Fatalf("sites page did not list the site: %d %s", recorder.Code, body)78+ }79+ if kinds, _ := backend.filter["kinds"].([]any); len(kinds) != 2 {80+ t.Fatalf("sites query kinds = %v", backend.filter["kinds"])81+ }82+}83+84+func TestFeedFiltersNarrowTheQuery(t *testing.T) {85+ filter := map[string]any{"kinds": []int{1, 30023}, "limit": 12}86+ applyFeedFilters(filter, url.Values{"kinds": {"7"}, "author": {strings.Repeat("b", 64)}, "since": {"2026-09-01"}})87+ if kinds, _ := filter["kinds"].([]int); len(kinds) != 1 || kinds[0] != 7 {88+ t.Fatalf("kinds = %v", filter["kinds"])89+ }90+ if authors, _ := filter["authors"].([]string); len(authors) != 1 {91+ t.Fatalf("authors = %v", filter["authors"])92+ }93+ if since, _ := filter["since"].(int64); since != 1788220800 {94+ t.Fatalf("since = %v", filter["since"])95+ }96+ applyFeedFilters(filter, url.Values{"kinds": {"all"}, "author": {"short"}})97+ if _, ok := filter["kinds"]; ok {98+ t.Fatal("kinds were not cleared by all")99+ }100+}101+102+func TestManifestCarriesTenantPrefixAndRelayName(t *testing.T) {103+ p := policy.Defaults(strings.Repeat("a", 64))104+ p.Name = "my relay"105+ backend := &fakeBackend{policy: p}106+ app, err := New(backend, Options{})107+ if err != nil {108+ t.Fatal(err)109+ }110+ recorder := httptest.NewRecorder()111+ request := httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil)112+ request.RequestURI = "/r/alice/manifest.webmanifest"113+ app.ServeHTTP(recorder, request)114+ body := recorder.Body.String()115+ 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"`) {116+ t.Fatalf("manifest: %d %s", recorder.Code, body)117+ }118+ for _, path := range []string{"/sw.js", "/icon.svg", "/icon-mono.svg"} {119+ recorder := httptest.NewRecorder()120+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))121+ if recorder.Code != http.StatusOK {122+ t.Errorf("%s: %d", path, recorder.Code)123+ }124+ }125+}126+127+func TestPromptPathSpellsOutRepositoriesAndFiles(t *testing.T) {128+ if got := promptPath("/", nil); got != "home" {129+ t.Fatalf("home prompt = %q", got)130+ }131+ if got := promptPath("/repo", url.Values{"repo": {"notes"}, "view": {"file"}}); got != "repos/notes/file" {132+ t.Fatalf("repo prompt = %q", got)133+ }134+ if got := promptPath("/manage/people", nil); got != "manage/people" {135+ t.Fatalf("manage prompt = %q", got)136+ }137+}
M internal/webui/style.css +170 -96
@@ -1,105 +1,179 @@
1-html,body{margin:0;padding:0}2-body{font-family:system-ui,sans-serif;line-height:1.4;color:#202124}3-#layout{border-collapse:collapse;table-layout:fixed;width:960px;margin:0 auto}4-#layout>tbody>tr>td{padding:12px;vertical-align:top;overflow-wrap:anywhere}5-#layout>tbody>tr>td>header{border-bottom:1px solid #bbb}6-#layout>tbody>tr>td>footer{border-top:1px solid #bbb;margin-top:1rem;padding-top:.5rem}7-h1{font-size:1.6rem}8-h2{font-size:1.3rem}9-h3{font-size:1.05rem;margin:1.5rem 0 .5rem;border-bottom:1px solid #e2e2e2}10-input,textarea,button,select{max-width:100%;box-sizing:border-box;font:inherit}11-button{padding:.3rem .65rem}12-textarea{width:100%;min-height:5em;vertical-align:top}1+/* Palette: paper and ink with forest accents. The dark variant follows the2+ system preference or the footer toggle, which sets data-theme on html. */3+: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}4+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}5+@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}}6+7+html,body{margin:0;padding:0;height:100%;overflow:hidden;overscroll-behavior:none}8+body{font:14px/1.45 var(--sans);color:var(--ink);background:var(--paper)}9+a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}10+code,kbd,samp,pre{font-family:var(--mono);font-size:13px}11+h1{font-size:24px;margin:0 0 12px}h2{font-size:17px;margin:22px 0 8px}h3{font-size:14px;margin:18px 0 6px}12+p{margin:0 0 10px}13+small{color:var(--muted)}14+[role=alert]{color:var(--alert)}15+[role=status]{color:var(--muted)}16+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%}17+button{cursor:pointer;background:var(--rail)}button:hover{background:var(--hover)}button:disabled{cursor:default;color:var(--muted)}18+textarea{width:100%;min-height:5em;font-family:var(--mono);font-size:13px}1319 pre{white-space:pre-wrap;overflow-wrap:anywhere}14-code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}15-[role=alert]{color:#a40000}16-[role=status]{color:#444}17-small{color:#666}18-nav a{white-space:nowrap}19-#manage-nav{margin:.5rem 0 1rem}20-21-/* Data tables inside the page body. Wide tables sit in a scroll-box and22- scroll sideways instead of squeezing columns; cells never break mid-word. */20+img{max-width:100%}21+22+/* Selection: chrome is not selectable, content and values are. */23+#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}24+25+/* Shell: a table with three columns; each column scrolls on its own. */26+#topbar{display:none}27+#layout{border-collapse:collapse;table-layout:fixed;width:100%;height:calc(100vh - 28px)}28+#layout>tbody>tr>td{vertical-align:top;padding:0;position:relative;height:100%}29+#railbox,#content,#panel{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;overscroll-behavior:contain}30+#rail{width:240px;background:var(--rail);border-right:1px solid var(--line)}31+#aside{width:280px;border-left:1px solid var(--line)}32+#content>section{padding:14px 22px 30px}33+#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}34+#footer table{width:100%;border-collapse:collapse;height:27px}#footer td{border:0;padding:0;vertical-align:middle}35+#footer td:last-child{text-align:right}36+#prompt{color:var(--muted)}#prompt b{color:var(--ink);font-weight:500}37+#who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)}38+#who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline}39+#theme{border:0;background:none;padding:0 0 0 8px;color:var(--muted);font:inherit}40+41+/* Rail */42+#rail header{height:60px;box-sizing:border-box;border-bottom:1px solid var(--line);font:13px var(--mono);display:table;width:100%}43+#rail header>span{display:table-cell;vertical-align:middle;line-height:1.3;padding:0 2ch 0 2.5ch;overflow-wrap:break-word}44+#rail header a{color:var(--muted)}#rail header b{font-weight:500}45+#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}46+#rail header a#mark{color:var(--ink)}#mark:hover{text-decoration-color:var(--ink)}47+#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)}48+#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)}49+#rail nav a:hover{background:var(--hover);text-decoration:none}50+#rail nav a[aria-current=page]{background:var(--soft)}51+#rail p{padding:10px 2ch 10px 2.5ch;margin:0;font:12px var(--mono);color:var(--muted)}52+53+/* Panel */54+#panel section{padding:10px 14px;border-bottom:1px solid var(--line);font:12px var(--mono)}55+#panel h4{margin:0 0 6px;font:12px var(--mono);color:var(--muted)}56+#panel table{width:100%;border-collapse:collapse;margin:0}57+#panel th{text-align:left;font-weight:normal;color:var(--muted);padding:2px 1ch 2px 0;border:0;white-space:nowrap;vertical-align:top}58+#panel td{text-align:right;padding:2px 0;border:0;overflow-wrap:anywhere}59+#panel ul{list-style:none;margin:0;padding:0}#panel li{line-height:24px;overflow-wrap:anywhere}60+#panel li small{float:right}61+#panel label{display:block;color:var(--muted);margin:8px 0 2px;font-size:11px;text-transform:uppercase;letter-spacing:.04em}62+#panel input,#panel select{width:100%;font:12px var(--mono)}63+#panel form button{margin-top:8px}64+#panel json-view{margin-top:4px}65+66+/* Rows: the list rhythm used by repositories, files, history, and activity. */67+#rows{width:100%;border-collapse:collapse}68+#rows td{height:36px;border-bottom:1px solid var(--line);padding:0 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}69+#rows td:first-child{width:120px;font:13px var(--mono);color:var(--accent)}70+#rows td:last-child{width:170px;text-align:right;font:12px var(--mono);color:var(--muted)}71+#rows tr:hover td{background:var(--hover)}72+#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)}73+#rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}74+#pages{font:12px var(--mono)}75+76+/* Data tables inside content, including json-view results. */2377 scroll-box{display:block;overflow-x:auto;max-width:100%}24-main table{width:100%;border-collapse:collapse;margin:.5rem 0}25-main th,main td{padding:.35rem .5rem;border-bottom:1px solid #e2e2e2;text-align:left;vertical-align:top;overflow-wrap:normal}26-main th,main td code,main td nostr-key,main td time,main td a{white-space:nowrap}27-main th[scope=row]{width:9em}28-dl{margin:.5rem 0}29-dt{font-weight:600}30-dd{margin:0 0 .35rem 1rem}31-32-/* rpc-form: one signed management call. Labels wrap like inline text so short forms stay on one line. */33-rpc-form,signed-form,publish-list{display:block;margin:.6rem 0}34-rpc-form label,signed-form label,publish-list label{display:inline-block;margin:0 .6rem .35rem 0;vertical-align:top}78+scroll-box table,json-view table{width:100%;border-collapse:collapse;margin:.5rem 0}79+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}80+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}81+dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}82+#facts{border-collapse:collapse;font:13px var(--mono);width:auto}83+#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}84+85+/* Identity: the relay entry page. */86+#identity{max-width:720px}#identity>header{border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:12px}87+#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}88+#identity>header h1{display:inline-block;vertical-align:middle;margin:0}89+#identity>header small{display:block;margin-top:4px;font-family:var(--mono)}90+#identity>header small code{font-size:12px}91+#tags a{display:inline-block;font:12px var(--mono);margin-right:8px}92+93+/* README and other rendered markdown. */94+#readme{max-width:720px}#readme h1{font-size:24px;border-bottom:1px solid var(--line);padding-bottom:8px}#readme h2{font-size:17px}95+#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}96+#readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px}97+98+/* rpc-form, signed-form, publish-list: one signed call each. */99+rpc-form,signed-form,publish-list{display:block;border-bottom:1px solid var(--line);padding:10px 0}100+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)}101+rpc-form label input,rpc-form label select,signed-form label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}35102 rpc-form label:has(textarea),publish-list label:has(textarea){display:block}36-rpc-form output,signed-form output,publish-list output{display:block;margin:.3rem 0;color:#444}37-rpc-form output[data-error]{color:#a40000}38-39-/* json-view: structured rendering of any JSON payload, with the raw text kept behind a details toggle. */40-json-view{display:block}41-json-view>details>summary{cursor:pointer;color:#666}42-json-view>details>pre{margin:.3rem 0;padding:.5rem;border:1px solid #e2e2e2;background:#fafafa;font-size:13px}43-json-view table{font-size:14px}44-json-view td pre{margin:0;white-space:pre;max-width:40em;overflow:auto}45-json-view dl dl{margin:.2rem 0 .2rem 1rem}46-json-view ul{margin:.2rem 0;padding-left:1.2rem}47-json-view code{overflow-wrap:anywhere}48-49-/* nostr-event: a single event in feeds, activity, and detail pages. */103+rpc-form output,signed-form output,publish-list output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px}104+rpc-form output[data-error]{color:var(--alert)}105+#content>section>h3{border-bottom:0;margin-top:22px;font-size:15px}106+107+/* json-view: structured rendering of any JSON payload. */108+json-view{display:block;font:12px var(--mono)}109+json-view>details>summary{cursor:pointer;color:var(--muted)}110+json-view>details>pre{margin:.3rem 0;padding:.5rem;border:1px solid var(--line);background:var(--rail);font-size:12px}111+json-view table{font-size:12px}json-view td pre{margin:0;white-space:pre;max-width:40em;overflow:auto}112+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}113+114+/* nostr-event: one event in feeds, activity, and detail pages. */50115 #events{list-style:none;margin:0;padding:0}51-nostr-event{display:block;padding:.6rem 0;border-bottom:1px solid #e2e2e2}52-nostr-event>p{margin:0 0 .3rem;color:#555}53-nostr-event>pre{margin:0}54-nostr-event>details{margin-top:.3rem}55-nostr-event>details>summary{cursor:pointer;color:#666}56-nostr-key{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;cursor:copy}57-58-/* Repository browsing. */59-#repo-head{border:1px solid #d0d0d0;padding:12px;background:#fafafa}60-#repo-head h2{margin:0 0 6px}61-#repo-head p{margin:0 0 10px}62-#repo-tabs{display:block;border-bottom:1px solid #bbb;margin:1rem 0}63-#repo-tabs a{display:inline-block;padding:.45rem .7rem;text-decoration:none}64-#repo-tabs a[aria-current=page]{border:1px solid #bbb;border-bottom-color:white;margin-bottom:-1px;background:white;font-weight:600}65-#repo-ref{display:inline-block;margin:0 .5rem 0 0;vertical-align:middle}66-#repo-clone{display:inline-block;vertical-align:middle}67-#repo-clone input{width:40em;font:13px ui-monospace,monospace}68-#repo-breadcrumbs{margin:16px 0;font:14px ui-monospace,monospace}69-#file-heading{padding:8px 12px;margin:0;border:1px solid #ccc;border-bottom:0;background:#f5f5f5}70-#file-heading a{float:right}71-#repo-tree small{display:inline-block;width:5.5em;color:#777;font:12px ui-monospace,monospace}72-#repo-tree td:last-child,#repo-tree th:last-child{font-family:ui-monospace,monospace}73-#repo-pages a{margin-right:16px}116+nostr-event{display:block;border-bottom:1px solid var(--line);padding:10px 0}117+nostr-event>header{font:12px var(--mono);color:var(--muted);margin-bottom:4px}118+nostr-event>header small{float:right}119+nostr-event>pre{margin:0;white-space:pre-wrap;font:14px/1.45 var(--sans)}120+nostr-event>details{margin-top:6px;font:12px var(--mono)}nostr-event>details>summary{cursor:pointer;color:var(--muted)}121+nostr-key{font-family:var(--mono);cursor:copy}122+123+/* Repository: tree with a preview pane. */124+#split{width:100%;border-collapse:collapse;table-layout:fixed;margin:0}125+#split>tbody>tr>td{vertical-align:top;padding:0;border:0}126+#tree{width:300px;overflow:auto;border-right:1px solid var(--line);font:13px/24px var(--mono);padding:8px 0 20px}127+#tree header{padding:0 12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#tree header small{float:right}128+#tree ul{list-style:none;margin:0;padding:0 0 0 12px}129+#tree li{white-space:nowrap}#tree li>span{color:var(--muted)}130+#tree a{color:var(--ink)}#tree a[aria-current=page]{background:var(--soft);padding:0 4px}131+#preview{padding:0;overflow:hidden}132+#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}133+#preview header a{float:right}134+#preview p{color:var(--muted);padding:24px 12px;font:13px var(--mono)}135+#preview>section{padding:12px}74136 75137 /* source-view and diff-view wrap the tables emitted by the source renderer. */76-source-view,diff-view{display:block;overflow:auto;border:1px solid #ccc;background:#fafafa}77-source-view table,diff-view table{border-collapse:collapse;width:100%;margin:0;font:13px/20px ui-monospace,SFMono-Regular,Consolas,monospace}78-source-view td,diff-view td{padding:0 .75rem;border:0;vertical-align:top;white-space:pre;overflow-wrap:normal;word-break:normal}79-source-view td:first-child,diff-view td:first-child{color:#888;text-align:right;user-select:none;border-right:1px solid #ddd}80-source-view td:first-child a,diff-view td:first-child a{text-decoration:none;color:#777}138+source-view,diff-view{display:block;overflow:auto}139+diff-view{border:1px solid var(--line);margin:0 0 14px}140+source-view table,diff-view table{border-collapse:collapse;width:100%;margin:0;font:13px/20px var(--mono)}141+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}142+source-view td:first-child,diff-view td:first-child{color:var(--muted);text-align:right;user-select:none;width:1%;padding-right:8px}143+source-view td:first-child a,diff-view td:first-child a{text-decoration:none;color:var(--muted)}81144 source-view td:last-child,diff-view td:last-child{width:100%}82-source-view tr:target,diff-view tr:target{background:#fff1bc}145+source-view tr:target,diff-view tr:target{background:var(--soft)}83146 source-view code,diff-view code{font:inherit}84-source-view b{font-weight:600}85-source-view i{font-style:normal;color:#777}86-source-view q{quotes:none;color:#146b2d}87-source-view q::before,source-view q::after{content:none}88-source-view var{font-style:normal;color:#7a3e00}89-source-view tfoot td,diff-view tfoot td{white-space:normal;color:#777;font-family:system-ui,sans-serif}90-diff-view ins,diff-view del{text-decoration:none}91-diff-view tr:has(ins){background:#e9f7e9}92-diff-view tr:has(del){background:#fbeaea}93-diff-view b{font-weight:600;color:#555}94-95-@media(max-width:979px){96-#layout{width:360px;max-width:100%;margin:0}97-#layout>tbody>tr>td{padding:8px}98-nav{line-height:2}99-#repo-tabs{overflow-x:auto}100-#repo-tabs a{white-space:nowrap}101-#repo-tree th:nth-child(3),#repo-tree td:nth-child(3){display:none}102-#repo-clone input{width:100%}103-source-view table,diff-view table{font-size:12px}104-source-view td,diff-view td{padding:0 .5rem}147+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)}148+source-view tfoot td,diff-view tfoot td{white-space:normal;color:var(--muted);font-family:var(--sans)}149+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}150+diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-bottom:1px solid var(--line);caption-side:top}151+152+/* Activity grid. */153+#grid{border-collapse:separate;border-spacing:2px;margin:0 0 8px;width:auto}154+#grid td{width:10px;height:10px;padding:0;border:0;background:var(--g0)}155+#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)}156+#grid th{font:11px var(--mono);color:var(--muted);font-weight:normal;text-align:left;padding:0;border:0}157+#gridhead{font:12px var(--mono);color:var(--muted);margin:0 0 6px}158+159+/* Sign in. */160+#signer-qr{display:block;margin:8px 0;background:#fff;padding:8px;border:1px solid var(--line)}161+162+/* Phones: a top bar with a menu, the rail slides in, the panel stacks under the content. */163+@media(max-width:720px){164+html,body{overflow:auto}165+#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}166+#topbar b{color:var(--ink);font-weight:500}#menu{border:1px solid var(--line);padding:2px 10px;margin-right:10px;line-height:1.4}167+#layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}168+#layout{padding:40px 0 28px}169+#railbox,#content,#panel{position:static;overflow:visible}170+#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}171+html[data-menu=open] #layout>tbody>tr>td#rail{transform:none;box-shadow:8px 0 24px rgba(0,0,0,.15)}172+#layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)}173+#content>section{padding:14px 2.5ch 20px}174+#prompt{display:none}#footer td:last-child{text-align:left}175+#split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}176+#tree{border-right:0;border-bottom:1px solid var(--line)}177+#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}178+#grid,#gridhead,h2:has(+#gridhead){display:none}105179 }
A internal/webui/sw.js +9 -0
M internal/webui/templates.go +81 -12
@@ -32,6 +32,72 @@ var bridgeJS string
3232 //go:embed components.js3333 var componentsJS string3434 35+// Installable app assets. The manifest is a template: NAME and BASE are36+// replaced per request so tenant prefixes and relay names stay correct.37+//38+//go:embed manifest.webmanifest39+var manifestTemplate string40+41+//go:embed sw.js42+var serviceWorkerJS []byte43+44+//go:embed icon.svg45+var iconSVG []byte46+47+//go:embed icon-mono.svg48+var iconMonoSVG []byte49+50+// navItem is one rail entry. Tab matches PageData.Tab for the active state.51+type navItem struct{ Label, Href, Tab string }52+53+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"}}54+55+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"}}56+57+// railKind picks the sitemap for a tab: one repository, management, or the relay.58+func railKind(tab string) string {59+ if tab == "repo" {60+ return "repo"61+ }62+ for _, item := range manageNav {63+ if item.Tab == tab {64+ return "manage"65+ }66+ }67+ return "relay"68+}69+70+// promptPath renders the footer prompt: the path after the relay name, with71+// repository pages spelled out as repos/<name>/<view>.72+func promptPath(path string, query url.Values) string {73+ path = strings.Trim(path, "/")74+ switch {75+ case path == "":76+ return "home"77+ case path == "repo" && query.Get("repo") != "":78+ return "repos/" + query.Get("repo") + "/" + repoView(query)79+ case path == "file" && (query.Get("sha") != "" || query.Get("hash") != ""):80+ return "files/" + shortID(query.Get("sha")+query.Get("hash"))81+ }82+ return path83+}84+85+// short renders a Unix timestamp as a compact UTC stamp for list rows.86+func short(value any) string {87+ seconds := unixSeconds(value)88+ if seconds <= 0 {89+ return ""90+ }91+ return time.Unix(seconds, 0).UTC().Format("Jan 2 15:04")92+}93+94+func repoView(query url.Values) string {95+ if view := query.Get("view"); view != "" {96+ return view97+ }98+ return "tree"99+}100+35101 func parseTemplates() (*template.Template, error) {36102 scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS}37103 funcs := template.FuncMap{
@@ -50,18 +116,21 @@ func parseTemplates() (*template.Template, error) {
50116 }51117 return string(encoded)52118 },53- "join": strings.Join,54- "urlquery": url.QueryEscape,55- "asMap": valueMap,56- "str": plainString,57- "datetime": datetime,58- "when": when,59- "selectedView": func(query url.Values, view string) string {60- if query.Get("view") == view || (query.Get("view") == "" && view == "tree") {61- return " selected"62- }63- return ""64- },119+ "join": strings.Join,120+ "urlquery": url.QueryEscape,121+ "asMap": valueMap,122+ "str": plainString,123+ "datetime": datetime,124+ "when": when,125+ "markdown": renderMarkdown,126+ "npub": identityNpub,127+ "short": short,128+ "prompt": promptPath,129+ "railKind": railKind,130+ "repoView": repoView,131+ "relayItems": func() []navItem { return relayNav },132+ "manageItems": func() []navItem { return manageNav },133+ "add": func(a, b int) int { return a + b },65134 "repoCommitURL": repoCommitURL,66135 "hasNextOffset": hasNextOffset,67136 "repoURL": repoURL,
M internal/webui/webui.go +84 -3
@@ -76,6 +76,9 @@ type PageData struct {
7676 Base string7777 Query url.Values7878 Private bool79+ Path string80+ Readme template.HTML81+ Tree []any7982 }8083 8184 func New(backend Backend, options Options) (*App, error) {
@@ -191,6 +194,25 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
191194 _, _ = writer.Write(fixiJS)192195 return193196 }197+ if request.URL.Path == "/sw.js" {198+ writer.Header().Set("content-type", "application/javascript; charset=utf-8")199+ writer.Header().Set("service-worker-allowed", "/")200+ _, _ = writer.Write(serviceWorkerJS)201+ return202+ }203+ if request.URL.Path == "/icon.svg" || request.URL.Path == "/icon-mono.svg" {204+ writer.Header().Set("content-type", "image/svg+xml; charset=utf-8")205+ if request.URL.Path == "/icon.svg" {206+ _, _ = writer.Write(iconSVG)207+ } else {208+ _, _ = writer.Write(iconMonoSVG)209+ }210+ return211+ }212+ if request.URL.Path == "/manifest.webmanifest" {213+ a.manifest(writer, request)214+ return215+ }194216 if request.URL.Path == "/qr.svg" {195217 a.qrImage(writer, request.URL.Query().Get("text"))196218 return
@@ -481,7 +503,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
481503 switch method {482504 case "browserepo":483505 view := request.URL.Query().Get("view")484- if view == "" {506+ if view == "" || view == "home" {485507 view = "tree"486508 }487509 offset := 0
@@ -499,6 +521,10 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
499521 }500522 raw, _ := json.Marshal(query)501523 params = append(params, raw)524+ var tree []any525+ if method == "browserepo" && request.URL.Query().Get("view") == "file" {526+ tree = a.siblings(request.Context(), actor, request.URL.Query())527+ }502528 result, err := a.backend.Query(request.Context(), method, params, actor)503529 if err != nil {504530 a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})
@@ -509,9 +535,48 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
509535 pageQuery.Set("view", "tree")510536 }511537 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}538+ if method == "browserepo" && pageQuery.Get("view") == "home" {539+ data.Readme = a.readme(request.Context(), actor, pageQuery)540+ }541+ data.Tree = tree512542 a.render(writer, request, data)513543 }514544 545+// readme renders README.md from the requested ref for the repository entry546+// page. A missing or binary README simply yields no content.547+func (a *App) readme(ctx context.Context, actor string, query url.Values) template.HTML {548+ for _, name := range []string{"README.md", "readme.md", "README"} {549+ raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": name, "view": "file", "limit": 1})550+ result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor)551+ if err != nil {552+ continue553+ }554+ page := valueMap(result)555+ content, _ := page["content"].(string)556+ if binary, _ := page["binary"].(bool); binary || content == "" {557+ continue558+ }559+ return renderMarkdown(content)560+ }561+ return ""562+}563+564+// siblings lists the directory that holds the file being previewed so the565+// tree pane stays populated beside the file.566+func (a *App) siblings(ctx context.Context, actor string, query url.Values) []any {567+ dir := ""568+ if slash := strings.LastIndex(query.Get("path"), "/"); slash >= 0 {569+ dir = query.Get("path")[:slash]570+ }571+ raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": dir, "view": "tree", "limit": 100})572+ result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor)573+ if err != nil {574+ return nil575+ }576+ entries, _ := valueMap(result)["entries"].([]any)577+ return entries578+}579+515580 func browseTab(path string) string {516581 switch path {517582 case "/repos":
@@ -564,6 +629,7 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag
564629 if data.Query == nil {565630 data.Query = request.URL.Query()566631 }632+ data.Path = strings.TrimPrefix(request.URL.Path, data.Base)567633 a.privatePageData(&data, request, actor)568634 writer.Header().Set("Cache-Control", "private, no-store")569635 writer.Header().Set("content-type", "text/html; charset=utf-8")
@@ -633,6 +699,19 @@ func bech32Polymod(values []byte) uint64 {
633699 return checksum634700 }635701 702+// manifest describes the installable app for this relay and tenant prefix.703+func (a *App) manifest(writer http.ResponseWriter, request *http.Request) {704+ name := a.backend.Policy().Name705+ if name == "" || privatePolicyEnabled(a.backend.Policy()) {706+ name = a.backend.Slug()707+ }708+ encoded, _ := json.Marshal(name)709+ body := strings.ReplaceAll(manifestTemplate, `"NAME"`, string(encoded))710+ body = strings.ReplaceAll(body, "BASE/", requestPrefix(request)+"/")711+ writer.Header().Set("content-type", "application/manifest+json; charset=utf-8")712+ _, _ = writer.Write([]byte(body))713+}714+636715 func (a *App) qrImage(writer http.ResponseWriter, text string) {637716 if a.qr == nil || text == "" {638717 http.Error(writer, "QR generation is not configured", http.StatusNotImplemented)
@@ -654,10 +733,12 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
654733 actor = ""655734 }656735 data := PageData{Tab: tab, Query: request.URL.Query()}657- if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" {736+ if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" || tab == "home" || tab == "sites" {658737 feed, feedErr := a.publicFeed(request.Context(), tab, actor, request.URL.Query())659738 if feedErr != nil {660739 data.Error = feedErr.Error()740+ } else if tab == "sites" {741+ data.Feed = a.siteRows(feed)661742 } else {662743 data.Feed = feed663744 }
@@ -790,7 +871,7 @@ func tabForPath(path string) string {
790871 return "files"791872 case "file":792873 return "file"793- case "tools", "signin":874+ case "tools", "signin", "sites":794875 return path795876 case "people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":796877 return path