tinyrelaycommit

Commit b55685d47897

browse files at this commit | history

1diff --git a/AGENTS.md b/AGENTS.md
2index 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.
10diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js
11index 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
40diff --git a/internal/webui/browse.html b/internal/webui/browse.html
41index 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}}
138diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
139index 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, "&lt;script&gt;") || !strings.Contains(body, "line two") || !strings.Contains(body, "Raw/download") {
147+ if recorder.Code != http.StatusOK || !strings.Contains(body, "&lt;script&gt;") || !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 }
151diff --git a/internal/webui/icon-mono.svg b/internal/webui/icon-mono.svg
152new file mode 100644
153index 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>
158diff --git a/internal/webui/icon.svg b/internal/webui/icon.svg
159new file mode 100644
160index 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>
165diff --git a/internal/webui/manage.html b/internal/webui/manage.html
166index 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}}
335diff --git a/internal/webui/manifest.webmanifest b/internal/webui/manifest.webmanifest
336new file mode 100644
337index 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+}
355diff --git a/internal/webui/page.html b/internal/webui/page.html
356index 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> &raquo; {{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> &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>
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>&larr; <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>&larr; <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}}
498diff --git a/internal/webui/private.go b/internal/webui/private.go
499index 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
511diff --git a/internal/webui/public.html b/internal/webui/public.html
512index 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}}
740diff --git a/internal/webui/public_pages.go b/internal/webui/public_pages.go
741index 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" }
835diff --git a/internal/webui/repo.html b/internal/webui/repo.html
836index 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")}}&amp;repo={{urlquery (.Query.Get "repo")}}&amp;ref={{urlquery (.Query.Get "ref")}}&amp;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")}}&amp;repo={{urlquery (.Query.Get "repo")}}&amp;ref={{urlquery (.Query.Get "ref")}}&amp;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}}
924diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
925new file mode 100644
926index 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> &raquo; 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+}
1067diff --git a/internal/webui/style.css b/internal/webui/style.css
1068index 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 }
1347diff --git a/internal/webui/sw.js b/internal/webui/sw.js
1348new file mode 100644
1349index 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+});
1362diff --git a/internal/webui/templates.go b/internal/webui/templates.go
1363index 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,
1473diff --git a/internal/webui/webui.go b/internal/webui/webui.go
1474index 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

About

ownerc01503e8035f
visibilitypublic
headb55685d47897

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs