Build shared UI shell pieces

This commit is contained in:
Jonathon Leight committed 2026-10-25 20:17:25 -04:00
1 parent 9257757a4e
commit a80d244247
63 files changed
+550 -325

No files matched your search

-2
View File
@@ -103,8 +103,6 @@ func (s *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, uid int
"MinPasswordLen": MinPasswordLen,
"Passkeys": views,
"NextRename": nextRename, // nil when a rename is allowed now
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
"PKMsg": r.URL.Query().Get("pk"),
"PKErr": r.URL.Query().Get("pkerr"),
"EmMsg": r.URL.Query().Get(flashEmail),
+1 -2
View File
@@ -42,7 +42,6 @@ func (s *Handlers) pageDeleteAccount(w http.ResponseWriter, r *http.Request) {
"HasPasskeys": preview.Passkeys > 0,
// Repeater transfer lives on the app host, so links out need its origin.
"AppOrigin": s.Auth.AppOrigin(r),
"Error": r.URL.Query().Get("error"),
})
}
@@ -101,5 +100,5 @@ func (s *Handlers) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
// holding a cookie for an account that no longer exists.
func (s *Handlers) finishDeletion(w http.ResponseWriter, r *http.Request) {
_ = s.Auth.Logout(r.Context())
web.RedirectFlash(w, r, "/login", "ok", "Your account and everything in it have been deleted.")
web.RedirectOK(w, r, "/login", "Your account and everything in it have been deleted.")
}
+3 -4
View File
@@ -15,7 +15,6 @@ import (
func (s *Handlers) pageForgot(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "forgot.html", map[string]any{
"Layout": "authbase",
"Error": r.URL.Query().Get("error"),
// Sent switches the page to the confirmation panel. It's a query flag rather
// than a distinct URL so the POST can redirect (no re-submit on refresh) and
// still say the same thing regardless of what was submitted.
@@ -48,7 +47,7 @@ func (s *Handlers) handleForgot(addrLimit web.KeyLimiter) http.HandlerFunc {
// nothing about whether the account exists: we'd show it just the same for
// an address with no account if the provider were down.
web.LogError(r, "password reset send failed", err)
web.RedirectFlash(w, r, "/forgot", "error",
web.RedirectErr(w, r, "/forgot",
"We couldn't send the email just now. Please try again in a few minutes.")
return
}
@@ -121,7 +120,7 @@ func (s *Handlers) handleReset(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, ErrResetNotAllowed):
// The account shed its password between the link being sent and used, so there
// is nothing to reset and email must not create one.
web.RedirectFlash(w, r, "/login", "error",
web.RedirectErr(w, r, "/login",
"That account no longer has a password — sign in with your passkey instead.")
return
case err != nil:
@@ -133,7 +132,7 @@ func (s *Handlers) handleReset(w http.ResponseWriter, r *http.Request) {
// Deliberately not signed in here: every login was just revoked, and landing on
// the sign-in page proves the new password actually works while the user still has
// it in mind.
web.RedirectFlash(w, r, "/login", "ok",
web.RedirectOK(w, r, "/login",
"Password updated. Sign in with your new password.")
}
-2
View File
@@ -2,7 +2,6 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your account</div>
<h1 class="page-title fs-1">Account settings</h1>
</div>
</div>
@@ -23,7 +22,6 @@
clutter, not information. */}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<div class="card">
<div class="card-header">
@@ -5,7 +5,6 @@
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
-1
View File
@@ -24,7 +24,6 @@
Enter your username or the email address on your account, and we'll send a link
to set a new password.
</p>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<form method="post" action="/forgot">
<div class="mb-3">
-2
View File
@@ -3,11 +3,9 @@
<div class="card card-md">
<div class="card-body">
<h1 class="card-title text-center mb-4">Sign in to your account</h1>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{/* Success flash: a completed password reset and a confirmed email address both
land here, and both need to say so — otherwise the visitor can't tell whether
what they just did worked. */}}
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<form method="post" action="/login/password" id="signin-form">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
-1
View File
@@ -3,7 +3,6 @@
<div class="card card-md">
<div class="card-body">
<h1 class="card-title text-center mb-2">Create your account</h1>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary text-center mb-4">No email required. Passkeys are the easiest, most secure option — your device unlocks the account with a fingerprint, face, PIN, or a security key.</p>
<form method="post" action="/signup/password" id="signup-form">
-3
View File
@@ -175,8 +175,6 @@ func (s *Handlers) pageLogin(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "login.html", map[string]any{
"Layout": "authbase",
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
"Next": r.URL.Query().Get("next"),
// Gates the "Forgot password?" link — there's no point offering recovery on a
// deployment that can't send mail.
@@ -194,7 +192,6 @@ func (s *Handlers) pageSignup(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "signup.html", map[string]any{
"Layout": "authbase",
"Error": r.URL.Query().Get("error"),
"Next": r.URL.Query().Get("next"),
// Stated in the form and used as minlength, so the client hint can't drift
// from the server's floor.
-1
View File
@@ -164,7 +164,6 @@ func (s *Handlers) pageUsers(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
"Users": users,
"Self": s.Auth.CurrentUserID(r.Context()),
"Error": q.Get("error"),
"Sort": string(sortKey),
"Cap": string(capFilter),
"Query": query,
+1 -2
View File
@@ -59,7 +59,6 @@ func (s *Handlers) pageCSPReports(w http.ResponseWriter, r *http.Request) {
// Clearing is destructive, so the button only renders for the capability
// that already governs granting capabilities (see the route table).
"CanClear": u.CapManageUsers,
"Flash": r.URL.Query().Get("flash"),
})
}
@@ -93,5 +92,5 @@ func (s *Handlers) handleClearCSPReports(w http.ResponseWriter, r *http.Request)
if n == 1 {
noun = "violation"
}
web.RedirectFlash(w, r, back, "flash", fmt.Sprintf("%d %s cleared.", n, noun))
web.RedirectOK(w, r, back, fmt.Sprintf("%d %s cleared.", n, noun))
}
-1
View File
@@ -33,7 +33,6 @@ func (s *Handlers) pageCompanions(w http.ResponseWriter, r *http.Request) {
"Companions": companions,
"Max": store.MaxCompanionsPerUser,
"TTLHours": int(store.UnconfirmedCompanionTTL.Hours()),
"Error": r.URL.Query().Get("error"),
})
}
+2 -2
View File
@@ -38,7 +38,7 @@ func (s *Handlers) handleSetRadio(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "could not clear radio settings", err)
return
}
web.RedirectFlash(w, r, back, "ok", "Radio settings cleared.")
web.RedirectOK(w, r, back, "Radio settings cleared.")
return
}
radio, ok := parseRadio(r)
@@ -53,5 +53,5 @@ func (s *Handlers) handleSetRadio(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "could not save radio settings", err)
return
}
web.RedirectFlash(w, r, back, "ok", "Radio settings saved.")
web.RedirectOK(w, r, back, "Radio settings saved.")
}
+1 -1
View File
@@ -168,5 +168,5 @@ func (s *Handlers) handleUpdateNetAir(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "Couldn't save the on-air settings.", err)
return
}
web.RedirectFlash(w, r, netSettingsURL(org.Slug, net.ID), "ok", "On-air settings saved.")
web.RedirectOK(w, r, netSettingsURL(org.Slug, net.ID), "On-air settings saved.")
}
-1
View File
@@ -134,7 +134,6 @@ func (s *Handlers) pageNetSession(w http.ResponseWriter, r *http.Request) {
"Members": members,
"Observers": len(observers),
"ObserversOnline": online,
"Error": r.URL.Query().Get("error"),
}
if c.netControl && inProgress {
air, err := s.Store.GetNetAir(ctx, c.org.ID, c.net.ID)
+3 -6
View File
@@ -102,7 +102,6 @@ func (s *Handlers) pageOrgNets(w http.ResponseWriter, r *http.Request) {
"IsAdmin": isAdmin,
"Nets": views,
"Now": now,
"Error": r.URL.Query().Get("error"),
})
}
@@ -228,8 +227,6 @@ func (s *Handlers) pageOrgNet(w http.ResponseWriter, r *http.Request) {
"Now": now,
"Sessions": sessions,
"SettingsURL": netSettingsURL(org.Slug, net.ID),
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
}
if hasMore && len(sessions) > 0 {
data["NextBefore"] = web.EncodeCursor(sessions[len(sessions)-1].StartsAt)
@@ -384,7 +381,7 @@ func (s *Handlers) handleUpdateNet(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "Couldn't save the net.", err)
return
}
web.RedirectFlash(w, r, netSettingsURL(org.Slug, net.ID), "ok", "Schedule saved. Sessions that have already started keep their original times.")
web.RedirectOK(w, r, netSettingsURL(org.Slug, net.ID), "Schedule saved. Sessions that have already started keep their original times.")
}
// handleSetNetAck saves how a net's check-ins are acknowledged.
@@ -400,7 +397,7 @@ func (s *Handlers) handleSetNetAck(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "Couldn't save the net.", err)
return
}
web.RedirectFlash(w, r, netSettingsURL(org.Slug, net.ID), "ok", "Check-in settings saved.")
web.RedirectOK(w, r, netSettingsURL(org.Slug, net.ID), "Check-in settings saved.")
}
func (s *Handlers) handleDeleteNet(w http.ResponseWriter, r *http.Request) {
@@ -420,7 +417,7 @@ func (s *Handlers) handleDeleteNet(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "Couldn't delete the net.", err)
return
}
web.RedirectFlash(w, r, settingsURL(orgParam(r), "nets"), "ok", "Net deleted.")
web.RedirectOK(w, r, settingsURL(orgParam(r), "nets"), "Net deleted.")
}
// netSessionContext is what every session page and action resolves first.
+3 -3
View File
@@ -109,7 +109,7 @@ func (s *Handlers) handleSaveOrgBroker(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "Couldn't save the broker.", err)
return
}
web.RedirectFlash(w, r, mqttURL(r), "ok", "Broker saved. MeshTender connects within a minute.")
web.RedirectOK(w, r, mqttURL(r), "Broker saved. MeshTender connects within a minute.")
}
func (s *Handlers) handleDeleteOrgBroker(w http.ResponseWriter, r *http.Request) {
@@ -121,7 +121,7 @@ func (s *Handlers) handleDeleteOrgBroker(w http.ResponseWriter, r *http.Request)
s.ServerError(w, r, "Couldn't remove the broker.", err)
return
}
web.RedirectFlash(w, r, mqttURL(r), "ok", "Broker removed.")
web.RedirectOK(w, r, mqttURL(r), "Broker removed.")
}
// handleTestOrgBroker connects to the saved broker once (address guard and all)
@@ -157,7 +157,7 @@ func (s *Handlers) handleTestOrgBroker(w http.ResponseWriter, r *http.Request) {
return
}
client.Disconnect(250)
web.RedirectFlash(w, r, mqttURL(r), "ok", "Connected to the broker.")
web.RedirectOK(w, r, mqttURL(r), "Connected to the broker.")
}
func (s *Handlers) handleAddOrgObserver(w http.ResponseWriter, r *http.Request) {
-1
View File
@@ -113,7 +113,6 @@ func (s *Handlers) renderOrgHome(w http.ResponseWriter, r *http.Request, org *st
"Radio": radio,
"Config": config,
"MapPoints": reps.MapPoints,
"Error": r.URL.Query().Get("error"),
}
if radio != nil {
data["Preset"] = presetName(*radio)
-2
View File
@@ -102,8 +102,6 @@ func (s *Handlers) settingsData(ctx context.Context, r *http.Request, org *store
"Org": org,
"Nav": s.orgNav(ctx, org, "settings-"+section, true),
"Section": section,
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
}
switch section {
case "profile":
+4 -8
View File
@@ -36,16 +36,13 @@ func (s *Handlers) pageMyOrgs(w http.ResponseWriter, r *http.Request) {
return
}
s.Render(w, r, "my_orgs.html", map[string]any{
"Orgs": mine,
"Error": r.URL.Query().Get("error"),
"Orgs": mine,
})
}
// pageNewOrg renders the standalone "create an organization" form.
func (s *Handlers) pageNewOrg(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "new_org.html", map[string]any{
"Error": r.URL.Query().Get("error"),
})
s.Render(w, r, "new_org.html", map[string]any{})
}
// handleCreateOrg creates an org with the current user as its first admin.
@@ -177,7 +174,6 @@ func (s *Handlers) pageOrgMembers(w http.ResponseWriter, r *http.Request) {
"IsAdmin": role == "admin",
"Members": members,
"Self": uid,
"Error": r.URL.Query().Get("error"),
})
}
@@ -257,7 +253,7 @@ func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) {
return
}
// The slug may have changed; land on the new canonical URL.
web.RedirectFlash(w, r, settingsURL(entered.Slug, "profile"), "ok", "Profile saved.")
web.RedirectOK(w, r, settingsURL(entered.Slug, "profile"), "Profile saved.")
}
// handleSetOrgLinks replaces an org's whole set of social/site links from the
@@ -360,7 +356,7 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
fail("Could not save links.")
return
}
web.RedirectFlash(w, r, settingsURL(orgParam(r), "profile"), "ok", "Links saved.")
web.RedirectOK(w, r, settingsURL(orgParam(r), "profile"), "Links saved.")
}
// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
-2
View File
@@ -33,7 +33,6 @@ func (s *Handlers) pageRepeaterDocs(w http.ResponseWriter, r *http.Request) {
"Repeater": rep,
"Nav": web.RepeaterNav(rep.PublicID, rep.Name, rep.OwnerName(), isOwner, "docs"),
"IsOwner": isOwner,
"Error": r.URL.Query().Get("error"),
})
}
@@ -71,7 +70,6 @@ func (s *Handlers) pageRepeaterMaintenance(w http.ResponseWriter, r *http.Reques
"IsOwner": isOwner,
"Entries": entries,
"Today": time.Now().Format("2006-01-02"),
"Error": r.URL.Query().Get("error"),
})
}
-4
View File
@@ -53,7 +53,6 @@ func (s *Handlers) pageAddRepeater(w http.ResponseWriter, r *http.Request) {
"RevokeCommand": s.Identity.RevokePermCommand(),
"Radio": newRadioFields(defaultRadio(), true),
"RadioSerial": newRadioFields(defaultRadio(), false), // serial-setup.js reads the fields by id
"Error": r.URL.Query().Get("error"),
}
if step == "serial" {
orgs := s.setupOrgOptions(r)
@@ -102,8 +101,6 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) {
"Nav": web.RepeaterNav(rep.PublicID, rep.Name, rep.OwnerName(), isOwner, "overview"),
"Radio": radio,
"ContactURI": contactURI,
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
}
// When the owner has published a public page, surface its link plus a QR code
// for it.
@@ -260,7 +257,6 @@ func (s *Handlers) pageEditRepeater(w http.ResponseWriter, r *http.Request) {
"Repeater": rep,
"Radio": newRadioFields(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}, true), //nolint:gosec // G115: radio config value is bounded (preset-constrained)
"RevokeCommand": s.Identity.RevokePermCommand(),
"Error": r.URL.Query().Get("error"),
})
}
-1
View File
@@ -65,7 +65,6 @@ func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
// Stated in the copy so an owner knows a link is time-boxed before handing it
// out. Derived from the store's TTL so the two can't disagree.
"InviteTTLDays": inviteTTLDays,
"Error": r.URL.Query().Get("error"),
})
}
@@ -7,7 +7,6 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-12 col-lg-9 col-xl-8">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<ul class="steps steps-counter mb-4">
<li class="step-item {{if eq .Step "method"}}active{{end}}">Connection</li>
-1
View File
@@ -15,7 +15,6 @@
</div>
{{end}}
{{define "content"}}
{{if .Flash}}<div class="alert alert-success" role="alert">{{.Flash}}</div>{{end}}
<div class="row row-cards">
<div class="col-sm-4">
-1
View File
@@ -56,7 +56,6 @@
{{define "users-frag"}}{{template "user-rows" .}}{{if .NextCursor}}{{template "user-more" .}}{{end}}{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row row-cards">
{{/* Search/filters come FIRST in the DOM and move to the right of the list at lg
(order-lg-last). Source order governs both the mobile stack and the tab order,
+1 -3
View File
@@ -2,13 +2,11 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your radios</div>
<h1 class="page-title fs-1">Companions</h1>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger" role="alert"><div>{{.Error}}</div></div>{{end}}
<div class="row row-cards">
<div class="col-lg-8">
@@ -21,7 +19,7 @@
<span class="fw-bold">{{.Name}}</span>
{{if .Confirmed}}<span class="badge bg-green-lt">Confirmed</span>{{else}}<span class="badge bg-yellow-lt">Waiting for an advert</span>{{end}}
<span class="ms-auto text-secondary">
{{if .LastHeardAt}}Last heard {{ts .LastHeardAt "datetime"}}{{else}}Not heard yet{{end}}
{{if .LastHeardAt}}Last heard {{ts .LastHeardAt "relative"}}{{else}}Not heard yet{{end}}
</span>
</div>
<div class="text-secondary font-monospace small">{{slice .PublicKeyHex 0 16}}…</div>
+1 -3
View File
@@ -2,13 +2,11 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Dashboard</div>
<h1 class="page-title fs-1">Welcome back{{if .UserName}}, {{.UserName}}{{end}}</h1>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
<!-- Summary stats -->
<div class="row row-cards">
@@ -106,7 +104,7 @@
<td class="text-truncate" style="max-width:10rem"><a class="text-reset" href="/repeaters/{{.RepeaterPublicID}}/log">{{.RepeaterName}}</a></td>
<td><code>{{.CommandText}}</code></td>
<td class="text-secondary">{{if .SenderUsername}}<a class="text-reset" href="{{$.RootURL}}/u/{{.SenderUsername}}">{{.SenderName}}</a>{{else}}{{.SenderName}}{{end}}</td>
<td class="text-secondary text-nowrap">{{ts .SentAt "datetime"}}</td>
<td class="text-secondary text-nowrap">{{ts .SentAt "relative"}}</td>
<td>{{if .AckReceived}}<span class="badge bg-success-lt">reply</span>{{else}}<span class="badge bg-yellow-lt">no reply</span>{{end}}</td>
</tr>
{{end}}
@@ -8,7 +8,6 @@
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-body">
<p class="text-secondary">The public key identifies the repeater and can't be changed — delete and re-add to change it.</p>
-2
View File
@@ -2,7 +2,6 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your network</div>
<h1 class="page-title fs-1">Organizations</h1>
</div>
<div class="col-auto ms-auto d-print-none">
@@ -14,7 +13,6 @@
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .Orgs}}
<div class="row row-cards" data-filter-root>
{{/* Search/filters come FIRST in the DOM and move to the right of the list at lg
-1
View File
@@ -2,7 +2,6 @@
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger" role="alert"><div>{{.Error}}</div></div>{{end}}
<ol class="breadcrumb mb-2" aria-label="Breadcrumb">
<li class="breadcrumb-item"><a href="/orgs/{{.Org.Slug}}/nets">Nets</a></li>
-2
View File
@@ -2,7 +2,6 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organizations</div>
<h1 class="page-title fs-1">Create an organization</h1>
</div>
</div>
@@ -12,7 +11,6 @@
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary">
An organization lets its admins help keep members' repeaters in spec. You'll be its first
admin, and you can invite others and set what commands they may run once it's created.
+2 -3
View File
@@ -2,7 +2,6 @@
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger" role="alert"><div>{{.Error}}</div></div>{{end}}
{{/* A banner for each net in session: the thing to do right now. */}}
{{range .LiveNets}}
@@ -125,13 +124,13 @@
{{if eq .Kind "repeater_added"}}
<span class="text-secondary d-flex">{{template "icon-antenna" ""}}</span>
<div><div><strong>{{.Name}}</strong> joined the organization</div>
<div class="text-secondary small">{{ts .At "datetime"}}</div></div>
<div class="text-secondary small">{{ts .At "relative"}}</div></div>
{{else}}
<span class="text-secondary d-flex">{{template "icon-calendar-event" ""}}</span>
<div>
<div><a href="/orgs/{{$.Org.Slug}}/nets/{{.NetID}}/sessions/{{.SessionID}}">{{.Name}}</a>
{{if eq .Kind "net_started"}}started{{else}}ended · {{.CheckedIn}} checked in{{end}}</div>
<div class="text-secondary small">{{ts .At "datetime"}}</div>
<div class="text-secondary small">{{ts .At "relative"}}</div>
</div>
{{end}}
</div>
-1
View File
@@ -2,7 +2,6 @@
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row row-cards">
{{/* Search/filters come FIRST in the DOM and move to the right of the roster at lg
-2
View File
@@ -2,8 +2,6 @@
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger" role="alert"><div>{{.Error}}</div></div>{{end}}
{{if .OK}}<div class="alert alert-success" role="alert"><div>{{.OK}}</div></div>{{end}}
<ol class="breadcrumb mb-2" aria-label="Breadcrumb">
<li class="breadcrumb-item"><a href="/orgs/{{.Org.Slug}}/nets">Nets</a></li>
-1
View File
@@ -3,7 +3,6 @@
{{define "content"}}
{{template "org-tabs" .Nav}}
{{template "org-checkins-nav" .Nav}}
{{if .Error}}<div class="alert alert-danger" role="alert"><div>{{.Error}}</div></div>{{end}}
{{if .Nets}}
{{template "net-list" .}}
@@ -7,8 +7,6 @@
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "settings-layout-open" .Nav}}
{{if .Error}}<div class="alert alert-danger" role="alert"><div>{{.Error}}</div></div>{{end}}
{{if .OK}}<div class="alert alert-success" role="alert" data-testid="settings-ok"><div>{{.OK}}</div></div>{{end}}
{{if eq .Section "profile"}}{{template "settings-profile" .}}
{{else if eq .Section "regions"}}{{template "settings-regions" .}}
{{else if eq .Section "profiles"}}{{template "settings-profiles" .}}
-2
View File
@@ -2,8 +2,6 @@
{{define "header"}}{{template "repeater-header" .Nav}}{{end}}
{{define "content"}}
{{template "repeater-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<div class="row row-cards">
<!-- Left column -->
@@ -2,7 +2,6 @@
{{define "header"}}{{template "repeater-header" .Nav}}{{end}}
{{define "content"}}
{{template "repeater-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .IsOwner}}
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/docs">
@@ -2,7 +2,6 @@
{{define "header"}}{{template "repeater-header" .Nav}}{{end}}
{{define "content"}}
{{template "repeater-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-body">
-2
View File
@@ -2,7 +2,6 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your equipment</div>
<h1 class="page-title fs-1">Repeaters</h1>
</div>
<div class="col-auto ms-auto d-print-none">
@@ -11,7 +10,6 @@
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
{{if .Repeaters}}
<div class="row row-cards" data-filter-root>
-1
View File
@@ -2,7 +2,6 @@
{{define "header"}}{{template "repeater-header" .Nav}}{{end}}
{{define "content"}}
{{template "repeater-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-body">
@@ -76,7 +76,6 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{template "transfer-intro" .}}
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/transfer" id="transfer-form"
data-confirm="Transfer this repeater? The new owner controls it from then on, and you can't undo this yourself.">
@@ -98,7 +97,6 @@
{{define "header"}}{{template "repeater-header" .Nav}}{{end}}
{{define "content"}}
{{template "repeater-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
+1 -2
View File
@@ -45,7 +45,6 @@ func (s *Handlers) pageTransferRepeater(w http.ResponseWriter, r *http.Request)
"Repeater": rep,
"Nav": web.RepeaterNav(rep.PublicID, rep.Name, rep.OwnerName(), true, "sharing"),
"Stewards": stewards,
"Error": r.URL.Query().Get("error"),
}
if r.Header.Get("HX-Request") != "" {
data["Layout"] = "transfer-modal"
@@ -83,7 +82,7 @@ func (s *Handlers) handleTransferRepeater(w http.ResponseWriter, r *http.Request
default:
// The outgoing owner keeps access as a steward, so the repeater page still
// renders for them — the right place to see the handover took effect.
web.RedirectFlash(w, r, "/repeaters/"+repeaterParam(r), "ok",
web.RedirectOK(w, r, "/repeaters/"+repeaterParam(r),
"Ownership transferred. You're now a steward of this repeater.")
}
}
-2
View File
@@ -372,7 +372,6 @@ func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "repeaters.html", map[string]any{
"Repeaters": combined,
"ShareCounts": shareCounts,
"Error": r.URL.Query().Get("error"),
})
}
@@ -531,7 +530,6 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) {
"Onboarding": steps,
"OnboardingDone": doneCount,
"OnboardingTotal": total,
"Error": r.URL.Query().Get("error"),
})
}
+1 -1
View File
@@ -348,7 +348,7 @@ func TestE2ENetAirSettings(t *testing.T) {
chromedp.Evaluate(`(() => { const s = document.getElementById("air-scope"); s.value = s.options[1].value; })()`, nil),
chromedp.SetValue(`#air-sender`, "Net Control", chromedp.ByQuery),
chromedp.Click(`[data-testid="net-air-save"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="settings-ok"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="flash-ok"]`, chromedp.ByQuery),
chromedp.Navigate(fmt.Sprintf("%s/orgs/%s/nets/%d", srv.appURL, org.Slug, net.ID)),
chromedp.WaitVisible(`[data-testid="net-air-summary"]`, chromedp.ByQuery),
chromedp.Text(`[data-testid="net-air-summary"]`, &summary, chromedp.ByQuery),
+2 -2
View File
@@ -36,7 +36,7 @@ func TestE2EOrgProfileSettingsSave(t *testing.T) {
chromedp.Value(`#org_name`, &name, chromedp.ByQuery),
chromedp.SetValue(`#org_region`, "Buffalo, NY", chromedp.ByQuery),
chromedp.Click(`[data-testid="save-profile-settings"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="settings-ok"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="flash-ok"]`, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", settingsURL, err)
}
@@ -121,7 +121,7 @@ func TestE2EOrgLinksSettingsAddRow(t *testing.T) {
chromedp.Evaluate(countRows, &after),
chromedp.SetValue(`[data-testid="settings-links"] .link-row:last-child .link-value`, "https://example.org", chromedp.ByQuery),
chromedp.Click(`[data-testid="save-links"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="settings-ok"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="flash-ok"]`, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", settingsURL, err)
}
+1 -1
View File
@@ -54,7 +54,7 @@ func TestE2EOrgRadioSettings(t *testing.T) {
editRadioField("radio_sf", "9"),
chromedp.Evaluate(radioState, &afterEdit),
chromedp.Click(`[data-testid="save-radio"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="settings-ok"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="flash-ok"]`, chromedp.ByQuery),
chromedp.Evaluate(radioState, &saved),
); err != nil {
t.Fatalf("set org radio: %v", err)
+48
View File
@@ -82,3 +82,51 @@ func TestE2ETimestampLocalizesToUserZone(t *testing.T) {
}
watch.assertClean(t)
}
// TestE2ERelativeTimestamp: a "relative" <time> reads like "3 min. ago" (what
// the browser's own Intl.RelativeTimeFormat says, so the assertion is
// locale-independent), with the exact time as its hover title.
func TestE2ERelativeTimestamp(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "reltime")
c, err := srv.store.CreateCompanion(srv.ctx, user.ID, "Rel T1", strings.Repeat("cd", 32))
if err != nil {
t.Fatal(err)
}
if _, err := srv.store.Pool().Exec(srv.ctx, `UPDATE companions SET last_heard_at = $2 WHERE id = $1`,
c.ID, time.Now().Add(-3*time.Minute)); err != nil {
t.Fatal(err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var res struct {
Text string `json:"text"`
Expected string `json:"expected"`
Title string `json:"title"`
}
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(srv.appURL+"/companions"),
chromedp.WaitReady(`time[data-fmt="relative"]`, chromedp.ByQuery),
chromedp.Evaluate(`(function () {
var el = document.querySelector('time[data-fmt="relative"]');
return {
text: el.textContent,
title: el.title,
expected: new Intl.RelativeTimeFormat(undefined, {numeric: 'auto', style: 'short'}).format(-3, 'minute')
};
})()`, &res),
); err != nil {
t.Fatal(err)
}
if res.Text != res.Expected {
t.Errorf("relative time reads %q, want %q", res.Text, res.Expected)
}
if res.Title == "" || res.Title == res.Text {
t.Errorf("hover title = %q, want the exact time", res.Title)
}
watch.assertClean(t)
}
-1
View File
@@ -43,7 +43,6 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) {
}
data := map[string]any{
"All": all,
"Error": q.Get("error"),
"Sort": string(sortKey),
"Query": query,
}
-1
View File
@@ -2,7 +2,6 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Help</div>
<h1 class="page-title fs-1">How MeshTender works</h1>
</div>
</div>
-1
View File
@@ -52,7 +52,6 @@
{{define "orgs-frag"}}{{template "org-rows" .}}{{if .NextCursor}}{{template "org-more" .}}{{end}}{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row row-cards">
{{/* Search/filters come FIRST in the DOM and move to the right of the list at lg
(order-lg-last). Source order governs both the mobile stack and the tab order,
@@ -2,7 +2,6 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Legal</div>
<h1 class="page-title fs-1">Privacy</h1>
</div>
</div>
-1
View File
@@ -2,7 +2,6 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Legal</div>
<h1 class="page-title fs-1">Terms of use</h1>
</div>
</div>
+20 -4
View File
@@ -28,7 +28,7 @@ import (
"github.com/MeshTender/MeshTender/internal/store"
)
//go:embed templates/base.html templates/icons.html templates/brand.html templates/org_tabs.html templates/repeater_tabs.html templates/command_grid.html templates/org_access.html templates/radio_fields.html templates/net_form.html templates/org_config_parts.html templates/org_public.html templates/org_config.html templates/org_repeaters.html templates/error.html
//go:embed templates/base.html templates/icons.html templates/brand.html templates/org_tabs.html templates/repeater_tabs.html templates/command_grid.html templates/org_access.html templates/radio_fields.html templates/net_form.html templates/org_config_parts.html templates/ui_parts.html templates/org_public.html templates/org_config.html templates/org_repeaters.html templates/error.html
var sharedTemplatesFS embed.FS
// sharedPages are full content pages (not just layout partials) that more than
@@ -247,6 +247,8 @@ var tsFallbackLayouts = map[string]string{
"datetime": "Jan 2, 2006, 15:04 UTC",
"time": "15:04 UTC",
"time-seconds": "15:04:05 UTC",
// relative ("5 min ago", exact time on hover) falls back to the full time.
"relative": "Jan 2, 2006, 15:04 UTC",
}
// TimeElement renders an instant as
@@ -254,8 +256,9 @@ var tsFallbackLayouts = map[string]string{
// <time datetime="2006-01-02T15:04:05Z" data-fmt="datetime">Jan 2, 2006, 15:04 UTC</time>
//
// The machine-readable datetime is always a full RFC3339 UTC timestamp; kind
// (date|datetime|time|time-seconds) selects which parts ui.js shows once it
// localizes the element. A zero time renders nothing (call sites guard nil).
// (date|datetime|time|time-seconds|relative) selects which parts ui.js shows
// once it localizes the element; relative reads "5 min ago", with the exact time
// on hover. A zero time renders nothing (call sites guard nil).
//
// The result is template.HTML because it's server-controlled markup: the only
// interpolated values are a formatted timestamp and a fixed, validated kind — no
@@ -281,7 +284,7 @@ func TimeElement(t time.Time, kind string) template.HTML {
}
func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) {
base, err := template.New("").Funcs(templateFuncs).ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/brand.html", "templates/org_tabs.html", "templates/repeater_tabs.html", "templates/command_grid.html", "templates/org_access.html", "templates/radio_fields.html", "templates/net_form.html", "templates/org_config_parts.html")
base, err := template.New("").Funcs(templateFuncs).ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/brand.html", "templates/org_tabs.html", "templates/repeater_tabs.html", "templates/command_grid.html", "templates/org_access.html", "templates/radio_fields.html", "templates/net_form.html", "templates/org_config_parts.html", "templates/ui_parts.html")
if err != nil {
return nil, err
}
@@ -364,6 +367,13 @@ func (rn *Renderer) render(w http.ResponseWriter, r *http.Request, status int, p
data["UserTZ"] = tz
}
}
// NavActive marks the current section in the top nav (navSection).
data["NavActive"] = navSection(rn.cfg, r)
// FlashOK / FlashError are the one-shot messages a redirect carries
// (RedirectFlash's ?ok= / ?error=). The layout shows them above every page's
// content, so no page renders — or forgets — its own.
q := r.URL.Query()
data["FlashOK"], data["FlashError"] = q.Get("ok"), q.Get("error")
// LiveReload pulls in livereload.js (see WithLiveReload). Dev only.
data["LiveReload"] = rn.cfg.LiveReload
// Per-request CSP nonce for inline <script nonce="{{.Nonce}}"> blocks.
@@ -530,6 +540,12 @@ func RedirectFlash(w http.ResponseWriter, r *http.Request, path, key, msg string
http.Redirect(w, r, path+sep+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
}
// RedirectOK is RedirectFlash with the "ok" key: a success message the layout
// shows above the next page.
func RedirectOK(w http.ResponseWriter, r *http.Request, path, msg string) {
RedirectFlash(w, r, path, "ok", msg)
}
// RedirectErr is RedirectFlash with the conventional "error" key.
func RedirectErr(w http.ResponseWriter, r *http.Request, path, msg string) {
RedirectFlash(w, r, path, "error", msg)
+121
View File
@@ -0,0 +1,121 @@
package web
import (
"context"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/MeshTender/MeshTender/internal/config"
)
func TestNavSection(t *testing.T) {
t.Parallel()
cfg := &config.Config{PrimaryHost: "app.example", AuthHost: "auth.example", RootHost: "example"}
for _, tc := range []struct{ url, want string }{
{"https://app.example/", NavDashboard},
{"https://app.example/repeaters", NavRepeaters},
{"https://app.example/repeaters/abc/console", NavRepeaters},
{"https://app.example/repeatersx", ""},
{"https://app.example/companions/abc", NavCompanions},
{"https://app.example/orgs/wny/nets/3", NavOrganizations},
{"https://app.example/admin/users", NavAdmin},
{"https://app.example/invite/tok", ""},
{"https://example/orgs/wny", NavOrganizations},
{"https://example/docs", NavDocs},
{"https://example/", ""},
{"https://auth.example/account", ""},
} {
if got := navSection(cfg, httptest.NewRequest(http.MethodGet, tc.url, nil)); got != tc.want {
t.Errorf("navSection(%s) = %q, want %q", tc.url, got, tc.want)
}
}
}
// renderLayout renders the error page in a layout for url, signed in as
// "alice" when signedIn.
func renderLayout(t *testing.T, layout, url string, signedIn bool) string {
t.Helper()
e := testEnv(t)
if signedIn {
e.Renderer.userInfo = func(context.Context) (string, bool, string, bool) { return "alice", false, "", true }
}
rec := httptest.NewRecorder()
e.Renderer.render(rec, httptest.NewRequest(http.MethodGet, url, nil), http.StatusNotFound, "error.html",
map[string]any{"Layout": layout, "Status": http.StatusNotFound, "Title": "Page not found", "Message": "gone"})
return rec.Body.String()
}
// TestTopNavMarksTheCurrentSection: the top nav underlines the section the page
// is in (and only that one), and a signed-in user gets the same app sections
// on the public host as on the app host.
func TestTopNavMarksTheCurrentSection(t *testing.T) {
t.Parallel()
active := regexp.MustCompile(`<li class="nav-item active">\s*<a class="nav-link" href="([^"]+)" aria-current="page"`)
body := renderLayout(t, "base", "https://app.example/repeaters/abc", true)
if m := active.FindAllStringSubmatch(body, -1); len(m) != 1 || !strings.HasSuffix(m[0][1], "/repeaters") {
t.Errorf("app /repeaters/abc: active items %v, want just Repeaters", m)
}
root := renderLayout(t, "rootbase", "https://example/orgs/wny", true)
for _, want := range []string{"/repeaters\"", "/companions\""} {
if !strings.Contains(root, want) {
t.Errorf("signed in on the public host, the nav lacks %s", want)
}
}
if m := active.FindAllStringSubmatch(root, -1); len(m) != 1 || !strings.HasSuffix(m[0][1], "/orgs") {
t.Errorf("public /orgs/wny: active items %v, want just Organizations", m)
}
out := renderLayout(t, "landingbase", "https://example/", false)
if strings.Contains(out, "/repeaters\"") || !strings.Contains(out, "Sign in") {
t.Error("signed out, the nav should offer sign-in and no app sections")
}
}
// TestEveryLayoutShowsRedirectFlashes: a redirect's ?ok= / ?error= message
// appears on every layout, escaped, without the page doing anything.
func TestEveryLayoutShowsRedirectFlashes(t *testing.T) {
t.Parallel()
for _, layout := range []string{"base", "rootbase", "authbase", "landingbase"} {
body := renderLayout(t, layout, "https://app.example/x?ok=Saved+%3Cb%3E&error=Nope", false)
if !strings.Contains(body, `data-testid="flash-ok"`) || !strings.Contains(body, "Saved &lt;b&gt;") {
t.Errorf("%s: no escaped ok flash", layout)
}
if !strings.Contains(body, `data-testid="flash-error"`) || !strings.Contains(body, "Nope") {
t.Errorf("%s: no error flash", layout)
}
}
}
// TestHandlersLeaveRedirectFlashesToTheLayout: the layout shows ?ok= and
// ?error=, so a handler reading them into page data would show the message
// twice. (A form's own validation error is page data under another source.)
func TestHandlersLeaveRedirectFlashesToTheLayout(t *testing.T) {
t.Parallel()
readsFlash := regexp.MustCompile(`Query\(\)\.Get\("(ok|error)"\)|\bq\.Get\("(ok|error)"\)`)
for _, dir := range []string{"../core", "../auth", "../marketing"} {
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
b, err := os.ReadFile(path) //nolint:gosec // G304: walking our own source tree
if err != nil {
return err
}
if m := readsFlash.Find(b); m != nil {
t.Errorf("%s reads %s; the layout already shows it (see the flash template)", path, m)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package web
import (
"net/http"
"strings"
"github.com/MeshTender/MeshTender/internal/config"
)
// Top-nav sections (base.html's "topnav"), named by navSection.
const (
NavDashboard = "dashboard"
NavRepeaters = "repeaters"
NavCompanions = "companions"
NavOrganizations = "organizations"
NavDocs = "docs"
NavAdmin = "admin"
)
// navSection is the top-nav section a request belongs to, from its host and
// path; "" when it's in none (account pages, sign-in, the landing page).
func navSection(cfg *config.Config, r *http.Request) string {
path := r.URL.Path
under := func(prefix string) bool { return path == prefix || strings.HasPrefix(path, prefix+"/") }
switch host := HostWithoutPort(r.Host); {
case strings.EqualFold(host, cfg.AuthHost):
return ""
case strings.EqualFold(host, cfg.RootHost):
switch {
case under("/orgs"):
return NavOrganizations
case under("/docs"):
return NavDocs
}
return ""
}
switch {
case path == "/":
return NavDashboard
case under("/repeaters"):
return NavRepeaters
case under("/companions"):
return NavCompanions
case under("/orgs"):
return NavOrganizations
case under("/admin"):
return NavAdmin
}
return ""
}
+5
View File
@@ -261,3 +261,8 @@ code.pubkey {
:root {
scrollbar-gutter: stable;
}
/* A copy button (ui.js data-copy*) that just copied: green for a moment. */
[data-copied] {
color: var(--tblr-success) !important;
}
+43 -3
View File
@@ -68,7 +68,7 @@
// --- timestamp localization ---------------------------------------------
// The server emits <time datetime="<RFC3339 UTC>" data-fmt="date|datetime|
// time|time-seconds">UTC fallback</time>. We reformat each into the viewer's
// time|time-seconds|relative">UTC fallback</time>. We reformat each into the viewer's
// locale (via Intl, with an undefined locale = the browser's) and time zone:
// the user's saved IANA zone from <html data-tz>, or — when unset — the
// browser's own zone (achieved by omitting timeZone so Intl uses local).
@@ -110,10 +110,44 @@
var el = els[i];
var d = new Date(el.getAttribute("datetime"));
if (isNaN(d.getTime())) continue; // leave the server fallback in place
el.textContent = tsFormatter(el.getAttribute("data-fmt")).format(d);
var kind = String(el.getAttribute("data-fmt"));
if (kind === "relative") {
el.textContent = relativeText(d);
el.title = tsFormatter("datetime").format(d);
} else {
el.textContent = tsFormatter(kind).format(d);
}
}
}
// relativeText is "just now", "5 min ago", "in 2 hours", "yesterday"… for a
// recent or near-future instant, and the date once it's more than a week
// away, where "23 days ago" stops being useful.
var RELATIVE_STEPS = [
["second", 60], ["minute", 60], ["hour", 24], ["day", 7],
];
var relativeFmt = null;
function relativeText(d) {
var secs = (d.getTime() - Date.now()) / 1000;
if (Math.abs(secs) < 45) return "just now";
if (!relativeFmt && window.Intl && Intl.RelativeTimeFormat) {
relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: "auto", style: "short" });
}
var value = secs;
for (var i = 0; i < RELATIVE_STEPS.length; i++) {
var unit = RELATIVE_STEPS[i][0], size = RELATIVE_STEPS[i][1];
if (Math.abs(value) < size && relativeFmt) return relativeFmt.format(Math.round(value), unit);
value /= size;
}
return tsFormatter("date").format(d);
}
// Relative times drift as the page sits open; refresh them each minute.
setInterval(function () {
var els = document.querySelectorAll('time[data-fmt="relative"][datetime]');
if (els.length) formatTimes(document);
}, 60000);
// Exposed so views that inject timestamped nodes outside htmx (e.g. a
// WebSocket log) can localize them: window.MeshtenderFormatTimes(node).
window.MeshtenderFormatTimes = formatTimes;
@@ -217,7 +251,13 @@
text = target.tagName === "INPUT" || target.tagName === "TEXTAREA" ? target.value : target.textContent;
}
}
if (text != null && navigator.clipboard) navigator.clipboard.writeText(text);
if (text != null && navigator.clipboard) {
navigator.clipboard.writeText(text).then(function () {
// Brief confirmation: the button turns green (app.css [data-copied]).
copyBtn.setAttribute("data-copied", "");
setTimeout(function () { copyBtn.removeAttribute("data-copied"); }, 1500);
});
}
}
});
+151 -219
View File
@@ -1,104 +1,14 @@
{{/* The four page layouts — base (app and auth hosts), rootbase (public pages),
landingbase (the landing page) and authbase (sign-in) — share one <head>
(htmlhead), one top nav (topnav), the redirect messages (flash) and the
footer (footerbody), so no surface can drift from the others. */}}
{{define "base"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-bs-theme-base="zinc" data-bs-theme-primary="blue" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" href="{{ asset "/static/favicon.svg" }}" type="image/svg+xml">
<title>{{block "title" .}}MeshTender{{end}}</title>
<link rel="stylesheet" href="{{ asset "/static/tabler.min.css" }}">
{{/* tabler-themes.min.css supplies the palette chosen on <html>:
data-bs-theme-base="zinc" (Tabler 1.6+ defaults to neutral) and blue primary. */}}
<link rel="stylesheet" href="{{ asset "/static/tabler-themes.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/app.css" }}">
<script src="{{ asset "/static/tabler.min.js" }}" defer></script>
<script src="{{ asset "/static/htmx.min.js" }}" defer></script>
<script src="{{ asset "/static/ui.js" }}" defer></script>
{{template "livereload" .}}
<script src="{{ asset "/static/listfilter.js" }}" defer></script>
</head>
{{template "htmlopen" .}}
<body>
<a href="#main-content" class="visually-hidden-focusable btn btn-primary position-absolute top-0 start-0 m-2" style="z-index:1080">Skip to main content</a>
{{template "skiplink"}}
<div class="page">
<header class="navbar navbar-expand-md d-print-none">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a href="{{if .UserName}}{{.AppURL}}{{else}}{{.RootURL}}{{end}}/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none pe-0 pe-md-3">
{{template "icon-logo" "me-1"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
{{if .UserName}}
<div class="nav-item dropdown">
{{/* A real <button>, not an anchor: it opens a menu rather than going
anywhere. As an <a href="#"> it was announced as a link, appended #
to the URL when used, and ignored the Space key (links only respond
to Enter). .nav-link already zeroes background/border, so it needs no
reset classes. */}}
<button type="button" class="nav-link d-flex lh-1 p-0 px-2" data-bs-toggle="dropdown" aria-label="Open user menu">
<span class="avatar avatar-sm">{{template "icon-user" ""}}</span>
<div class="d-none d-xl-block ps-2"><div>{{.UserName}}</div></div>
</button>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a class="dropdown-item" href="{{.AuthURL}}/account">{{template "icon-settings" "dropdown-item-icon"}}Account settings</a>
{{if .LogoutURL}}
<div class="dropdown-divider"></div>
<form method="post" action="{{.LogoutURL}}" class="m-0" data-testid="logout-form">
<button type="submit" class="dropdown-item text-danger">{{template "icon-logout" "dropdown-item-icon"}}Sign out</button>
</form>
{{end}}
</div>
</div>
{{else}}
<a href="{{.AppURL}}/login" class="btn btn-primary">{{template "icon-login" "me-1"}}Sign in</a>
{{end}}
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav">
{{if .UserName}}
<li class="nav-item">
<a class="nav-link" href="{{.AppURL}}/">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-home" ""}}</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{.AppURL}}/repeaters">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-antenna" ""}}</span>
<span class="nav-link-title">Repeaters</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{.AppURL}}/companions">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-radio" ""}}</span>
<span class="nav-link-title">Companions</span>
</a>
</li>
{{end}}
<li class="nav-item">
<a class="nav-link" href="{{if .UserName}}{{.AppURL}}{{else}}{{.RootURL}}{{end}}/orgs">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-world" ""}}</span>
<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">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-settings" ""}}</span>
<span class="nav-link-title">Admin</span>
</a>
</li>
{{end}}
</ul>
</div>
</div>
</header>
{{template "topnav" .}}
<div class="page-wrapper">
<div class="page-header d-print-none" aria-label="Page header">
<div class="container-xl">
@@ -113,6 +23,7 @@
</div>
<div class="page-body">
<div class="container-xl" id="main-content" tabindex="-1">
{{template "flash" .}}
{{block "content" .}}{{end}}
</div>
</div>
@@ -124,96 +35,14 @@
</body>
</html>{{end}}
{{/* footerbody is the one footer used by every layout, so the legal links can't
go missing on a surface. /privacy and /terms are served by the root host
only, hence absolute URLs: the app and auth hosts have no such routes.
The source link is the AGPL section 13 offer — the source of the program
you are interacting with, reachable from every page. */}}
{{/* livereload pulls in the dev server's auto-refresh (web.WithLiveReload). It
renders nothing unless MESHTENDER_LIVE_RELOAD is set, which only the dev
mise task does. */}}
{{define "livereload"}}{{if .LiveReload}}<script src="{{ asset "/static/livereload.js" }}" defer></script>{{end}}{{end}}
{{define "footerbody"}}
<div class="row text-center align-items-center flex-row-reverse gy-2">
<div class="col-12 col-lg-auto">
<span class="text-secondary">MeshTender &mdash; tend your mesh.</span>
</div>
<div class="col-12 col-lg-auto">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/docs">Docs</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/privacy">Privacy</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/terms">Terms</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.SourceURL}}" rel="noopener">Source</a></li>
</ul>
</div>
</div>
{{end}}
{{/* rootnav is the public/root-host topbar: brand + Organizations, with the
sign-in/create-account actions collapsing to a single Dashboard button once
signed in (the root identity beacon sets .UserName). Shared by rootbase and
landingbase so every root page has an identical topbar. */}}
{{define "rootnav"}}
<header class="navbar navbar-expand-md d-print-none">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none pe-0 pe-md-3">
{{template "icon-logo" "me-1"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
{{if .UserName}}
<a href="{{.AppURL}}/" class="btn">{{template "icon-home" "me-1"}}Dashboard</a>
{{else}}
<a href="{{.AppURL}}/login" class="btn">{{template "icon-login" "me-1"}}Sign in</a>
<a href="{{.AppURL}}/signup" class="btn btn-primary">Create account</a>
{{end}}
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/orgs">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-world" ""}}</span>
<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>
</header>
{{end}}
{{/* rootbase is the public/root-host page layout: the shared rootnav topbar plus
the standard page chrome (header block, content, footer). The marketing
surface defaults to this so every root page matches the rest of the site. */}}
{{/* rootbase is the public/root-host page layout: the standard page chrome
(header block, content, footer). The marketing surface defaults to it. */}}
{{define "rootbase"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-bs-theme-base="zinc" data-bs-theme-primary="blue" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" href="{{ asset "/static/favicon.svg" }}" type="image/svg+xml">
<title>{{template "title" .}}</title>
<link rel="stylesheet" href="{{ asset "/static/tabler.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/tabler-themes.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/app.css" }}">
<script src="{{ asset "/static/tabler.min.js" }}" defer></script>
<script src="{{ asset "/static/htmx.min.js" }}" defer></script>
<script src="{{ asset "/static/ui.js" }}" defer></script>
{{template "livereload" .}}
<script src="{{ asset "/static/listfilter.js" }}" defer></script>
</head>
{{template "htmlopen" .}}
<body>
<a href="#main-content" class="visually-hidden-focusable btn btn-primary position-absolute top-0 start-0 m-2" style="z-index:1080">Skip to main content</a>
{{template "skiplink"}}
<div class="page">
{{template "rootnav" .}}
{{template "topnav" .}}
<div class="page-wrapper">
<div class="page-header d-print-none" aria-label="Page header">
<div class="container-xl">
@@ -222,6 +51,7 @@
</div>
<div class="page-body">
<div class="container-xl" id="main-content" tabindex="-1">
{{template "flash" .}}
{{template "content" .}}
</div>
</div>
@@ -235,24 +65,12 @@
{{/* authbase is a standalone, centered layout for the sign-in / sign-up pages —
no navbar or page chrome, just a brand logo above the page's content card.
Pages opt in with {{define "base"}}{{template "authbase" .}}{{end}}.
It still carries footerbody: the legal links and the AGPL section 13 source
offer are owed on every surface, and someone signing in is as much a user of
this server as someone already signed in. */}}
Pages opt in with the "Layout":"authbase" render key. It still carries
footerbody: the legal links and the AGPL section 13 source offer are owed on
every surface, and someone signing in is as much a user of this server as
someone already signed in. */}}
{{define "authbase"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-bs-theme-base="zinc" data-bs-theme-primary="blue" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" href="{{ asset "/static/favicon.svg" }}" type="image/svg+xml">
<title>{{template "title" .}}</title>
<link rel="stylesheet" href="{{ asset "/static/tabler.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/tabler-themes.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/app.css" }}">
<script src="{{ asset "/static/tabler.min.js" }}" defer></script>
<script src="{{ asset "/static/ui.js" }}" defer></script>
{{template "livereload" .}}
</head>
{{template "htmlopen" .}}
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
@@ -261,6 +79,7 @@
{{template "icon-logo" "me-1"}}<span class="fw-bold fs-2">MeshTender</span>
</a>
</div>
{{template "flash" .}}
{{template "content" .}}
<footer class="footer footer-transparent d-print-none mt-4 footer-centered">{{template "footerbody" .}}</footer>
</div>
@@ -268,25 +87,13 @@
</body>
</html>{{end}}
{{/* landingbase is the public marketing layout: a transparent navbar with
sign-in / sign-up actions, the page content, and a simple footer. Pages opt
in via the "Layout":"landingbase" render key. */}}
{{/* landingbase is the public marketing layout: the top nav, the page content,
and the footer. Pages opt in via the "Layout":"landingbase" render key. */}}
{{define "landingbase"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-bs-theme-base="zinc" data-bs-theme-primary="blue" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" href="{{ asset "/static/favicon.svg" }}" type="image/svg+xml">
<title>{{template "title" .}}</title>
<link rel="stylesheet" href="{{ asset "/static/tabler.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/tabler-themes.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/app.css" }}">
<script src="{{ asset "/static/tabler.min.js" }}" defer></script>
<script src="{{ asset "/static/ui.js" }}" defer></script>
{{template "livereload" .}}
</head>
{{template "htmlopen" .}}
<body>
{{template "rootnav" .}}
{{template "topnav" .}}
{{if or .FlashOK .FlashError}}<div class="container-xl mt-3">{{template "flash" .}}</div>{{end}}
{{template "content" .}}
<footer class="footer footer-transparent">
<div class="container">{{template "footerbody" .}}</div>
@@ -294,6 +101,131 @@
</body>
</html>{{end}}
{{/* htmlopen is every layout's <html> element and <head>. The theme attributes
pick the palette tabler-themes.min.css supplies: data-bs-theme-base="zinc"
(Tabler 1.6+ defaults to neutral) and a blue primary. */}}
{{define "htmlopen"}}<html lang="en" data-bs-theme="dark" data-bs-theme-base="zinc" data-bs-theme-primary="blue" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="icon" href="{{ asset "/static/favicon.svg" }}" type="image/svg+xml">
<title>{{block "title" .}}MeshTender{{end}}</title>
<link rel="stylesheet" href="{{ asset "/static/tabler.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/tabler-themes.min.css" }}">
<link rel="stylesheet" href="{{ asset "/static/app.css" }}">
<script src="{{ asset "/static/tabler.min.js" }}" defer></script>
<script src="{{ asset "/static/htmx.min.js" }}" defer></script>
<script src="{{ asset "/static/ui.js" }}" defer></script>
{{template "livereload" .}}
<script src="{{ asset "/static/listfilter.js" }}" defer></script>
</head>{{end}}
{{define "skiplink"}}<a href="#main-content" class="visually-hidden-focusable btn btn-primary position-absolute top-0 start-0 m-2" style="z-index:1080">Skip to main content</a>{{end}}
{{/* topnav is the one top bar on every host. Signed in, it's the app's sections
(links are absolute, since sessions are per host) with the current one
marked (.NavActive, from web.navSection) and the user menu; signed out, it's
the public pages and sign-in. The root host has no /logout (it's GET-only, see
docs/auth-cross-host.md), so there the user menu has no Sign out. */}}
{{define "topnav"}}
<header class="navbar navbar-expand-md d-print-none">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a href="{{if .UserName}}{{.AppURL}}{{else}}{{.RootURL}}{{end}}/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none pe-0 pe-md-3">
{{template "icon-logo" "me-1"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
{{if .UserName}}
<div class="nav-item dropdown">
{{/* A real <button>, not an anchor: it opens a menu rather than going
anywhere. As an <a href="#"> it was announced as a link, appended # to
the URL when used, and ignored the Space key (links only respond to
Enter). .nav-link already zeroes background/border. */}}
<button type="button" class="nav-link d-flex lh-1 p-0 px-2" data-bs-toggle="dropdown" aria-label="Open user menu">
<span class="avatar avatar-sm">{{template "icon-user" ""}}</span>
<div class="d-none d-xl-block ps-2"><div>{{.UserName}}</div></div>
</button>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a class="dropdown-item" href="{{.AuthURL}}/account">{{template "icon-settings" "dropdown-item-icon"}}Account settings</a>
{{if .LogoutURL}}
<div class="dropdown-divider"></div>
<form method="post" action="{{.LogoutURL}}" class="m-0" data-testid="logout-form">
<button type="submit" class="dropdown-item">{{template "icon-logout" "dropdown-item-icon"}}Sign out</button>
</form>
{{end}}
</div>
</div>
{{else}}
<a href="{{.AppURL}}/login" class="btn">{{template "icon-login" "me-1"}}Sign in</a>
<a href="{{.AppURL}}/signup" class="btn btn-primary d-none d-sm-inline-flex">Create account</a>
{{end}}
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav" data-testid="topnav">
{{if .UserName}}
{{template "topnav-item" (dict "Page" . "Section" "dashboard" "Href" (printf "%s/" .AppURL) "Icon" "home" "Label" "Dashboard")}}
{{template "topnav-item" (dict "Page" . "Section" "repeaters" "Href" (printf "%s/repeaters" .AppURL) "Icon" "antenna" "Label" "Repeaters")}}
{{template "topnav-item" (dict "Page" . "Section" "companions" "Href" (printf "%s/companions" .AppURL) "Icon" "radio" "Label" "Companions")}}
{{template "topnav-item" (dict "Page" . "Section" "organizations" "Href" (printf "%s/orgs" .AppURL) "Icon" "world" "Label" "Organizations")}}
{{else}}
{{template "topnav-item" (dict "Page" . "Section" "organizations" "Href" (printf "%s/orgs" .RootURL) "Icon" "world" "Label" "Organizations")}}
{{end}}
{{template "topnav-item" (dict "Page" . "Section" "docs" "Href" (printf "%s/docs" .RootURL) "Icon" "help" "Label" "Docs")}}
{{if .CanAdmin}}
{{template "topnav-item" (dict "Page" . "Section" "admin" "Href" (printf "%s/admin" .AppURL) "Icon" "settings" "Label" "Admin")}}
{{end}}
</ul>
</div>
</div>
</header>
{{end}}
{{/* topnav-item is one top-nav link, marked active (Tabler underlines an active
.nav-item) when .Section is the page's. Data: dict Page, Section, Href, Icon,
Label. */}}
{{define "topnav-item"}}
{{$active := eq .Page.NavActive .Section}}
<li class="nav-item{{if $active}} active{{end}}">
<a class="nav-link" href="{{.Href}}"{{if $active}} aria-current="page"{{end}}>
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "topnav-icon" .Icon}}</span>
<span class="nav-link-title">{{.Label}}</span>
</a>
</li>
{{end}}
{{/* topnav-icon picks a nav icon by name (template names can't be computed). */}}
{{define "topnav-icon"}}{{if eq . "home"}}{{template "icon-home" ""}}{{else if eq . "antenna"}}{{template "icon-antenna" ""}}{{else if eq . "radio"}}{{template "icon-radio" ""}}{{else if eq . "world"}}{{template "icon-world" ""}}{{else if eq . "help"}}{{template "icon-help" ""}}{{else if eq . "settings"}}{{template "icon-settings" ""}}{{end}}{{end}}
{{/* flash shows a redirect's one-shot message (web.RedirectOK / RedirectErr:
?ok= / ?error=, read by the renderer into .FlashOK / .FlashError) above the
page content, on every layout. A form's own validation error is the form's
to show, next to it. */}}
{{define "flash"}}
{{with .FlashError}}<div class="alert alert-danger alert-dismissible" role="alert" data-testid="flash-error"><div>{{.}}</div><a class="btn-close" data-bs-dismiss="alert" aria-label="Close"></a></div>{{end}}
{{with .FlashOK}}<div class="alert alert-success alert-dismissible" role="alert" data-testid="flash-ok"><div>{{.}}</div><a class="btn-close" data-bs-dismiss="alert" aria-label="Close"></a></div>{{end}}
{{end}}
{{/* livereload pulls in the dev server's auto-refresh (web.WithLiveReload). It
renders nothing unless MESHTENDER_LIVE_RELOAD is set, which only the dev
mise task does. */}}
{{define "livereload"}}{{if .LiveReload}}<script src="{{ asset "/static/livereload.js" }}" defer></script>{{end}}{{end}}
{{/* footerbody is the one footer used by every layout, so the legal links can't
go missing on a surface. /privacy and /terms are served by the root host
only, hence absolute URLs: the app and auth hosts have no such routes.
The source link is the AGPL section 13 offer — the source of the program
you are interacting with, reachable from every page. */}}
{{define "footerbody"}}
<ul class="list-inline list-inline-dots mb-0 text-center">
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/docs">Docs</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/privacy">Privacy</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.RootURL}}/terms">Terms</a></li>
<li class="list-inline-item"><a class="link-secondary" href="{{.SourceURL}}" rel="noopener">Source</a></li>
</ul>
{{end}}
{{/* revokedoc explains how to revoke MeshTender's access. Dot is the revoke command string. */}}
{{define "revokedoc"}}
<details class="mt-3">
+29
View File
@@ -0,0 +1,29 @@
{{/* Small shared pieces of UI, so each kind of thing looks the same on every
page. */}}
{{/* empty-state is what a page or list shows when it has nothing yet: a title,
one line saying what goes here (or what to do), and optionally the action
that fills it. Compact suits the inside of a card; without it, it's Tabler's
full-size .empty for an empty page. Data: dict Title, Line, and optionally
ActionHref, ActionLabel, Compact, TestID. */}}
{{define "empty-state"}}
{{if .Compact}}
<div class="card-body text-center py-4"{{with .TestID}} data-testid="{{.}}"{{end}}>
<div class="fw-bold">{{.Title}}</div>
{{with .Line}}<div class="text-secondary small">{{.}}</div>{{end}}
{{with .ActionHref}}<a class="btn btn-sm mt-2" href="{{.}}">{{template "icon-plus" "me-1"}}{{$.ActionLabel}}</a>{{end}}
</div>
{{else}}
<div class="empty"{{with .TestID}} data-testid="{{.}}"{{end}}>
<p class="empty-title">{{.Title}}</p>
{{with .Line}}<p class="empty-subtitle text-secondary">{{.}}</p>{{end}}
{{with .ActionHref}}<div class="empty-action"><a class="btn btn-primary" href="{{.}}">{{template "icon-plus" "me-1"}}{{$.ActionLabel}}</a></div>{{end}}
</div>
{{end}}
{{end}}
{{/* pubkey shows a MeshCore public key the same way everywhere: its first 8 hex
digits (enough to tell keys apart at a glance; the full key is the title)
and a copy button for the whole key. The button sits above a row's
.stretched-link, so it can live in a clickable row. Pass the 64-hex key. */}}
{{define "pubkey"}}<span class="d-inline-flex align-items-center"><span class="font-monospace" title="{{.}}">{{if gt (len .) 8}}{{slice . 0 8}}…{{else}}{{.}}{{end}}</span><button type="button" class="btn btn-sm btn-ghost-secondary btn-icon position-relative ms-1 p-0" style="z-index:2" data-copy="{{.}}" aria-label="Copy public key" title="Copy public key">{{template "icon-copy" ""}}</button></span>{{end}}
+4
View File
@@ -59,4 +59,8 @@ func TestTimeElement(t *testing.T) {
if s := string(TimeElement(ts, "time-seconds")); !strings.Contains(s, "20:04:05 UTC") {
t.Errorf("time-seconds kind = %q", s)
}
// relative keeps its kind for ui.js and falls back to the full time.
if r := string(TimeElement(ts, "relative")); !strings.Contains(r, `data-fmt="relative"`) || !strings.Contains(r, "Jul 5, 2026, 20:04 UTC") {
t.Errorf("relative kind = %q", r)
}
}
+52
View File
@@ -0,0 +1,52 @@
package web
import (
"strings"
"testing"
)
// execPart runs one shared partial with data.
func execPart(t *testing.T, name string, data any) string {
t.Helper()
var b strings.Builder
if err := testEnv(t).Renderer.pages["error.html"].ExecuteTemplate(&b, name, data); err != nil {
t.Fatalf("%s: %v", name, err)
}
return b.String()
}
func TestPubkeyPartial(t *testing.T) {
t.Parallel()
key := strings.Repeat("ab", 32)
got := execPart(t, "pubkey", key)
for _, want := range []string{`>abababab…<`, `title="` + key + `"`, `data-copy="` + key + `"`, `aria-label="Copy public key"`} {
if !strings.Contains(got, want) {
t.Errorf("pubkey lacks %s: %s", want, got)
}
}
if short := execPart(t, "pubkey", "abc"); !strings.Contains(short, ">abc<") {
t.Errorf("a short key isn't shown whole: %s", short)
}
}
func TestEmptyStatePartial(t *testing.T) {
t.Parallel()
dict := func(pairs ...any) map[string]any {
m, err := Dict(pairs...)
if err != nil {
t.Fatal(err)
}
return m
}
full := execPart(t, "empty-state", dict("Title", "No companions yet", "Line", "Register the radio you carry.",
"ActionHref", "/companions/new", "ActionLabel", "Register companion"))
for _, want := range []string{`class="empty"`, "No companions yet", "Register the radio you carry.", `href="/companions/new"`, "Register companion", "btn-primary"} {
if !strings.Contains(full, want) {
t.Errorf("full empty state lacks %s", want)
}
}
compact := execPart(t, "empty-state", dict("Title", "No pending links", "Compact", true))
if !strings.Contains(compact, "card-body") || strings.Contains(compact, `class="empty"`) || strings.Contains(compact, "<a ") {
t.Errorf("compact empty state, no action: %s", compact)
}
}