Basic documentation page

This commit is contained in:
Jonathon Leight committed 2026-07-01 19:14:53 -04:00
1 parent 6db0436263
commit 05d3f7e8ab
18 files changed
+188 -26

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
}
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
+5 -1
View File
@@ -174,7 +174,11 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) {
if !lr.IsAdmin {
// Guests can't run CLI commands (including get lat/lon), so there's
// nothing more to do — stop here rather than fruitlessly querying.
_ = bridge.Status("warning", fmt.Sprintf("Repeater reached, but MeshTender only has GUEST access (permissions=%d). Guest is open to anyone with a blank password, so MeshTender can't administer this repeater — re-run `%s` to grant admin.", lr.Permissions, s.Identity.SetPermCommand()))
msg := fmt.Sprintf("Repeater reached, but MeshTender only has GUEST access (permissions=%d). Guest is open to anyone with a blank password, so MeshTender can't administer this repeater — re-run `%s` to grant admin.", lr.Permissions, s.Identity.SetPermCommand())
if s.Cfg.RootHost != "" {
msg += " See " + s.Origin(r, s.Cfg.RootHost) + "/docs#setperm for help."
}
_ = bridge.Status("warning", msg)
return
}
_ = bridge.Status("confirmed", "Repeater reached with admin access. ✓")
+28
View File
@@ -0,0 +1,28 @@
package core
import (
"strings"
"testing"
)
// TestDocsPage renders the public help page on the root host and confirms it
// shows the live setperm command (with the instance's real key filled in) and
// the key sidebar — the whole reason the page exists.
func TestDocsPage(t *testing.T) {
_, _, ts, h := splitServer(t)
body := readBody(t, do(t, ts, h.root, "/docs"))
for _, want := range []string{
"How MeshTender works",
"Granting MeshTender admin",
"setperm ", // the live command, e.g. "setperm <hex> 3"
"MeshTender server public key",
} {
if !strings.Contains(body, want) {
t.Fatalf("docs page missing %q", want)
}
}
// The literal placeholder must NOT appear — the key should be filled in.
if strings.Contains(body, "setperm &lt;MeshTender") || strings.Contains(body, "<key>") {
t.Fatalf("docs page shows a placeholder instead of the real setperm command")
}
}
+10 -10
View File
@@ -32,7 +32,7 @@ func (s *Handlers) pageMyOrgs(w http.ResponseWriter, r *http.Request) {
uid := s.Auth.CurrentUserID(r.Context())
mine, err := s.Store.ListOrgsForUser(r.Context(), uid)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
http.Error(w, "could not load organizations", http.StatusInternalServerError)
return
}
s.Render(w, r, "my_orgs.html", map[string]any{
@@ -81,7 +81,7 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
}
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
if !isMember || r.URL.Query().Get("view") == "public" {
@@ -149,22 +149,22 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
memberCount, repeaterCount, err := s.Store.OrgCounts(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
pubReps, err := s.Store.ListPublicRepeaters(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
links, err := s.Store.ListOrgLinks(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
uid := s.Auth.CurrentUserID(r.Context())
@@ -200,7 +200,7 @@ func (s *Handlers) pageOrgMembers(w http.ResponseWriter, r *http.Request) {
}
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
if !isMember {
@@ -239,7 +239,7 @@ func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
}
role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
rv, err := web.BuildRepeatersView(r.Context(), s.Store, id, isMember)
@@ -398,7 +398,7 @@ func (s *Handlers) pageJoinOrg(w http.ResponseWriter, r *http.Request) {
}
_, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
if isMember {
@@ -461,7 +461,7 @@ func (s *Handlers) handleSetOrgMember(w http.ResponseWriter, r *http.Request) {
return
}
if errors.Is(err, store.ErrLastAdmin) {
memberErr("That would leave the org with no admin.")
memberErr("That would leave the organization with no admin.")
return
}
if err != nil {
+1 -1
View File
@@ -158,7 +158,7 @@ func (s *Handlers) pageRepeaterAdded(w http.ResponseWriter, r *http.Request) {
}
orgs, err := s.Store.ListOrgsForUser(r.Context(), uid)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
http.Error(w, "could not load organizations", http.StatusInternalServerError)
return
}
s.Render(w, r, "repeater_added.html", map[string]any{
+1 -1
View File
@@ -34,7 +34,7 @@ func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
// repeater participates (the default) or has been opted out.
orgs, err := s.Store.ListRepeaterOrgMemberships(r.Context(), id)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
http.Error(w, "could not load organizations", http.StatusInternalServerError)
return
}
s.Render(w, r, "share.html", map[string]any{
+4 -2
View File
@@ -56,6 +56,7 @@
<p class="text-secondary">
MeshTender controls repeaters through a single MeshTender identity. Before it can do anything with
your repeater, that identity must be granted admin access (permission level 3) on the device.
<a href="{{.RootURL}}/docs#setperm">How does this work? →</a>
</p>
<div class="alert alert-danger" role="alert">
@@ -105,6 +106,7 @@
<code class="form-control font-monospace">{{.SetPermCommand}}</code>
<button type="button" class="btn" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent)">{{template "icon-copy" ""}}Copy</button>
</div>
<small class="form-hint">Run it through the repeater console in the MeshCore companion app, or directly over USB serial.</small>
</div>
{{template "revokedoc" .RevokeCommand}}
<label class="form-check mt-3">
@@ -175,7 +177,7 @@
<select class="form-select" id="org">
<option value="0">None — use a radio preset</option>
</select>
<small class="form-hint">An org configuration supplies the radio settings, base settings, and location-based region.</small>
<small class="form-hint">An organization configuration supplies the radio settings, base settings, and location-based region.</small>
</div>
<div class="mb-3" id="profile-wrap" hidden>
@@ -204,7 +206,7 @@
{{/* Location */}}
<div class="mt-3">
<label class="form-label">Location</label>
<p class="form-hint mt-0">Click the map to place this repeater. Used for its coordinates and (with an org
<p class="form-hint mt-0">Click the map to place this repeater. Used for its coordinates and (with an organization
configuration) to pick the right region.</p>
<div id="map" style="height:320px" class="rounded border"></div>
<div class="form-hint" id="loc-status">No location selected.</div>
+1 -1
View File
@@ -14,7 +14,7 @@
The firmware commands MeshTender can send. A repeater owner can run anything. <strong>Share</strong> seeds
the default command set for a new one-off share. <strong>Member</strong>/<strong>admin</strong> are the
ceiling of what an organization may ever run on a contributed repeater, by tier. <strong>Risky</strong>
commands can lock the owner out or brick a node — leave these out of the org tiers. Hover a command to
commands can lock the owner out or brick a node — leave these out of the organization tiers. Hover a command to
see what it does.
</p>
{{if .Saved}}<div class="alert alert-success mt-3 mb-0">Saved</div>{{end}}
+1 -1
View File
@@ -10,7 +10,7 @@
<div class="card">
<div class="card-body">
<p class="text-secondary">
This removes the repeater from MeshTender — its shares, org contributions, and command history here
This removes the repeater from MeshTender — its shares, organization contributions, and command history here
all go away. This can't be undone.
</p>
+1 -1
View File
@@ -15,7 +15,7 @@
<p class="text-secondary">
Joining <strong>{{.Org.Name}}</strong> lets its admins and members run the commands the organization
is permitted to run on the repeaters you share with it. You can change this anytime — opt individual
repeaters in or out, or limit which commands the org may run — from each repeater's sharing page.
repeaters in or out, or limit which commands the organization may run — from each repeater's sharing page.
</p>
{{if .HasRepeaters}}
<p class="mb-3">How would you like to start?</p>
+1 -1
View File
@@ -104,7 +104,7 @@
<input type="text" class="form-control font-monospace" id="org_slug" name="slug" value="{{.Org.Slug}}"
pattern="[a-z0-9]+(-[a-z0-9]+)*" minlength="3" maxlength="40" required>
</div>
<small class="form-hint">Lowercase letters, numbers, and hyphens. Changing this changes the org's URL.</small>
<small class="form-hint">Lowercase letters, numbers, and hyphens. Changing this changes the organization's URL.</small>
</div>
<div class="mb-3">
<label class="form-label" for="org_region">Region <span class="text-secondary">(optional)</span></label>
+1 -1
View File
@@ -47,7 +47,7 @@
<p class="text-secondary">
This repeater is now shared with the organizations you belong to, so their admins and members can run
the commands those organizations are permitted to run on it. Opt out of any of them here — you can
change this anytime, and limit which commands an org may run, from the sharing page.
change this anytime, and limit which commands an organization may run, from the sharing page.
</p>
<div class="list-group list-group-flush">
{{range .Orgs}}
+15
View File
@@ -0,0 +1,15 @@
package marketing
import "net/http"
// pageDocs renders the public help / how-it-works page. It injects MeshTender's
// server public key so the setperm example shows the exact command, with the
// real key filled in, rather than a placeholder.
func (s *Handlers) pageDocs(w http.ResponseWriter, r *http.Request) {
data := map[string]any{}
if s.Identity != nil {
data["ServerKey"] = s.Identity.PublicKeyHex()
data["SetPermCommand"] = s.Identity.SetPermCommand()
}
s.Render(w, r, "docs.html", data)
}
+1
View File
@@ -48,6 +48,7 @@ func (s *Handlers) Routes() chi.Router {
// docs/auth-cross-host.md.
r.Get("/session/beacon", s.Auth.BeaconCallback)
r.Get("/", s.pageLanding)
r.Get("/docs", s.pageDocs) // public help / how-it-works
r.Get("/orgs", s.pageOrgs) // public organization directory
r.Get("/orgs/{id}", s.pageOrgPublic) // public org page
r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map
+5 -5
View File
@@ -43,7 +43,7 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) {
all, hasMore, err := s.Store.ListPublicOrgsPage(r.Context(), p)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
http.Error(w, "could not load organizations", http.StatusInternalServerError)
return
}
data := map[string]any{
@@ -67,22 +67,22 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) {
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
memberCount, repeaterCount, err := s.Store.OrgCounts(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
pubReps, err := s.Store.ListPublicRepeaters(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
links, err := s.Store.ListOrgLinks(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
uid := s.Auth.CurrentUserID(r.Context())
+99
View File
@@ -0,0 +1,99 @@
{{define "title"}}Docs · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Help</div>
<h2 class="page-title">How MeshTender works</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="row row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<p>MeshTender helps you register, configure, and maintain your <a href="https://meshcore.io" target="_blank" rel="noopener">MeshCore</a> repeaters, and share them with your community. It talks to your repeaters <strong>over the mesh</strong> under its own identity (a "server identity"), so once a repeater grants it access, you can manage that repeater remotely from here.</p>
<p class="mb-0">The one thing MeshTender needs from each repeater is <strong>admin access</strong> — otherwise it can only read public info. That's the <a href="#setperm">setperm</a> step below.</p>
</div>
</div>
<div class="card mt-3" id="setperm">
<div class="card-header"><h3 class="card-title">Granting MeshTender admin (<code>setperm</code>)</h3></div>
<div class="card-body">
<p>MeshTender signs in to a repeater as its server identity — one fixed public key. Until a repeater grants that key <strong>admin</strong> (permission level 3), MeshTender only has <strong>guest</strong> access — open to anyone with a blank password — and can't administer the repeater.</p>
<p>Run this on your repeater — through the <strong>repeater console in the MeshCore companion app</strong>, directly over USB serial, or let MeshTender run it for you during USB setup:</p>
{{if .SetPermCommand}}
<div class="input-group mb-2">
<input type="text" class="form-control font-monospace" id="setperm-cmd" value="{{.SetPermCommand}}" readonly>
<button type="button" class="btn" onclick="navigator.clipboard&amp;&amp;navigator.clipboard.writeText(document.getElementById('setperm-cmd').value)">Copy</button>
</div>
<p class="text-secondary small mb-0">The long value is <strong>MeshTender's</strong> public key — not your repeater's. If you see a guest-access warning after confirming a repeater, this is the fix: run the command above, then confirm again.</p>
{{else}}
<p class="text-secondary mb-0">Run <code>setperm &lt;MeshTender's public key&gt; 3</code> on your repeater. Sign in and start adding a repeater to see the exact command with the key filled in.</p>
{{end}}
</div>
</div>
<div class="card mt-3" id="add">
<div class="card-header"><h3 class="card-title">Adding a repeater</h3></div>
<div class="card-body">
<p>There are two ways in, matching how you reach the repeater:</p>
<ul class="mb-2">
<li><strong>Set up a brand-new repeater (USB)</strong> — connect a freshly-flashed repeater over USB serial and MeshTender runs the whole setup for you: identity, radio, region, name, and the admin grant. Best for a repeater that isn't on the mesh yet.</li>
<li><strong>Add one that's already on the network (over the air)</strong> — for a repeater you've already configured, MeshTender reaches it over the air through a <a href="#kiss">KISS modem</a> attached to your browser. You grant MeshTender admin with the <a href="#setperm"><code>setperm</code></a> command yourself first, then <em>confirm</em> it here.</li>
</ul>
<p>Confirming does a live login round-trip to prove MeshTender can reach and administer the repeater. If it reports guest access, the <code>setperm</code> grant hasn't been applied yet.</p>
<p class="mb-0">Either way, the device must already be running MeshCore's <strong>Repeater</strong> firmware — MeshTender configures repeaters, it doesn't flash them. A MeshCore device runs one firmware at a time (Repeater, a Companion variant, or KISS modem), so switching roles means re-flashing.</p>
</div>
</div>
<div class="card mt-3" id="kiss">
<div class="card-header"><h3 class="card-title">What's a KISS modem?</h3></div>
<div class="card-body">
<p>A <strong>KISS modem</strong> turns a MeshCore device into a plain radio for a host to drive: instead of acting as a repeater or companion, it just relays raw packets to and from whatever's connected to it. That's how MeshTender talks to your mesh <strong>over the air</strong> — you attach a KISS-modem device to your browser over USB serial, and MeshTender uses it as its radio to reach repeaters that are already deployed (the <a href="#add">over-the-air</a> flow above).</p>
<p>It's a relatively new addition to MeshCore, and it's a <strong>standard firmware variant</strong> — you flash it right from the standard MeshCore flasher, the same place you'd flash Repeater or a Companion. It's available for a <strong>large number</strong> of MeshCore devices, though <strong>not all</strong> of them — check the flasher for your specific board.</p>
<p class="mb-0">Remember that firmware variants are exclusive: a device running KISS-modem firmware is your radio interface, not a repeater. To turn that same device into a repeater later, you'd re-flash it with Repeater firmware.</p>
</div>
</div>
<div class="card mt-3" id="config">
<div class="card-header"><h3 class="card-title">Organizations</h3></div>
<div class="card-body">
<p>An <strong>organization</strong> is a community that coordinates repeaters together. It helps you generate the commands to run to configure your repeater properly, and share settings with others in your area.</p>
<p class="mb-0">Browse communities on the <a href="/orgs">Organizations</a> page.</p>
</div>
</div>
<div class="card mt-3" id="sharing">
<div class="card-header"><h3 class="card-title">Sharing &amp; stewards</h3></div>
<div class="card-body">
<p class="mb-0">You can share a repeater with other people so they can help operate it, and mark trusted helpers as <strong>stewards</strong> — co-maintainers listed on the repeater's public page. Public repeater and organization pages link to each person's <strong>public profile</strong>, so visitors can find a way to reach the people who keep the mesh running.</p>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">On this page</h3></div>
<div class="list-group list-group-flush">
<a class="list-group-item list-group-item-action" href="#setperm">Granting admin (setperm)</a>
<a class="list-group-item list-group-item-action" href="#add">Adding a repeater</a>
<a class="list-group-item list-group-item-action" href="#kiss">What's a KISS modem?</a>
<a class="list-group-item list-group-item-action" href="#config">Organizations</a>
<a class="list-group-item list-group-item-action" href="#sharing">Sharing &amp; stewards</a>
</div>
</div>
{{if .ServerKey}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">MeshTender's key</h3></div>
<div class="card-body">
<div class="form-label">MeshTender server public key</div>
<code class="pk d-block">{{.ServerKey}}</code>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
+12
View File
@@ -62,6 +62,12 @@
<span class="nav-link-title">Organizations</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{.RootURL}}/docs">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-help" ""}}</span>
<span class="nav-link-title">Docs</span>
</a>
</li>
{{if .CanAdmin}}
<li class="nav-item">
<a class="nav-link" href="{{.AppURL}}/admin">
@@ -134,6 +140,12 @@
<span class="nav-link-title">Organizations</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/docs">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-help" ""}}</span>
<span class="nav-link-title">Docs</span>
</a>
</li>
</ul>
</div>
</div>
+1
View File
@@ -5,6 +5,7 @@
{{define "icon-logo"}}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 837.000000 477.000000" fill="currentColor" aria-hidden="true" focusable="false" style="height:1.6em;width:auto" class="{{.}}"><g transform="translate(0.000000,477.000000) scale(0.100000,-0.100000)" > <path d="M7140 4596 c-36 -20 -200 -205 -217 -244 -24 -58 -9 -91 95 -214 366 -430 561 -839 649 -1363 26 -156 26 -580 0 -745 -81 -515 -321 -1019 -659 -1385 -80 -86 -88 -101 -88 -162 0 -60 13 -82 129 -210 149 -167 161 -163 396 107 631 729 889 1696 695 2610 -119 563 -368 1039 -778 1487 -121 132 -159 153 -222 119z"/> <path d="M1041 4528 c-254 -265 -483 -611 -634 -958 -237 -545 -302 -1138 -191 -1746 101 -557 402 -1139 803 -1555 89 -92 99 -99 134 -99 53 0 64 7 149 107 152 178 154 211 22 354 -185 202 -381 511 -488 771 -245 596 -258 1286 -34 1888 120 323 283 595 531 883 120 140 117 168 -36 330 -91 96 -93 97 -139 97 -45 0 -50 -3 -117 -72z"/> <path d="M4105 4561 c-195 -90 -463 -377 -548 -589 l-33 -81 -42 19 c-129 58 -310 101 -502 120 -126 13 -162 6 -184 -39 -25 -48 -49 -215 -55 -376 l-6 -160 -55 -6 c-209 -23 -474 -160 -538 -279 -32 -61 -26 -170 17 -299 65 -194 159 -336 303 -456 l55 -47 -80 -77 c-181 -173 -300 -419 -301 -623 -1 -93 0 -97 30 -135 89 -111 331 -227 519 -249 l52 -6 6 -157 c9 -217 37 -368 73 -400 27 -22 33 -23 119 -17 174 12 342 50 498 112 49 20 90 34 91 32 2 -1 22 -43 44 -91 101 -217 287 -420 506 -551 76 -46 120 -47 194 -6 222 122 457 376 537 578 16 40 29 72 31 72 1 0 35 -13 76 -29 160 -64 344 -106 515 -118 88 -6 92 -5 116 19 39 39 76 257 77 451 0 95 2 107 18 107 55 0 238 56 327 100 227 111 278 189 240 367 -51 244 -213 492 -393 603 -15 9 -14 13 12 33 163 124 237 210 311 362 88 180 116 333 76 421 -50 110 -302 249 -518 285 l-70 11 -6 152 c-10 263 -41 393 -99 417 -68 28 -399 -25 -577 -92 -103 -38 -121 -40 -121 -14 0 26 -100 211 -149 275 -62 82 -206 221 -291 282 -140 99 -196 115 -275 79z m141 -182 c240 -168 390 -366 446 -589 25 -103 21 -220 -12 -320 -46 -138 -240 -474 -268 -466 -145 45 -287 51 -416 17 -63 -17 -82 -18 -88 -9 -45 68 -218 437 -233 496 -59 243 32 500 254 722 74 74 227 190 250 190 4 0 34 -19 67 -41z m-1182 -515 c116 -18 306 -73 375 -109 l48 -24 7 -94 c12 -174 54 -293 195 -558 45 -83 81 -155 81 -159 0 -4 -16 -20 -36 -35 -31 -24 -37 -25 -52 -12 -481 414 -595 500 -722 542 l-49 16 -7 56 c-7 61 0 204 18 320 12 84 1 79 142 57z m2368 -18 c14 -55 30 -270 23 -326 l-6 -56 -88 -21 c-195 -46 -400 -175 -696 -438 -47 -42 -85 -74 -85 -72 0 3 29 51 66 108 151 240 212 401 221 586 l6 112 67 30 c89 39 223 78 335 95 127 20 147 18 157 -18z m303 -566 c92 -23 223 -86 291 -137 l46 -35 -7 -52 c-33 -244 -193 -478 -397 -582 l-48 -24 -392 2 -391 3 -13 60 c-17 83 -40 138 -91 219 -52 80 -60 63 87 191 399 348 618 432 915 355z m-2895 9 c122 -27 234 -104 535 -364 109 -93 204 -175 213 -182 13 -11 11 -20 -15 -80 -17 -37 -38 -101 -48 -140 l-18 -73 -414 0 -414 0 -58 39 c-169 115 -312 354 -337 562 l-6 56 48 35 c159 116 376 179 514 147z m1433 -414 c332 -70 504 -439 344 -738 -196 -369 -712 -359 -893 16 -180 376 147 807 549 722z m-750 -648 c17 -67 52 -148 90 -212 l24 -40 -171 -162 c-273 -260 -385 -336 -546 -369 -171 -36 -425 27 -587 145 -49 36 -53 57 -28 156 51 199 147 355 295 481 l75 64 416 0 417 0 15 -63z m2142 34 c195 -114 356 -358 390 -592 l7 -47 -62 -42 c-272 -185 -582 -191 -843 -16 -70 46 -405 313 -460 365 -17 16 -16 19 16 62 47 61 83 136 103 211 9 34 18 69 20 76 3 9 89 12 391 12 l388 0 50 -29z m-1896 -420 c11 -7 5 -25 -33 -90 -170 -295 -246 -504 -246 -677 l0 -63 -47 -21 c-124 -55 -345 -114 -463 -122 l-45 -3 -12 70 c-7 39 -14 130 -15 204 l-3 134 70 19 c201 55 348 157 661 460 104 100 109 104 133 89z m1176 -322 c174 -131 307 -198 459 -229 l59 -12 -6 -126 c-3 -70 -11 -165 -18 -210 l-12 -84 -46 7 c-164 22 -393 85 -483 131 -26 13 -28 19 -28 73 0 201 -68 377 -280 726 -21 33 -2 20 115 -81 77 -66 185 -154 240 -195z m-940 211 c50 -13 111 -20 170 -20 59 0 120 7 170 20 43 11 79 20 81 20 5 0 161 -274 197 -345 122 -244 112 -441 -34 -688 -90 -152 -379 -403 -433 -376 -81 42 -234 176 -313 274 -157 197 -217 420 -167 621 19 75 231 514 248 514 2 0 38 -9 81 -20z"/> <path d="M1715 3826 c-80 -35 -270 -302 -391 -549 -294 -605 -268 -1247 75 -1815 121 -202 258 -373 317 -398 123 -52 274 54 274 193 0 38 -19 74 -82 152 -527 652 -525 1389 6 2085 75 98 85 133 62 204 -36 109 -163 171 -261 128z"/> <path d="M6483 3815 c-74 -37 -113 -98 -113 -176 0 -54 3 -62 49 -121 206 -262 331 -515 393 -795 30 -133 32 -425 5 -553 -55 -258 -205 -570 -357 -742 -70 -80 -90 -117 -90 -167 1 -164 192 -269 306 -169 150 132 382 518 468 779 180 546 85 1145 -263 1670 -184 277 -272 338 -398 274z"/> </g></svg>{{end}}
{{define "icon-home"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M5 12l-2 0l9 -9l9 9l-2 0"/><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7"/><path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6"/></svg>{{end}}
{{define "icon-plus"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M12 5l0 14"/><path d="M5 12l14 0"/></svg>{{end}}
{{define "icon-help"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M12 17l0 .01"/><path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"/></svg>{{end}}
{{define "icon-search"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg>{{end}}
{{define "icon-terminal"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M8 9l3 3l-3 3"/><path d="M13 15l3 0"/><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z"/></svg>{{end}}
{{define "icon-pencil"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4"/><path d="M13.5 6.5l4 4"/></svg>{{end}}