mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-11-04 03:12:21 +00:00
Build shared UI shell pieces
This commit is contained in:
1 parent
9257757a4e
commit
a80d244247
63 files changed
+550
-325
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 <b>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 — 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">
|
||||
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user