An event companion and onboarding experience for the ATmosphere (alpha) atmo.quest
6

Configure Feed

Select the types of activity you want to include in your feed.

feat(admin): step 6 — admin console (dashboard, users, events, badges)

- port internal/admincrypto (ed25519 Signer + Sign/Verify, key resolution
via ADMIN_SIGNING_KEY env or data/admin_signing.key file)
- port internal/badge (Design + canonical signing payload + RenderSVG with
shield/circle/star/hexagon shapes)
- features/admin/handlers.go: AdminDashboard, AdminUsers, AdminUsersPromote,
AdminEvents, AdminEventNew, AdminEventCreate, AdminEventBadge,
AdminEventBadgeSave; RequireAdmin middleware
- features/admin/pages/{dashboard,users,events}.templ split from the old
single admin_events.templ — same window-chrome + nav shell
- main.go loads the signer (allowGenerate=true only in dev) and threads it
through router.SetupRoutes -> admin.SetupRoutes

routes (all behind RequireAdmin → 303 /signin?next=… for anon):
- GET /admin dashboard
- GET /admin/users (?q= ?admins= ?page=) user list
- POST /admin/users/{did}/promote toggle admin
- GET /admin/events event list
- GET /admin/events/new new-event form
- POST /admin/events create + redirect to badge
- GET /admin/events/{token}/badge badge designer
- POST /admin/events/{token}/badge save signed badge design

verified: all 9 unauthed routes 303 to /signin with correct next=path.
build clean. all tests pass.

Brittany Ellich (May 15, 2026, 10:58 AM -0700) 776d71ea 60a4ea3c

+3275 -1
+11 -1
cmd/web/main.go
··· 2 2 3 3 import ( 4 4 "atmoquest/config" 5 + "atmoquest/internal/admincrypto" 5 6 "atmoquest/internal/db" 6 7 "atmoquest/internal/oauthclient" 7 8 "atmoquest/internal/oauthstore" ··· 71 72 } 72 73 slog.Info("oauth client ready", "client_id", oauthClientID, "localhost", config.Global.IsLocalhost()) 73 74 75 + // Admin signing key — used to sign event badge designs. In dev we let the 76 + // loader generate one and write it to data/admin_signing.key; in prod 77 + // the operator must set ADMIN_SIGNING_KEY (base64 ed25519 seed). 78 + signer, err := admincrypto.Load(config.Global.Environment == config.Dev) 79 + if err != nil { 80 + return fmt.Errorf("load admin signer: %w", err) 81 + } 82 + slog.Info("admin signer ready", "key_id", signer.KeyID) 83 + 74 84 ns, err := nats.SetupNATS(ctx) 75 85 if err != nil { 76 86 return err ··· 78 88 79 89 eg, egctx := errgroup.WithContext(ctx) 80 90 81 - if err := router.SetupRoutes(egctx, r, sessionMgr, oauthApp, ns, conn); err != nil { 91 + if err := router.SetupRoutes(egctx, r, sessionMgr, oauthApp, ns, conn, signer); err != nil { 82 92 return fmt.Errorf("error setting up routes: %w", err) 83 93 } 84 94
+453
features/admin/handlers.go
··· 1 + // Package admin implements the admin console — dashboard, user management, 2 + // event CRUD, and the badge designer. Every route is mounted behind 3 + // RequireAdmin so anonymous users get bounced to /signin and logged-in 4 + // non-admins get a flat 403. 5 + package admin 6 + 7 + import ( 8 + "database/sql" 9 + "encoding/json" 10 + "errors" 11 + "log/slog" 12 + "net/http" 13 + "strconv" 14 + "strings" 15 + "time" 16 + 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + "github.com/go-chi/chi/v5" 19 + 20 + "atmoquest/config" 21 + "atmoquest/features/admin/pages" 22 + "atmoquest/features/auth" 23 + "atmoquest/internal/admincrypto" 24 + "atmoquest/internal/badge" 25 + "atmoquest/internal/event" 26 + "atmoquest/internal/users" 27 + ) 28 + 29 + // Handlers holds the dependencies the admin feature needs. 30 + type Handlers struct { 31 + DB *sql.DB 32 + Auth *auth.Handlers 33 + Signer *admincrypto.Signer 34 + } 35 + 36 + // NewHandlers wires the admin feature. 37 + func NewHandlers(conn *sql.DB, authH *auth.Handlers, signer *admincrypto.Signer) *Handlers { 38 + return &Handlers{DB: conn, Auth: authH, Signer: signer} 39 + } 40 + 41 + // RequireAdmin wraps a handler so it only runs when the session cookie 42 + // identifies an active admin (users.is_admin = 1 AND is_banned = 0). 43 + // 44 + // - No cookie → redirect to /signin?next=<path>. 45 + // - Cookie present, not admin → 403 with a small page. 46 + // - Cookie present, admin → pass through. 47 + func (h *Handlers) RequireAdmin(next http.Handler) http.Handler { 48 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + didStr, sid := h.Auth.Sessions.Get(r) 50 + if didStr == "" || sid == "" { 51 + http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusSeeOther) 52 + return 53 + } 54 + did, err := syntax.ParseDID(didStr) 55 + if err != nil { 56 + http.Redirect(w, r, "/signin", http.StatusSeeOther) 57 + return 58 + } 59 + ok, err := users.IsAdmin(r.Context(), h.DB, did) 60 + if err != nil { 61 + slog.Error("RequireAdmin: IsAdmin", "did", did.String(), "err", err) 62 + http.Error(w, "internal error", http.StatusInternalServerError) 63 + return 64 + } 65 + if !ok { 66 + // Don't leak whether the page exists — render a generic 67 + // not-authorized page rather than 404 vs 403 distinction. 68 + w.WriteHeader(http.StatusForbidden) 69 + _, _ = w.Write([]byte("not authorized")) 70 + return 71 + } 72 + next.ServeHTTP(w, r) 73 + }) 74 + } 75 + 76 + // AdminDashboard renders the top-level admin landing page: a small set of 77 + // "is this app being used" stats plus quick links to the deeper admin 78 + // sections. 79 + func (h *Handlers) AdminDashboard(w http.ResponseWriter, r *http.Request) { 80 + stats, err := users.Counts(r.Context(), h.DB) 81 + if err != nil { 82 + slog.Error("admin dashboard: counts", "err", err) 83 + } 84 + 85 + // Event count is its own query — kept out of the users package so each 86 + // package stays focused. 87 + var eventCount, checkinCount int 88 + _ = h.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM events`).Scan(&eventCount) 89 + _ = h.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM checkins`).Scan(&checkinCount) 90 + 91 + view := pages.AdminDashboardView{ 92 + TotalUsers: stats.TotalUsers, 93 + AdminCount: stats.AdminCount, 94 + BannedCount: stats.BannedCount, 95 + ActiveLast7d: stats.ActiveLast7d, 96 + EventCount: eventCount, 97 + CheckinCount: checkinCount, 98 + SignerKeyID: signerKeyID(h.Signer), 99 + SignerPubKey: signerPubKey(h.Signer), 100 + CurrentHandle: h.currentAdminHandle(r), 101 + } 102 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 103 + if err := pages.AdminDashboard(view).Render(r.Context(), w); err != nil { 104 + slog.Error("render admin dashboard", "err", err) 105 + } 106 + } 107 + 108 + // AdminUsers renders the user list with optional search + admin filter. 109 + // 110 + // Query params: 111 + // 112 + // ?q=... substring search over handle/display_name/DID 113 + // ?admins=1 show only admins 114 + // ?page=N 1-indexed pagination (50 per page) 115 + func (h *Handlers) AdminUsers(w http.ResponseWriter, r *http.Request) { 116 + const perPage = 50 117 + q := r.URL.Query().Get("q") 118 + adminsOnly := r.URL.Query().Get("admins") == "1" 119 + page, _ := strconv.Atoi(r.URL.Query().Get("page")) 120 + if page < 1 { 121 + page = 1 122 + } 123 + 124 + list, err := users.List(r.Context(), h.DB, users.ListOptions{ 125 + Query: q, 126 + AdminsOnly: adminsOnly, 127 + Limit: perPage, 128 + Offset: (page - 1) * perPage, 129 + }) 130 + if err != nil { 131 + slog.Error("admin users: list", "err", err) 132 + http.Error(w, "internal error", http.StatusInternalServerError) 133 + return 134 + } 135 + 136 + view := pages.AdminUsersView{ 137 + Query: q, 138 + AdminsOnly: adminsOnly, 139 + Page: page, 140 + Users: toAdminUsersRows(list), 141 + // HasNext is a cheap signal: if we got a full page, there might be 142 + // another. Exact count would require a second query — not worth it 143 + // for a small admin list. 144 + HasNext: len(list) == perPage, 145 + } 146 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 147 + if err := pages.AdminUsers(view).Render(r.Context(), w); err != nil { 148 + slog.Error("render admin users", "err", err) 149 + } 150 + } 151 + 152 + // AdminUsersPromote handles POST /admin/users/{did}/promote — toggles the 153 + // is_admin flag based on the `make_admin` form value ("1" → admin, anything 154 + // else → demote). 155 + // 156 + // The current admin can demote themselves; this is intentional so a 157 + // sole-admin reset path exists (re-promote via the seed script). 158 + func (h *Handlers) AdminUsersPromote(w http.ResponseWriter, r *http.Request) { 159 + didStr := chi.URLParam(r, "did") 160 + did, err := syntax.ParseDID(didStr) 161 + if err != nil { 162 + http.Error(w, "invalid DID", http.StatusBadRequest) 163 + return 164 + } 165 + makeAdmin := r.FormValue("make_admin") == "1" 166 + if err := users.SetAdmin(r.Context(), h.DB, did, makeAdmin); err != nil { 167 + if errors.Is(err, users.ErrNotFound) { 168 + http.Error(w, "user not found (have they signed in?)", http.StatusNotFound) 169 + return 170 + } 171 + slog.Error("admin promote", "did", did.String(), "err", err) 172 + http.Error(w, "internal error", http.StatusInternalServerError) 173 + return 174 + } 175 + back := "/admin/users" 176 + if r.URL.RawQuery != "" { 177 + back += "?" + r.URL.RawQuery 178 + } 179 + http.Redirect(w, r, back, http.StatusSeeOther) 180 + } 181 + 182 + // AdminEvents renders the list of admin-managed events plus the "create 183 + // new" CTA. 184 + func (h *Handlers) AdminEvents(w http.ResponseWriter, r *http.Request) { 185 + list, err := event.ListAdmin(r.Context(), h.DB, 0) 186 + if err != nil { 187 + slog.Error("admin events: list", "err", err) 188 + http.Error(w, "internal error", http.StatusInternalServerError) 189 + return 190 + } 191 + view := pages.AdminEventsView{ 192 + PublicURL: config.Global.PublicURL, 193 + Events: toAdminEventRows(list, config.Global.PublicURL), 194 + } 195 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 196 + if err := pages.AdminEvents(view).Render(r.Context(), w); err != nil { 197 + slog.Error("render admin events", "err", err) 198 + } 199 + } 200 + 201 + // AdminEventNew renders the empty new-event form. 202 + func (h *Handlers) AdminEventNew(w http.ResponseWriter, r *http.Request) { 203 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 204 + if err := pages.AdminEventNew(pages.AdminEventFormView{}).Render(r.Context(), w); err != nil { 205 + slog.Error("render admin event new", "err", err) 206 + } 207 + } 208 + 209 + // AdminEventCreate handles POST /admin/events — validates the form, writes a 210 + // quest.atmo.event record to the admin's PDS, and caches the resulting event 211 + // locally with its QR token. 212 + func (h *Handlers) AdminEventCreate(w http.ResponseWriter, r *http.Request) { 213 + _, viewerSess, ok := h.Auth.RequireSession(w, r) 214 + if !ok { 215 + return 216 + } 217 + in, formView, perr := parseEventForm(r) 218 + if perr != "" { 219 + formView.Error = perr 220 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 221 + w.WriteHeader(http.StatusBadRequest) 222 + _ = pages.AdminEventNew(formView).Render(r.Context(), w) 223 + return 224 + } 225 + 226 + _, qr, err := event.Put(r.Context(), viewerSess, h.DB, in) 227 + if err != nil { 228 + slog.Error("admin event create", "err", err) 229 + formView.Error = "failed to create event: " + err.Error() 230 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 231 + w.WriteHeader(http.StatusBadGateway) 232 + _ = pages.AdminEventNew(formView).Render(r.Context(), w) 233 + return 234 + } 235 + // Land the admin on the badge designer next — most events want a badge 236 + // and this keeps the flow obvious. 237 + http.Redirect(w, r, "/admin/events/"+qr+"/badge", http.StatusSeeOther) 238 + } 239 + 240 + // AdminEventBadge renders the badge designer for an event, pre-loaded with 241 + // any existing design. 242 + func (h *Handlers) AdminEventBadge(w http.ResponseWriter, r *http.Request) { 243 + token := chi.URLParam(r, "token") 244 + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) 245 + if err != nil { 246 + http.NotFound(w, r) 247 + return 248 + } 249 + 250 + view := pages.AdminEventBadgeView{ 251 + EventURI: ev.URI, 252 + EventName: ev.Name, 253 + Token: token, 254 + Shapes: badge.DefaultShapes, 255 + Primary: badge.DefaultPrimaryColors, 256 + Accent: badge.DefaultAccentColors, 257 + Ribbon: badge.DefaultRibbonColors, 258 + Design: pages.AdminBadgeDesign{ 259 + Shape: "shield", 260 + PrimaryColor: badge.DefaultPrimaryColors[0], 261 + AccentColor: badge.DefaultAccentColors[0], 262 + RibbonColor: badge.DefaultRibbonColors[0], 263 + Label: ev.Name, 264 + }, 265 + QRPublicURL: strings.TrimRight(config.Global.PublicURL, "/") + "/e/" + token, 266 + } 267 + 268 + if h.Signer != nil { 269 + if d, err := badge.Get(r.Context(), h.DB, h.Signer, ev.URI); err == nil { 270 + view.Design = pages.AdminBadgeDesign{ 271 + Shape: d.Shape, 272 + PrimaryColor: d.PrimaryColor, 273 + AccentColor: d.AccentColor, 274 + RibbonColor: d.RibbonColor, 275 + Label: d.Label, 276 + Signature: d.Signature, 277 + SigningKeyID: d.SigningKeyID, 278 + } 279 + view.SVGPreview = badge.RenderSVG(d, 200) 280 + } else if !errors.Is(err, badge.ErrNotFound) { 281 + slog.Warn("admin badge: get", "event_uri", ev.URI, "err", err) 282 + } 283 + } 284 + 285 + // If no preview yet, render the default-design preview so the page 286 + // always shows something. 287 + if view.SVGPreview == "" { 288 + view.SVGPreview = badge.RenderSVG(badge.Design{ 289 + Shape: view.Design.Shape, 290 + PrimaryColor: view.Design.PrimaryColor, 291 + AccentColor: view.Design.AccentColor, 292 + RibbonColor: view.Design.RibbonColor, 293 + Label: view.Design.Label, 294 + }, 200) 295 + } 296 + 297 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 298 + if err := pages.AdminEventBadge(view).Render(r.Context(), w); err != nil { 299 + slog.Error("render admin badge", "err", err) 300 + } 301 + } 302 + 303 + // AdminEventBadgeSave handles POST /admin/events/{token}/badge. 304 + func (h *Handlers) AdminEventBadgeSave(w http.ResponseWriter, r *http.Request) { 305 + if h.Signer == nil { 306 + http.Error(w, "admin signing key not configured on the server", http.StatusServiceUnavailable) 307 + return 308 + } 309 + token := chi.URLParam(r, "token") 310 + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) 311 + if err != nil { 312 + http.NotFound(w, r) 313 + return 314 + } 315 + 316 + d := badge.Design{ 317 + EventURI: ev.URI, 318 + Shape: r.FormValue("shape"), 319 + PrimaryColor: r.FormValue("primary_color"), 320 + AccentColor: r.FormValue("accent_color"), 321 + RibbonColor: r.FormValue("ribbon_color"), 322 + Label: strings.TrimSpace(r.FormValue("label")), 323 + } 324 + if _, err := badge.Save(r.Context(), h.DB, h.Signer, d); err != nil { 325 + slog.Warn("admin badge save", "err", err) 326 + http.Error(w, err.Error(), http.StatusBadRequest) 327 + return 328 + } 329 + http.Redirect(w, r, "/admin/events/"+token+"/badge?saved=1", http.StatusSeeOther) 330 + } 331 + 332 + // parseEventForm pulls form values into a CreateInput, returning a 333 + // pre-populated formView for re-rendering on error. 334 + func parseEventForm(r *http.Request) (event.CreateInput, pages.AdminEventFormView, string) { 335 + in := event.CreateInput{ 336 + Name: strings.TrimSpace(r.FormValue("name")), 337 + Location: strings.TrimSpace(r.FormValue("location")), 338 + } 339 + if n, err := strconv.Atoi(r.FormValue("expected_attendees")); err == nil { 340 + in.ExpectedAttendees = n 341 + } 342 + // HTML datetime-local inputs come through as "2006-01-02T15:04" in the 343 + // browser's local TZ. We parse as local time and convert to UTC in 344 + // event.Put. Callers running the server in a TZ other than the event's 345 + // local TZ will see times slightly off — for v1, that's fine; the 346 + // conference admins running this will be co-located. 347 + startStr := r.FormValue("start_time") 348 + endStr := r.FormValue("end_time") 349 + startT, errS := time.ParseInLocation("2006-01-02T15:04", startStr, time.Local) 350 + endT, errE := time.ParseInLocation("2006-01-02T15:04", endStr, time.Local) 351 + 352 + view := pages.AdminEventFormView{ 353 + Name: in.Name, 354 + Location: in.Location, 355 + StartTime: startStr, 356 + EndTime: endStr, 357 + ExpectedAttendees: in.ExpectedAttendees, 358 + } 359 + 360 + if in.Name == "" { 361 + return in, view, "event name is required" 362 + } 363 + if errS != nil || errE != nil { 364 + return in, view, "start and end times are required (use the date/time pickers)" 365 + } 366 + if endT.Before(startT) { 367 + return in, view, "end time must be after start time" 368 + } 369 + in.StartTime = startT 370 + in.EndTime = endT 371 + return in, view, "" 372 + } 373 + 374 + // toAdminEventRows projects []event.AdminEvent into the view type. We 375 + // pre-format times here so the template stays dumb. 376 + func toAdminEventRows(list []event.AdminEvent, publicURL string) []pages.AdminEventRow { 377 + publicURL = strings.TrimRight(publicURL, "/") 378 + out := make([]pages.AdminEventRow, 0, len(list)) 379 + for _, ev := range list { 380 + out = append(out, pages.AdminEventRow{ 381 + URI: ev.URI, 382 + Name: ev.Name, 383 + Location: ev.Location, 384 + StartTime: ev.StartTime.Format("Mon Jan 2 · 3:04 PM"), 385 + EndTime: ev.EndTime.Format("Mon Jan 2 · 3:04 PM"), 386 + ExpectedAttendees: ev.ExpectedAttendees, 387 + QRToken: ev.QRToken, 388 + QRPublicURL: publicURL + "/e/" + ev.QRToken, 389 + }) 390 + } 391 + return out 392 + } 393 + 394 + // toAdminUsersRows projects users.User into the view-layer row type. We do 395 + // this in the handler so the template stays decoupled from the storage 396 + // struct (which has a syntax.DID, not a string). 397 + func toAdminUsersRows(list []users.User) []pages.AdminUserRow { 398 + out := make([]pages.AdminUserRow, 0, len(list)) 399 + for _, u := range list { 400 + out = append(out, pages.AdminUserRow{ 401 + DID: u.DID.String(), 402 + Handle: u.Handle, 403 + DisplayName: u.DisplayName, 404 + AuthCount: u.AuthCount, 405 + FirstSeen: u.FirstSeenAt.Format("2006-01-02"), 406 + LastSeen: u.LastSeenAt.Format("2006-01-02 15:04"), 407 + IsAdmin: u.IsAdmin, 408 + IsBanned: u.IsBanned, 409 + }) 410 + } 411 + return out 412 + } 413 + 414 + // currentAdminHandle returns a short label for whichever admin is viewing 415 + // the dashboard. Used purely for cosmetics ("hi, @you"). 416 + func (h *Handlers) currentAdminHandle(r *http.Request) string { 417 + didStr, _ := h.Auth.Sessions.Get(r) 418 + if didStr == "" { 419 + return "" 420 + } 421 + if did, err := syntax.ParseDID(didStr); err == nil { 422 + if u, err := users.Get(r.Context(), h.DB, did); err == nil { 423 + if u.Handle != "" { 424 + return u.Handle 425 + } 426 + } 427 + } 428 + return didStr 429 + } 430 + 431 + // signerKeyID / signerPubKey are small nil-safe helpers so callers can 432 + // render the signer fingerprint without nil-checking everywhere. 433 + func signerKeyID(s *admincrypto.Signer) string { 434 + if s == nil { 435 + return "" 436 + } 437 + return s.KeyID 438 + } 439 + 440 + func signerPubKey(s *admincrypto.Signer) string { 441 + if s == nil { 442 + return "" 443 + } 444 + return s.PublicKeyBase64() 445 + } 446 + 447 + // JSONResponse is a tiny helper for any future JSON admin endpoints. 448 + func writeJSON(w http.ResponseWriter, v any) { 449 + w.Header().Set("Content-Type", "application/json") 450 + _ = json.NewEncoder(w).Encode(v) 451 + } 452 + 453 + var _ = writeJSON // keep available for upcoming admin JSON endpoints
+136
features/admin/pages/dashboard.templ
··· 1 + package pages 2 + 3 + import "atmoquest/features/common/layouts" 4 + 5 + // AdminDashboardView feeds the admin landing page. 6 + type AdminDashboardView struct { 7 + TotalUsers int 8 + AdminCount int 9 + BannedCount int 10 + ActiveLast7d int 11 + EventCount int 12 + CheckinCount int 13 + // SignerKeyID + SignerPubKey are shown on the admin dashboard so the 14 + // admin can verify which signing key the server is using (useful 15 + // for key rotation later). Empty when no signer is configured. 16 + SignerKeyID string 17 + SignerPubKey string 18 + CurrentHandle string 19 + } 20 + 21 + templ AdminDashboard(v AdminDashboardView) { 22 + @layouts.Base("admin — atmo.quest", "atmo.quest admin console.") { 23 + @adminShell("dashboard", v.CurrentHandle) { 24 + <div class="admin-stats-grid"> 25 + @statTile("users", intStr(v.TotalUsers), "total signed in") 26 + @statTile("admins", intStr(v.AdminCount), "elevated") 27 + @statTile("active 7d", intStr(v.ActiveLast7d), "last week") 28 + @statTile("events", intStr(v.EventCount), "all-time") 29 + @statTile("checkins", intStr(v.CheckinCount), "all-time") 30 + @statTile("banned", intStr(v.BannedCount), "users") 31 + </div> 32 + 33 + if v.SignerPubKey != "" { 34 + <aside class="admin-signer-card"> 35 + <div class="admin-signer-label">badge signing key</div> 36 + <div class="admin-signer-id">key id: <code>{ v.SignerKeyID }</code></div> 37 + <details> 38 + <summary>public key (ed25519, base64)</summary> 39 + <code class="break admin-signer-pub">{ v.SignerPubKey }</code> 40 + </details> 41 + </aside> 42 + } 43 + } 44 + } 45 + } 46 + 47 + // statTile is a tiny stat box. 48 + templ statTile(label, value, sub string) { 49 + <div class="admin-stat-tile"> 50 + <div class="admin-stat-label">{ label }</div> 51 + <div class="admin-stat-value">{ value }</div> 52 + <div class="admin-stat-sub">{ sub }</div> 53 + </div> 54 + } 55 + 56 + // adminShell wraps every admin page with a consistent window chrome + nav. 57 + // The `active` param highlights the current section. 58 + templ adminShell(active, currentHandle string) { 59 + <div class="window"> 60 + <div class="window-bar"> 61 + <div class="dot dot-r"></div> 62 + <div class="dot dot-y"></div> 63 + <div class="dot dot-g"></div> 64 + <div class="window-title">~/atmo.quest — admin</div> 65 + </div> 66 + <div class="window-body"> 67 + <div class="prompt-line"> 68 + <span class="user">admin</span><span class="at">{ "@" }</span><span class="path">atmo.quest</span><span class="sep">:~$</span> 69 + <span class="cmd">manage --section { active }</span> 70 + </div> 71 + 72 + <nav class="admin-nav"> 73 + @adminNavLink("/admin", "dashboard", active == "dashboard") 74 + @adminNavLink("/admin/users", "users", active == "users") 75 + @adminNavLink("/admin/events", "events", active == "events") 76 + <a class="admin-nav-tail" href="/">← back to app</a> 77 + </nav> 78 + 79 + { children... } 80 + </div> 81 + <div class="statusbar"> 82 + <div> 83 + <span class="ok">●</span> admin console · signed in as 84 + if currentHandle != "" { 85 + <a href="/profile" class="status-handle">{ "@" + currentHandle }</a> 86 + } else { 87 + <span class="muted">(no handle)</span> 88 + } 89 + </div> 90 + <div class="muted">v0.2</div> 91 + </div> 92 + </div> 93 + <footer> 94 + atmo.quest admin · everything here is logged 95 + </footer> 96 + } 97 + 98 + templ adminNavLink(href, label string, active bool) { 99 + if active { 100 + <a class="admin-nav-link is-active" href={ templ.SafeURL(href) }>▸ { label }</a> 101 + } else { 102 + <a class="admin-nav-link" href={ templ.SafeURL(href) }>{ label }</a> 103 + } 104 + } 105 + 106 + // intStr is a tiny inline itoa to avoid pulling strconv into the template 107 + // package. 108 + func intStr(n int) string { 109 + if n == 0 { 110 + return "0" 111 + } 112 + var buf [20]byte 113 + i := len(buf) 114 + neg := n < 0 115 + if neg { 116 + n = -n 117 + } 118 + for n > 0 { 119 + i-- 120 + buf[i] = byte('0' + n%10) 121 + n /= 10 122 + } 123 + if neg { 124 + i-- 125 + buf[i] = '-' 126 + } 127 + return string(buf[i:]) 128 + } 129 + 130 + // shortDID renders the last 8 chars of a DID for compact UI rows. 131 + func shortDID(did string) string { 132 + if len(did) <= 12 { 133 + return did 134 + } 135 + return did[:8] + "…" + did[len(did)-6:] 136 + }
+452
features/admin/pages/dashboard_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.1020 4 + package pages 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import "atmoquest/features/common/layouts" 12 + 13 + // AdminDashboardView feeds the admin landing page. 14 + type AdminDashboardView struct { 15 + TotalUsers int 16 + AdminCount int 17 + BannedCount int 18 + ActiveLast7d int 19 + EventCount int 20 + CheckinCount int 21 + // SignerKeyID + SignerPubKey are shown on the admin dashboard so the 22 + // admin can verify which signing key the server is using (useful 23 + // for key rotation later). Empty when no signer is configured. 24 + SignerKeyID string 25 + SignerPubKey string 26 + CurrentHandle string 27 + } 28 + 29 + func AdminDashboard(v AdminDashboardView) templ.Component { 30 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 31 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 32 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 33 + return templ_7745c5c3_CtxErr 34 + } 35 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 36 + if !templ_7745c5c3_IsBuffer { 37 + defer func() { 38 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 39 + if templ_7745c5c3_Err == nil { 40 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 41 + } 42 + }() 43 + } 44 + ctx = templ.InitializeContext(ctx) 45 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 46 + if templ_7745c5c3_Var1 == nil { 47 + templ_7745c5c3_Var1 = templ.NopComponent 48 + } 49 + ctx = templ.ClearChildren(ctx) 50 + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 51 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 52 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 53 + if !templ_7745c5c3_IsBuffer { 54 + defer func() { 55 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 56 + if templ_7745c5c3_Err == nil { 57 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 58 + } 59 + }() 60 + } 61 + ctx = templ.InitializeContext(ctx) 62 + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 63 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 64 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 65 + if !templ_7745c5c3_IsBuffer { 66 + defer func() { 67 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 68 + if templ_7745c5c3_Err == nil { 69 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 70 + } 71 + }() 72 + } 73 + ctx = templ.InitializeContext(ctx) 74 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"admin-stats-grid\">") 75 + if templ_7745c5c3_Err != nil { 76 + return templ_7745c5c3_Err 77 + } 78 + templ_7745c5c3_Err = statTile("users", intStr(v.TotalUsers), "total signed in").Render(ctx, templ_7745c5c3_Buffer) 79 + if templ_7745c5c3_Err != nil { 80 + return templ_7745c5c3_Err 81 + } 82 + templ_7745c5c3_Err = statTile("admins", intStr(v.AdminCount), "elevated").Render(ctx, templ_7745c5c3_Buffer) 83 + if templ_7745c5c3_Err != nil { 84 + return templ_7745c5c3_Err 85 + } 86 + templ_7745c5c3_Err = statTile("active 7d", intStr(v.ActiveLast7d), "last week").Render(ctx, templ_7745c5c3_Buffer) 87 + if templ_7745c5c3_Err != nil { 88 + return templ_7745c5c3_Err 89 + } 90 + templ_7745c5c3_Err = statTile("events", intStr(v.EventCount), "all-time").Render(ctx, templ_7745c5c3_Buffer) 91 + if templ_7745c5c3_Err != nil { 92 + return templ_7745c5c3_Err 93 + } 94 + templ_7745c5c3_Err = statTile("checkins", intStr(v.CheckinCount), "all-time").Render(ctx, templ_7745c5c3_Buffer) 95 + if templ_7745c5c3_Err != nil { 96 + return templ_7745c5c3_Err 97 + } 98 + templ_7745c5c3_Err = statTile("banned", intStr(v.BannedCount), "users").Render(ctx, templ_7745c5c3_Buffer) 99 + if templ_7745c5c3_Err != nil { 100 + return templ_7745c5c3_Err 101 + } 102 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>") 103 + if templ_7745c5c3_Err != nil { 104 + return templ_7745c5c3_Err 105 + } 106 + if v.SignerPubKey != "" { 107 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<aside class=\"admin-signer-card\"><div class=\"admin-signer-label\">badge signing key</div><div class=\"admin-signer-id\">key id: <code>") 108 + if templ_7745c5c3_Err != nil { 109 + return templ_7745c5c3_Err 110 + } 111 + var templ_7745c5c3_Var4 string 112 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.SignerKeyID) 113 + if templ_7745c5c3_Err != nil { 114 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 36, Col: 63} 115 + } 116 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 117 + if templ_7745c5c3_Err != nil { 118 + return templ_7745c5c3_Err 119 + } 120 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</code></div><details><summary>public key (ed25519, base64)</summary> <code class=\"break admin-signer-pub\">") 121 + if templ_7745c5c3_Err != nil { 122 + return templ_7745c5c3_Err 123 + } 124 + var templ_7745c5c3_Var5 string 125 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.SignerPubKey) 126 + if templ_7745c5c3_Err != nil { 127 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 39, Col: 59} 128 + } 129 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 130 + if templ_7745c5c3_Err != nil { 131 + return templ_7745c5c3_Err 132 + } 133 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></details></aside>") 134 + if templ_7745c5c3_Err != nil { 135 + return templ_7745c5c3_Err 136 + } 137 + } 138 + return nil 139 + }) 140 + templ_7745c5c3_Err = adminShell("dashboard", v.CurrentHandle).Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) 141 + if templ_7745c5c3_Err != nil { 142 + return templ_7745c5c3_Err 143 + } 144 + return nil 145 + }) 146 + templ_7745c5c3_Err = layouts.Base("admin — atmo.quest", "atmo.quest admin console.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 147 + if templ_7745c5c3_Err != nil { 148 + return templ_7745c5c3_Err 149 + } 150 + return nil 151 + }) 152 + } 153 + 154 + // statTile is a tiny stat box. 155 + func statTile(label, value, sub string) templ.Component { 156 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 157 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 158 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 159 + return templ_7745c5c3_CtxErr 160 + } 161 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 162 + if !templ_7745c5c3_IsBuffer { 163 + defer func() { 164 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 165 + if templ_7745c5c3_Err == nil { 166 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 167 + } 168 + }() 169 + } 170 + ctx = templ.InitializeContext(ctx) 171 + templ_7745c5c3_Var6 := templ.GetChildren(ctx) 172 + if templ_7745c5c3_Var6 == nil { 173 + templ_7745c5c3_Var6 = templ.NopComponent 174 + } 175 + ctx = templ.ClearChildren(ctx) 176 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"admin-stat-tile\"><div class=\"admin-stat-label\">") 177 + if templ_7745c5c3_Err != nil { 178 + return templ_7745c5c3_Err 179 + } 180 + var templ_7745c5c3_Var7 string 181 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(label) 182 + if templ_7745c5c3_Err != nil { 183 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 50, Col: 39} 184 + } 185 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 186 + if templ_7745c5c3_Err != nil { 187 + return templ_7745c5c3_Err 188 + } 189 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"admin-stat-value\">") 190 + if templ_7745c5c3_Err != nil { 191 + return templ_7745c5c3_Err 192 + } 193 + var templ_7745c5c3_Var8 string 194 + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(value) 195 + if templ_7745c5c3_Err != nil { 196 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 51, Col: 39} 197 + } 198 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 199 + if templ_7745c5c3_Err != nil { 200 + return templ_7745c5c3_Err 201 + } 202 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><div class=\"admin-stat-sub\">") 203 + if templ_7745c5c3_Err != nil { 204 + return templ_7745c5c3_Err 205 + } 206 + var templ_7745c5c3_Var9 string 207 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(sub) 208 + if templ_7745c5c3_Err != nil { 209 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 52, Col: 35} 210 + } 211 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 212 + if templ_7745c5c3_Err != nil { 213 + return templ_7745c5c3_Err 214 + } 215 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></div>") 216 + if templ_7745c5c3_Err != nil { 217 + return templ_7745c5c3_Err 218 + } 219 + return nil 220 + }) 221 + } 222 + 223 + // adminShell wraps every admin page with a consistent window chrome + nav. 224 + // The `active` param highlights the current section. 225 + func adminShell(active, currentHandle string) templ.Component { 226 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 227 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 228 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 229 + return templ_7745c5c3_CtxErr 230 + } 231 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 232 + if !templ_7745c5c3_IsBuffer { 233 + defer func() { 234 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 235 + if templ_7745c5c3_Err == nil { 236 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 237 + } 238 + }() 239 + } 240 + ctx = templ.InitializeContext(ctx) 241 + templ_7745c5c3_Var10 := templ.GetChildren(ctx) 242 + if templ_7745c5c3_Var10 == nil { 243 + templ_7745c5c3_Var10 = templ.NopComponent 244 + } 245 + ctx = templ.ClearChildren(ctx) 246 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"window\"><div class=\"window-bar\"><div class=\"dot dot-r\"></div><div class=\"dot dot-y\"></div><div class=\"dot dot-g\"></div><div class=\"window-title\">~/atmo.quest — admin</div></div><div class=\"window-body\"><div class=\"prompt-line\"><span class=\"user\">admin</span><span class=\"at\">") 247 + if templ_7745c5c3_Err != nil { 248 + return templ_7745c5c3_Err 249 + } 250 + var templ_7745c5c3_Var11 string 251 + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("@") 252 + if templ_7745c5c3_Err != nil { 253 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 68, Col: 57} 254 + } 255 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 256 + if templ_7745c5c3_Err != nil { 257 + return templ_7745c5c3_Err 258 + } 259 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">manage --section ") 260 + if templ_7745c5c3_Err != nil { 261 + return templ_7745c5c3_Err 262 + } 263 + var templ_7745c5c3_Var12 string 264 + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(active) 265 + if templ_7745c5c3_Err != nil { 266 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 69, Col: 47} 267 + } 268 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) 269 + if templ_7745c5c3_Err != nil { 270 + return templ_7745c5c3_Err 271 + } 272 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span></div><nav class=\"admin-nav\">") 273 + if templ_7745c5c3_Err != nil { 274 + return templ_7745c5c3_Err 275 + } 276 + templ_7745c5c3_Err = adminNavLink("/admin", "dashboard", active == "dashboard").Render(ctx, templ_7745c5c3_Buffer) 277 + if templ_7745c5c3_Err != nil { 278 + return templ_7745c5c3_Err 279 + } 280 + templ_7745c5c3_Err = adminNavLink("/admin/users", "users", active == "users").Render(ctx, templ_7745c5c3_Buffer) 281 + if templ_7745c5c3_Err != nil { 282 + return templ_7745c5c3_Err 283 + } 284 + templ_7745c5c3_Err = adminNavLink("/admin/events", "events", active == "events").Render(ctx, templ_7745c5c3_Buffer) 285 + if templ_7745c5c3_Err != nil { 286 + return templ_7745c5c3_Err 287 + } 288 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a class=\"admin-nav-tail\" href=\"/\">← back to app</a></nav>") 289 + if templ_7745c5c3_Err != nil { 290 + return templ_7745c5c3_Err 291 + } 292 + templ_7745c5c3_Err = templ_7745c5c3_Var10.Render(ctx, templ_7745c5c3_Buffer) 293 + if templ_7745c5c3_Err != nil { 294 + return templ_7745c5c3_Err 295 + } 296 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"statusbar\"><div><span class=\"ok\">●</span> admin console · signed in as ") 297 + if templ_7745c5c3_Err != nil { 298 + return templ_7745c5c3_Err 299 + } 300 + if currentHandle != "" { 301 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"/profile\" class=\"status-handle\">") 302 + if templ_7745c5c3_Err != nil { 303 + return templ_7745c5c3_Err 304 + } 305 + var templ_7745c5c3_Var13 string 306 + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("@" + currentHandle) 307 + if templ_7745c5c3_Err != nil { 308 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 85, Col: 67} 309 + } 310 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 311 + if templ_7745c5c3_Err != nil { 312 + return templ_7745c5c3_Err 313 + } 314 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a>") 315 + if templ_7745c5c3_Err != nil { 316 + return templ_7745c5c3_Err 317 + } 318 + } else { 319 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"muted\">(no handle)</span>") 320 + if templ_7745c5c3_Err != nil { 321 + return templ_7745c5c3_Err 322 + } 323 + } 324 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"muted\">v0.2</div></div></div><footer>atmo.quest admin · everything here is logged</footer>") 325 + if templ_7745c5c3_Err != nil { 326 + return templ_7745c5c3_Err 327 + } 328 + return nil 329 + }) 330 + } 331 + 332 + func adminNavLink(href, label string, active bool) templ.Component { 333 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 334 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 335 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 336 + return templ_7745c5c3_CtxErr 337 + } 338 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 339 + if !templ_7745c5c3_IsBuffer { 340 + defer func() { 341 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 342 + if templ_7745c5c3_Err == nil { 343 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 344 + } 345 + }() 346 + } 347 + ctx = templ.InitializeContext(ctx) 348 + templ_7745c5c3_Var14 := templ.GetChildren(ctx) 349 + if templ_7745c5c3_Var14 == nil { 350 + templ_7745c5c3_Var14 = templ.NopComponent 351 + } 352 + ctx = templ.ClearChildren(ctx) 353 + if active { 354 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a class=\"admin-nav-link is-active\" href=\"") 355 + if templ_7745c5c3_Err != nil { 356 + return templ_7745c5c3_Err 357 + } 358 + var templ_7745c5c3_Var15 templ.SafeURL 359 + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) 360 + if templ_7745c5c3_Err != nil { 361 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 100, Col: 64} 362 + } 363 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 364 + if templ_7745c5c3_Err != nil { 365 + return templ_7745c5c3_Err 366 + } 367 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">▸ ") 368 + if templ_7745c5c3_Err != nil { 369 + return templ_7745c5c3_Err 370 + } 371 + var templ_7745c5c3_Var16 string 372 + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(label) 373 + if templ_7745c5c3_Err != nil { 374 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 100, Col: 78} 375 + } 376 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) 377 + if templ_7745c5c3_Err != nil { 378 + return templ_7745c5c3_Err 379 + } 380 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a>") 381 + if templ_7745c5c3_Err != nil { 382 + return templ_7745c5c3_Err 383 + } 384 + } else { 385 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"admin-nav-link\" href=\"") 386 + if templ_7745c5c3_Err != nil { 387 + return templ_7745c5c3_Err 388 + } 389 + var templ_7745c5c3_Var17 templ.SafeURL 390 + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) 391 + if templ_7745c5c3_Err != nil { 392 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 102, Col: 54} 393 + } 394 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) 395 + if templ_7745c5c3_Err != nil { 396 + return templ_7745c5c3_Err 397 + } 398 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">") 399 + if templ_7745c5c3_Err != nil { 400 + return templ_7745c5c3_Err 401 + } 402 + var templ_7745c5c3_Var18 string 403 + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(label) 404 + if templ_7745c5c3_Err != nil { 405 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 102, Col: 64} 406 + } 407 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) 408 + if templ_7745c5c3_Err != nil { 409 + return templ_7745c5c3_Err 410 + } 411 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</a>") 412 + if templ_7745c5c3_Err != nil { 413 + return templ_7745c5c3_Err 414 + } 415 + } 416 + return nil 417 + }) 418 + } 419 + 420 + // intStr is a tiny inline itoa to avoid pulling strconv into the template 421 + // package. 422 + func intStr(n int) string { 423 + if n == 0 { 424 + return "0" 425 + } 426 + var buf [20]byte 427 + i := len(buf) 428 + neg := n < 0 429 + if neg { 430 + n = -n 431 + } 432 + for n > 0 { 433 + i-- 434 + buf[i] = byte('0' + n%10) 435 + n /= 10 436 + } 437 + if neg { 438 + i-- 439 + buf[i] = '-' 440 + } 441 + return string(buf[i:]) 442 + } 443 + 444 + // shortDID renders the last 8 chars of a DID for compact UI rows. 445 + func shortDID(did string) string { 446 + if len(did) <= 12 { 447 + return did 448 + } 449 + return did[:8] + "…" + did[len(did)-6:] 450 + } 451 + 452 + var _ = templruntime.GeneratedTemplate
+228
features/admin/pages/events.templ
··· 1 + package pages 2 + 3 + import "atmoquest/features/common/layouts" 4 + 5 + // AdminEventsView is the data for /admin/events. 6 + type AdminEventsView struct { 7 + PublicURL string 8 + Events []AdminEventRow 9 + } 10 + 11 + // AdminEventRow is a single row in the admin event list. 12 + type AdminEventRow struct { 13 + URI string 14 + Name string 15 + Location string 16 + StartTime string 17 + EndTime string 18 + ExpectedAttendees int 19 + QRToken string 20 + QRPublicURL string 21 + } 22 + 23 + templ AdminEvents(v AdminEventsView) { 24 + @layouts.Base("admin · events — atmo.quest", "Manage atmo.quest events.") { 25 + @adminShell("events", "") { 26 + <div class="admin-events-toolbar"> 27 + <a href="/admin/events/new" class="btn btn-primary btn-sm">▸ new event</a> 28 + </div> 29 + 30 + if len(v.Events) == 0 { 31 + <p class="admin-empty">no events yet. <a href="/admin/events/new">create the first one</a>.</p> 32 + } else { 33 + <div class="admin-table-wrap"> 34 + <table class="admin-table"> 35 + <thead> 36 + <tr> 37 + <th>name</th> 38 + <th>location</th> 39 + <th>start → end</th> 40 + <th class="num">expected</th> 41 + <th>QR url</th> 42 + <th></th> 43 + </tr> 44 + </thead> 45 + <tbody> 46 + for _, ev := range v.Events { 47 + <tr> 48 + <td><strong>{ ev.Name }</strong></td> 49 + <td> 50 + if ev.Location != "" { 51 + { ev.Location } 52 + } else { 53 + <span class="muted">—</span> 54 + } 55 + </td> 56 + <td class="nowrap muted">{ ev.StartTime } &nbsp;→&nbsp; { ev.EndTime }</td> 57 + <td class="num">{ intStr(ev.ExpectedAttendees) }</td> 58 + <td><a class="break" href={ templ.SafeURL(ev.QRPublicURL) }>{ ev.QRPublicURL }</a></td> 59 + <td class="nowrap"> 60 + <a class="btn btn-ghost btn-sm" href={ templ.SafeURL("/admin/events/" + ev.QRToken + "/badge") }>badge →</a> 61 + </td> 62 + </tr> 63 + } 64 + </tbody> 65 + </table> 66 + </div> 67 + } 68 + } 69 + } 70 + } 71 + 72 + // AdminEventFormView is the data for /admin/events/new (also re-used on 73 + // validation-error re-renders). 74 + type AdminEventFormView struct { 75 + Name string 76 + Location string 77 + StartTime string // datetime-local "2006-01-02T15:04" 78 + EndTime string 79 + ExpectedAttendees int 80 + Error string 81 + } 82 + 83 + templ AdminEventNew(v AdminEventFormView) { 84 + @layouts.Base("admin · new event — atmo.quest", "Create a new atmo.quest event.") { 85 + @adminShell("events", "") { 86 + <h2 class="admin-section-title">create event</h2> 87 + 88 + if v.Error != "" { 89 + <div class="auth-error" role="alert"> 90 + <span class="auth-error-prefix">heads up:</span> { v.Error } 91 + </div> 92 + } 93 + 94 + <form class="admin-form" method="post" action="/admin/events"> 95 + <div class="admin-form-row"> 96 + <label for="event-name">name</label> 97 + <input id="event-name" name="name" type="text" required maxlength="120" value={ v.Name } placeholder="e.g. CascadiaJS 2026"/> 98 + </div> 99 + <div class="admin-form-row"> 100 + <label for="event-location">location</label> 101 + <input id="event-location" name="location" type="text" maxlength="160" value={ v.Location } placeholder="e.g. Portland Convention Center"/> 102 + </div> 103 + <div class="admin-form-row-pair"> 104 + <div class="admin-form-row"> 105 + <label for="event-start">start</label> 106 + <input id="event-start" name="start_time" type="datetime-local" required value={ v.StartTime }/> 107 + </div> 108 + <div class="admin-form-row"> 109 + <label for="event-end">end</label> 110 + <input id="event-end" name="end_time" type="datetime-local" required value={ v.EndTime }/> 111 + </div> 112 + </div> 113 + <div class="admin-form-row"> 114 + <label for="event-expected">expected attendees</label> 115 + <input id="event-expected" name="expected_attendees" type="number" min="0" value={ intStr(v.ExpectedAttendees) }/> 116 + </div> 117 + <div class="ctas"> 118 + <button type="submit" class="btn btn-primary">▸ create event</button> 119 + <a href="/admin/events" class="btn btn-ghost">cancel</a> 120 + </div> 121 + <p class="profile-edit-note"> 122 + creating an event writes a <span class="kw2">quest.atmo.event</span> record to your PDS. 123 + you'll be taken to the badge designer next. 124 + </p> 125 + </form> 126 + } 127 + } 128 + } 129 + 130 + // AdminEventBadgeView feeds the badge designer page. 131 + type AdminEventBadgeView struct { 132 + EventURI string 133 + EventName string 134 + Token string 135 + Shapes []string 136 + Primary []string 137 + Accent []string 138 + Ribbon []string 139 + Design AdminBadgeDesign 140 + SVGPreview string 141 + QRPublicURL string 142 + } 143 + 144 + // AdminBadgeDesign is the current state of the badge being designed. 145 + type AdminBadgeDesign struct { 146 + Shape string 147 + PrimaryColor string 148 + AccentColor string 149 + RibbonColor string 150 + Label string 151 + Signature string 152 + SigningKeyID string 153 + } 154 + 155 + templ AdminEventBadge(v AdminEventBadgeView) { 156 + @layouts.Base("admin · badge designer — atmo.quest", "Design an event badge.") { 157 + @adminShell("events", "") { 158 + <h2 class="admin-section-title">badge designer — { v.EventName }</h2> 159 + 160 + <div class="admin-badge-layout"> 161 + <div class="admin-badge-preview"> 162 + @templ.Raw(v.SVGPreview) 163 + <p class="admin-badge-uri muted break"> 164 + <small>{ v.EventURI }</small> 165 + </p> 166 + if v.Design.Signature != "" { 167 + <p class="admin-badge-signature"> 168 + <span class="ok">●</span> signed (key { v.Design.SigningKeyID }) 169 + </p> 170 + } 171 + </div> 172 + 173 + <form class="admin-form admin-badge-form" method="post" action={ templ.SafeURL("/admin/events/" + v.Token + "/badge") }> 174 + <div class="admin-form-row"> 175 + <label for="badge-shape">shape</label> 176 + <select id="badge-shape" name="shape" required> 177 + for _, s := range v.Shapes { 178 + if s == v.Design.Shape { 179 + <option value={ s } selected>{ s }</option> 180 + } else { 181 + <option value={ s }>{ s }</option> 182 + } 183 + } 184 + </select> 185 + </div> 186 + @colorSwatchField("primary_color", "primary color", v.Primary, v.Design.PrimaryColor) 187 + @colorSwatchField("accent_color", "accent color", v.Accent, v.Design.AccentColor) 188 + @colorSwatchField("ribbon_color", "ribbon color", v.Ribbon, v.Design.RibbonColor) 189 + <div class="admin-form-row"> 190 + <label for="badge-label">label</label> 191 + <input id="badge-label" name="label" type="text" required maxlength="40" value={ v.Design.Label }/> 192 + </div> 193 + <div class="ctas"> 194 + <button type="submit" class="btn btn-primary">▸ save badge</button> 195 + <a href={ templ.SafeURL("/e/" + v.Token + "/qr.svg") } class="btn btn-ghost">download QR</a> 196 + <a href="/admin/events" class="btn btn-ghost">← all events</a> 197 + </div> 198 + <p class="profile-edit-note"> 199 + public scan URL: <a class="break" href={ templ.SafeURL(v.QRPublicURL) }>{ v.QRPublicURL }</a><br/> 200 + saving signs this design with the server's admin key (ed25519). 201 + tampering with the row in the database will fail signature 202 + verification on next read. 203 + </p> 204 + </form> 205 + </div> 206 + } 207 + } 208 + } 209 + 210 + // colorSwatchField is a radio-group with visible color swatches. 211 + templ colorSwatchField(name, label string, options []string, selected string) { 212 + <div class="admin-form-row"> 213 + <label>{ label }</label> 214 + <div class="badge-swatches"> 215 + for _, c := range options { 216 + <label class="badge-swatch"> 217 + if c == selected { 218 + <input type="radio" name={ name } value={ c } checked/> 219 + } else { 220 + <input type="radio" name={ name } value={ c }/> 221 + } 222 + <span class="badge-swatch-chip" style={ "background:" + c }></span> 223 + <span class="badge-swatch-hex">{ c }</span> 224 + </label> 225 + } 226 + </div> 227 + </div> 228 + }
+835
features/admin/pages/events_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.1020 4 + package pages 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import "atmoquest/features/common/layouts" 12 + 13 + // AdminEventsView is the data for /admin/events. 14 + type AdminEventsView struct { 15 + PublicURL string 16 + Events []AdminEventRow 17 + } 18 + 19 + // AdminEventRow is a single row in the admin event list. 20 + type AdminEventRow struct { 21 + URI string 22 + Name string 23 + Location string 24 + StartTime string 25 + EndTime string 26 + ExpectedAttendees int 27 + QRToken string 28 + QRPublicURL string 29 + } 30 + 31 + func AdminEvents(v AdminEventsView) templ.Component { 32 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 33 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 34 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 35 + return templ_7745c5c3_CtxErr 36 + } 37 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 38 + if !templ_7745c5c3_IsBuffer { 39 + defer func() { 40 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 41 + if templ_7745c5c3_Err == nil { 42 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 43 + } 44 + }() 45 + } 46 + ctx = templ.InitializeContext(ctx) 47 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 48 + if templ_7745c5c3_Var1 == nil { 49 + templ_7745c5c3_Var1 = templ.NopComponent 50 + } 51 + ctx = templ.ClearChildren(ctx) 52 + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 53 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 54 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 55 + if !templ_7745c5c3_IsBuffer { 56 + defer func() { 57 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 58 + if templ_7745c5c3_Err == nil { 59 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 60 + } 61 + }() 62 + } 63 + ctx = templ.InitializeContext(ctx) 64 + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 65 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 66 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 67 + if !templ_7745c5c3_IsBuffer { 68 + defer func() { 69 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 70 + if templ_7745c5c3_Err == nil { 71 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 72 + } 73 + }() 74 + } 75 + ctx = templ.InitializeContext(ctx) 76 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"admin-events-toolbar\"><a href=\"/admin/events/new\" class=\"btn btn-primary btn-sm\">▸ new event</a></div>") 77 + if templ_7745c5c3_Err != nil { 78 + return templ_7745c5c3_Err 79 + } 80 + if len(v.Events) == 0 { 81 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<p class=\"admin-empty\">no events yet. <a href=\"/admin/events/new\">create the first one</a>.</p>") 82 + if templ_7745c5c3_Err != nil { 83 + return templ_7745c5c3_Err 84 + } 85 + } else { 86 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"admin-table-wrap\"><table class=\"admin-table\"><thead><tr><th>name</th><th>location</th><th>start → end</th><th class=\"num\">expected</th><th>QR url</th><th></th></tr></thead> <tbody>") 87 + if templ_7745c5c3_Err != nil { 88 + return templ_7745c5c3_Err 89 + } 90 + for _, ev := range v.Events { 91 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr><td><strong>") 92 + if templ_7745c5c3_Err != nil { 93 + return templ_7745c5c3_Err 94 + } 95 + var templ_7745c5c3_Var4 string 96 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Name) 97 + if templ_7745c5c3_Err != nil { 98 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 48, Col: 30} 99 + } 100 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 101 + if templ_7745c5c3_Err != nil { 102 + return templ_7745c5c3_Err 103 + } 104 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong></td><td>") 105 + if templ_7745c5c3_Err != nil { 106 + return templ_7745c5c3_Err 107 + } 108 + if ev.Location != "" { 109 + var templ_7745c5c3_Var5 string 110 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Location) 111 + if templ_7745c5c3_Err != nil { 112 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 51, Col: 24} 113 + } 114 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 115 + if templ_7745c5c3_Err != nil { 116 + return templ_7745c5c3_Err 117 + } 118 + } else { 119 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"muted\">—</span>") 120 + if templ_7745c5c3_Err != nil { 121 + return templ_7745c5c3_Err 122 + } 123 + } 124 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</td><td class=\"nowrap muted\">") 125 + if templ_7745c5c3_Err != nil { 126 + return templ_7745c5c3_Err 127 + } 128 + var templ_7745c5c3_Var6 string 129 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ev.StartTime) 130 + if templ_7745c5c3_Err != nil { 131 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 56, Col: 48} 132 + } 133 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 134 + if templ_7745c5c3_Err != nil { 135 + return templ_7745c5c3_Err 136 + } 137 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " &nbsp;→&nbsp; ") 138 + if templ_7745c5c3_Err != nil { 139 + return templ_7745c5c3_Err 140 + } 141 + var templ_7745c5c3_Var7 string 142 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(ev.EndTime) 143 + if templ_7745c5c3_Err != nil { 144 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 56, Col: 79} 145 + } 146 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 147 + if templ_7745c5c3_Err != nil { 148 + return templ_7745c5c3_Err 149 + } 150 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td class=\"num\">") 151 + if templ_7745c5c3_Err != nil { 152 + return templ_7745c5c3_Err 153 + } 154 + var templ_7745c5c3_Var8 string 155 + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(ev.ExpectedAttendees)) 156 + if templ_7745c5c3_Err != nil { 157 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 57, Col: 55} 158 + } 159 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 160 + if templ_7745c5c3_Err != nil { 161 + return templ_7745c5c3_Err 162 + } 163 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td><td><a class=\"break\" href=\"") 164 + if templ_7745c5c3_Err != nil { 165 + return templ_7745c5c3_Err 166 + } 167 + var templ_7745c5c3_Var9 templ.SafeURL 168 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(ev.QRPublicURL)) 169 + if templ_7745c5c3_Err != nil { 170 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 58, Col: 66} 171 + } 172 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 173 + if templ_7745c5c3_Err != nil { 174 + return templ_7745c5c3_Err 175 + } 176 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">") 177 + if templ_7745c5c3_Err != nil { 178 + return templ_7745c5c3_Err 179 + } 180 + var templ_7745c5c3_Var10 string 181 + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(ev.QRPublicURL) 182 + if templ_7745c5c3_Err != nil { 183 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 58, Col: 85} 184 + } 185 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 186 + if templ_7745c5c3_Err != nil { 187 + return templ_7745c5c3_Err 188 + } 189 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a></td><td class=\"nowrap\"><a class=\"btn btn-ghost btn-sm\" href=\"") 190 + if templ_7745c5c3_Err != nil { 191 + return templ_7745c5c3_Err 192 + } 193 + var templ_7745c5c3_Var11 templ.SafeURL 194 + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/events/" + ev.QRToken + "/badge")) 195 + if templ_7745c5c3_Err != nil { 196 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 60, Col: 104} 197 + } 198 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 199 + if templ_7745c5c3_Err != nil { 200 + return templ_7745c5c3_Err 201 + } 202 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">badge →</a></td></tr>") 203 + if templ_7745c5c3_Err != nil { 204 + return templ_7745c5c3_Err 205 + } 206 + } 207 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</tbody></table></div>") 208 + if templ_7745c5c3_Err != nil { 209 + return templ_7745c5c3_Err 210 + } 211 + } 212 + return nil 213 + }) 214 + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) 215 + if templ_7745c5c3_Err != nil { 216 + return templ_7745c5c3_Err 217 + } 218 + return nil 219 + }) 220 + templ_7745c5c3_Err = layouts.Base("admin · events — atmo.quest", "Manage atmo.quest events.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 221 + if templ_7745c5c3_Err != nil { 222 + return templ_7745c5c3_Err 223 + } 224 + return nil 225 + }) 226 + } 227 + 228 + // AdminEventFormView is the data for /admin/events/new (also re-used on 229 + // validation-error re-renders). 230 + type AdminEventFormView struct { 231 + Name string 232 + Location string 233 + StartTime string // datetime-local "2006-01-02T15:04" 234 + EndTime string 235 + ExpectedAttendees int 236 + Error string 237 + } 238 + 239 + func AdminEventNew(v AdminEventFormView) templ.Component { 240 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 241 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 242 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 243 + return templ_7745c5c3_CtxErr 244 + } 245 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 246 + if !templ_7745c5c3_IsBuffer { 247 + defer func() { 248 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 249 + if templ_7745c5c3_Err == nil { 250 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 251 + } 252 + }() 253 + } 254 + ctx = templ.InitializeContext(ctx) 255 + templ_7745c5c3_Var12 := templ.GetChildren(ctx) 256 + if templ_7745c5c3_Var12 == nil { 257 + templ_7745c5c3_Var12 = templ.NopComponent 258 + } 259 + ctx = templ.ClearChildren(ctx) 260 + templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 261 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 262 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 263 + if !templ_7745c5c3_IsBuffer { 264 + defer func() { 265 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 266 + if templ_7745c5c3_Err == nil { 267 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 268 + } 269 + }() 270 + } 271 + ctx = templ.InitializeContext(ctx) 272 + templ_7745c5c3_Var14 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 273 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 274 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 275 + if !templ_7745c5c3_IsBuffer { 276 + defer func() { 277 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 278 + if templ_7745c5c3_Err == nil { 279 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 280 + } 281 + }() 282 + } 283 + ctx = templ.InitializeContext(ctx) 284 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<h2 class=\"admin-section-title\">create event</h2>") 285 + if templ_7745c5c3_Err != nil { 286 + return templ_7745c5c3_Err 287 + } 288 + if v.Error != "" { 289 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"auth-error\" role=\"alert\"><span class=\"auth-error-prefix\">heads up:</span> ") 290 + if templ_7745c5c3_Err != nil { 291 + return templ_7745c5c3_Err 292 + } 293 + var templ_7745c5c3_Var15 string 294 + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) 295 + if templ_7745c5c3_Err != nil { 296 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 90, Col: 63} 297 + } 298 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 299 + if templ_7745c5c3_Err != nil { 300 + return templ_7745c5c3_Err 301 + } 302 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>") 303 + if templ_7745c5c3_Err != nil { 304 + return templ_7745c5c3_Err 305 + } 306 + } 307 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " <form class=\"admin-form\" method=\"post\" action=\"/admin/events\"><div class=\"admin-form-row\"><label for=\"event-name\">name</label> <input id=\"event-name\" name=\"name\" type=\"text\" required maxlength=\"120\" value=\"") 308 + if templ_7745c5c3_Err != nil { 309 + return templ_7745c5c3_Err 310 + } 311 + var templ_7745c5c3_Var16 string 312 + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Name) 313 + if templ_7745c5c3_Err != nil { 314 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 97, Col: 91} 315 + } 316 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) 317 + if templ_7745c5c3_Err != nil { 318 + return templ_7745c5c3_Err 319 + } 320 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" placeholder=\"e.g. CascadiaJS 2026\"></div><div class=\"admin-form-row\"><label for=\"event-location\">location</label> <input id=\"event-location\" name=\"location\" type=\"text\" maxlength=\"160\" value=\"") 321 + if templ_7745c5c3_Err != nil { 322 + return templ_7745c5c3_Err 323 + } 324 + var templ_7745c5c3_Var17 string 325 + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Location) 326 + if templ_7745c5c3_Err != nil { 327 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 101, Col: 94} 328 + } 329 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) 330 + if templ_7745c5c3_Err != nil { 331 + return templ_7745c5c3_Err 332 + } 333 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" placeholder=\"e.g. Portland Convention Center\"></div><div class=\"admin-form-row-pair\"><div class=\"admin-form-row\"><label for=\"event-start\">start</label> <input id=\"event-start\" name=\"start_time\" type=\"datetime-local\" required value=\"") 334 + if templ_7745c5c3_Err != nil { 335 + return templ_7745c5c3_Err 336 + } 337 + var templ_7745c5c3_Var18 string 338 + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.StartTime) 339 + if templ_7745c5c3_Err != nil { 340 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 106, Col: 98} 341 + } 342 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) 343 + if templ_7745c5c3_Err != nil { 344 + return templ_7745c5c3_Err 345 + } 346 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"></div><div class=\"admin-form-row\"><label for=\"event-end\">end</label> <input id=\"event-end\" name=\"end_time\" type=\"datetime-local\" required value=\"") 347 + if templ_7745c5c3_Err != nil { 348 + return templ_7745c5c3_Err 349 + } 350 + var templ_7745c5c3_Var19 string 351 + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.EndTime) 352 + if templ_7745c5c3_Err != nil { 353 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 110, Col: 92} 354 + } 355 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) 356 + if templ_7745c5c3_Err != nil { 357 + return templ_7745c5c3_Err 358 + } 359 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"></div></div><div class=\"admin-form-row\"><label for=\"event-expected\">expected attendees</label> <input id=\"event-expected\" name=\"expected_attendees\" type=\"number\" min=\"0\" value=\"") 360 + if templ_7745c5c3_Err != nil { 361 + return templ_7745c5c3_Err 362 + } 363 + var templ_7745c5c3_Var20 string 364 + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(intStr(v.ExpectedAttendees)) 365 + if templ_7745c5c3_Err != nil { 366 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 115, Col: 115} 367 + } 368 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) 369 + if templ_7745c5c3_Err != nil { 370 + return templ_7745c5c3_Err 371 + } 372 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"></div><div class=\"ctas\"><button type=\"submit\" class=\"btn btn-primary\">▸ create event</button> <a href=\"/admin/events\" class=\"btn btn-ghost\">cancel</a></div><p class=\"profile-edit-note\">creating an event writes a <span class=\"kw2\">quest.atmo.event</span> record to your PDS. you'll be taken to the badge designer next.</p></form>") 373 + if templ_7745c5c3_Err != nil { 374 + return templ_7745c5c3_Err 375 + } 376 + return nil 377 + }) 378 + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var14), templ_7745c5c3_Buffer) 379 + if templ_7745c5c3_Err != nil { 380 + return templ_7745c5c3_Err 381 + } 382 + return nil 383 + }) 384 + templ_7745c5c3_Err = layouts.Base("admin · new event — atmo.quest", "Create a new atmo.quest event.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) 385 + if templ_7745c5c3_Err != nil { 386 + return templ_7745c5c3_Err 387 + } 388 + return nil 389 + }) 390 + } 391 + 392 + // AdminEventBadgeView feeds the badge designer page. 393 + type AdminEventBadgeView struct { 394 + EventURI string 395 + EventName string 396 + Token string 397 + Shapes []string 398 + Primary []string 399 + Accent []string 400 + Ribbon []string 401 + Design AdminBadgeDesign 402 + SVGPreview string 403 + QRPublicURL string 404 + } 405 + 406 + // AdminBadgeDesign is the current state of the badge being designed. 407 + type AdminBadgeDesign struct { 408 + Shape string 409 + PrimaryColor string 410 + AccentColor string 411 + RibbonColor string 412 + Label string 413 + Signature string 414 + SigningKeyID string 415 + } 416 + 417 + func AdminEventBadge(v AdminEventBadgeView) templ.Component { 418 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 419 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 420 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 421 + return templ_7745c5c3_CtxErr 422 + } 423 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 424 + if !templ_7745c5c3_IsBuffer { 425 + defer func() { 426 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 427 + if templ_7745c5c3_Err == nil { 428 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 429 + } 430 + }() 431 + } 432 + ctx = templ.InitializeContext(ctx) 433 + templ_7745c5c3_Var21 := templ.GetChildren(ctx) 434 + if templ_7745c5c3_Var21 == nil { 435 + templ_7745c5c3_Var21 = templ.NopComponent 436 + } 437 + ctx = templ.ClearChildren(ctx) 438 + templ_7745c5c3_Var22 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 439 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 440 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 441 + if !templ_7745c5c3_IsBuffer { 442 + defer func() { 443 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 444 + if templ_7745c5c3_Err == nil { 445 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 446 + } 447 + }() 448 + } 449 + ctx = templ.InitializeContext(ctx) 450 + templ_7745c5c3_Var23 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 451 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 452 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 453 + if !templ_7745c5c3_IsBuffer { 454 + defer func() { 455 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 456 + if templ_7745c5c3_Err == nil { 457 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 458 + } 459 + }() 460 + } 461 + ctx = templ.InitializeContext(ctx) 462 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<h2 class=\"admin-section-title\">badge designer — ") 463 + if templ_7745c5c3_Err != nil { 464 + return templ_7745c5c3_Err 465 + } 466 + var templ_7745c5c3_Var24 string 467 + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName) 468 + if templ_7745c5c3_Err != nil { 469 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 158, Col: 67} 470 + } 471 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) 472 + if templ_7745c5c3_Err != nil { 473 + return templ_7745c5c3_Err 474 + } 475 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</h2><div class=\"admin-badge-layout\"><div class=\"admin-badge-preview\">") 476 + if templ_7745c5c3_Err != nil { 477 + return templ_7745c5c3_Err 478 + } 479 + templ_7745c5c3_Err = templ.Raw(v.SVGPreview).Render(ctx, templ_7745c5c3_Buffer) 480 + if templ_7745c5c3_Err != nil { 481 + return templ_7745c5c3_Err 482 + } 483 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p class=\"admin-badge-uri muted break\"><small>") 484 + if templ_7745c5c3_Err != nil { 485 + return templ_7745c5c3_Err 486 + } 487 + var templ_7745c5c3_Var25 string 488 + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventURI) 489 + if templ_7745c5c3_Err != nil { 490 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 164, Col: 25} 491 + } 492 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) 493 + if templ_7745c5c3_Err != nil { 494 + return templ_7745c5c3_Err 495 + } 496 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</small></p>") 497 + if templ_7745c5c3_Err != nil { 498 + return templ_7745c5c3_Err 499 + } 500 + if v.Design.Signature != "" { 501 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<p class=\"admin-badge-signature\"><span class=\"ok\">●</span> signed (key ") 502 + if templ_7745c5c3_Err != nil { 503 + return templ_7745c5c3_Err 504 + } 505 + var templ_7745c5c3_Var26 string 506 + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(v.Design.SigningKeyID) 507 + if templ_7745c5c3_Err != nil { 508 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 168, Col: 70} 509 + } 510 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) 511 + if templ_7745c5c3_Err != nil { 512 + return templ_7745c5c3_Err 513 + } 514 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ")</p>") 515 + if templ_7745c5c3_Err != nil { 516 + return templ_7745c5c3_Err 517 + } 518 + } 519 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div><form class=\"admin-form admin-badge-form\" method=\"post\" action=\"") 520 + if templ_7745c5c3_Err != nil { 521 + return templ_7745c5c3_Err 522 + } 523 + var templ_7745c5c3_Var27 templ.SafeURL 524 + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/events/" + v.Token + "/badge")) 525 + if templ_7745c5c3_Err != nil { 526 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 173, Col: 121} 527 + } 528 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) 529 + if templ_7745c5c3_Err != nil { 530 + return templ_7745c5c3_Err 531 + } 532 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><div class=\"admin-form-row\"><label for=\"badge-shape\">shape</label> <select id=\"badge-shape\" name=\"shape\" required>") 533 + if templ_7745c5c3_Err != nil { 534 + return templ_7745c5c3_Err 535 + } 536 + for _, s := range v.Shapes { 537 + if s == v.Design.Shape { 538 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<option value=\"") 539 + if templ_7745c5c3_Err != nil { 540 + return templ_7745c5c3_Err 541 + } 542 + var templ_7745c5c3_Var28 string 543 + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s) 544 + if templ_7745c5c3_Err != nil { 545 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 179, Col: 26} 546 + } 547 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) 548 + if templ_7745c5c3_Err != nil { 549 + return templ_7745c5c3_Err 550 + } 551 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" selected>") 552 + if templ_7745c5c3_Err != nil { 553 + return templ_7745c5c3_Err 554 + } 555 + var templ_7745c5c3_Var29 string 556 + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(s) 557 + if templ_7745c5c3_Err != nil { 558 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 179, Col: 41} 559 + } 560 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) 561 + if templ_7745c5c3_Err != nil { 562 + return templ_7745c5c3_Err 563 + } 564 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</option>") 565 + if templ_7745c5c3_Err != nil { 566 + return templ_7745c5c3_Err 567 + } 568 + } else { 569 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<option value=\"") 570 + if templ_7745c5c3_Err != nil { 571 + return templ_7745c5c3_Err 572 + } 573 + var templ_7745c5c3_Var30 string 574 + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(s) 575 + if templ_7745c5c3_Err != nil { 576 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 181, Col: 26} 577 + } 578 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) 579 + if templ_7745c5c3_Err != nil { 580 + return templ_7745c5c3_Err 581 + } 582 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">") 583 + if templ_7745c5c3_Err != nil { 584 + return templ_7745c5c3_Err 585 + } 586 + var templ_7745c5c3_Var31 string 587 + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(s) 588 + if templ_7745c5c3_Err != nil { 589 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 181, Col: 32} 590 + } 591 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) 592 + if templ_7745c5c3_Err != nil { 593 + return templ_7745c5c3_Err 594 + } 595 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</option>") 596 + if templ_7745c5c3_Err != nil { 597 + return templ_7745c5c3_Err 598 + } 599 + } 600 + } 601 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</select></div>") 602 + if templ_7745c5c3_Err != nil { 603 + return templ_7745c5c3_Err 604 + } 605 + templ_7745c5c3_Err = colorSwatchField("primary_color", "primary color", v.Primary, v.Design.PrimaryColor).Render(ctx, templ_7745c5c3_Buffer) 606 + if templ_7745c5c3_Err != nil { 607 + return templ_7745c5c3_Err 608 + } 609 + templ_7745c5c3_Err = colorSwatchField("accent_color", "accent color", v.Accent, v.Design.AccentColor).Render(ctx, templ_7745c5c3_Buffer) 610 + if templ_7745c5c3_Err != nil { 611 + return templ_7745c5c3_Err 612 + } 613 + templ_7745c5c3_Err = colorSwatchField("ribbon_color", "ribbon color", v.Ribbon, v.Design.RibbonColor).Render(ctx, templ_7745c5c3_Buffer) 614 + if templ_7745c5c3_Err != nil { 615 + return templ_7745c5c3_Err 616 + } 617 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"admin-form-row\"><label for=\"badge-label\">label</label> <input id=\"badge-label\" name=\"label\" type=\"text\" required maxlength=\"40\" value=\"") 618 + if templ_7745c5c3_Err != nil { 619 + return templ_7745c5c3_Err 620 + } 621 + var templ_7745c5c3_Var32 string 622 + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Design.Label) 623 + if templ_7745c5c3_Err != nil { 624 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 191, Col: 101} 625 + } 626 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) 627 + if templ_7745c5c3_Err != nil { 628 + return templ_7745c5c3_Err 629 + } 630 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"></div><div class=\"ctas\"><button type=\"submit\" class=\"btn btn-primary\">▸ save badge</button> <a href=\"") 631 + if templ_7745c5c3_Err != nil { 632 + return templ_7745c5c3_Err 633 + } 634 + var templ_7745c5c3_Var33 templ.SafeURL 635 + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/e/" + v.Token + "/qr.svg")) 636 + if templ_7745c5c3_Err != nil { 637 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 195, Col: 58} 638 + } 639 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) 640 + if templ_7745c5c3_Err != nil { 641 + return templ_7745c5c3_Err 642 + } 643 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"btn btn-ghost\">download QR</a> <a href=\"/admin/events\" class=\"btn btn-ghost\">← all events</a></div><p class=\"profile-edit-note\">public scan URL: <a class=\"break\" href=\"") 644 + if templ_7745c5c3_Err != nil { 645 + return templ_7745c5c3_Err 646 + } 647 + var templ_7745c5c3_Var34 templ.SafeURL 648 + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.QRPublicURL)) 649 + if templ_7745c5c3_Err != nil { 650 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 199, Col: 75} 651 + } 652 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) 653 + if templ_7745c5c3_Err != nil { 654 + return templ_7745c5c3_Err 655 + } 656 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">") 657 + if templ_7745c5c3_Err != nil { 658 + return templ_7745c5c3_Err 659 + } 660 + var templ_7745c5c3_Var35 string 661 + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(v.QRPublicURL) 662 + if templ_7745c5c3_Err != nil { 663 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 199, Col: 93} 664 + } 665 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) 666 + if templ_7745c5c3_Err != nil { 667 + return templ_7745c5c3_Err 668 + } 669 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a><br>saving signs this design with the server's admin key (ed25519). tampering with the row in the database will fail signature verification on next read.</p></form></div>") 670 + if templ_7745c5c3_Err != nil { 671 + return templ_7745c5c3_Err 672 + } 673 + return nil 674 + }) 675 + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var23), templ_7745c5c3_Buffer) 676 + if templ_7745c5c3_Err != nil { 677 + return templ_7745c5c3_Err 678 + } 679 + return nil 680 + }) 681 + templ_7745c5c3_Err = layouts.Base("admin · badge designer — atmo.quest", "Design an event badge.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var22), templ_7745c5c3_Buffer) 682 + if templ_7745c5c3_Err != nil { 683 + return templ_7745c5c3_Err 684 + } 685 + return nil 686 + }) 687 + } 688 + 689 + // colorSwatchField is a radio-group with visible color swatches. 690 + func colorSwatchField(name, label string, options []string, selected string) templ.Component { 691 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 692 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 693 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 694 + return templ_7745c5c3_CtxErr 695 + } 696 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 697 + if !templ_7745c5c3_IsBuffer { 698 + defer func() { 699 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 700 + if templ_7745c5c3_Err == nil { 701 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 702 + } 703 + }() 704 + } 705 + ctx = templ.InitializeContext(ctx) 706 + templ_7745c5c3_Var36 := templ.GetChildren(ctx) 707 + if templ_7745c5c3_Var36 == nil { 708 + templ_7745c5c3_Var36 = templ.NopComponent 709 + } 710 + ctx = templ.ClearChildren(ctx) 711 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"admin-form-row\"><label>") 712 + if templ_7745c5c3_Err != nil { 713 + return templ_7745c5c3_Err 714 + } 715 + var templ_7745c5c3_Var37 string 716 + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(label) 717 + if templ_7745c5c3_Err != nil { 718 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 213, Col: 16} 719 + } 720 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) 721 + if templ_7745c5c3_Err != nil { 722 + return templ_7745c5c3_Err 723 + } 724 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</label><div class=\"badge-swatches\">") 725 + if templ_7745c5c3_Err != nil { 726 + return templ_7745c5c3_Err 727 + } 728 + for _, c := range options { 729 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<label class=\"badge-swatch\">") 730 + if templ_7745c5c3_Err != nil { 731 + return templ_7745c5c3_Err 732 + } 733 + if c == selected { 734 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<input type=\"radio\" name=\"") 735 + if templ_7745c5c3_Err != nil { 736 + return templ_7745c5c3_Err 737 + } 738 + var templ_7745c5c3_Var38 string 739 + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) 740 + if templ_7745c5c3_Err != nil { 741 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 218, Col: 37} 742 + } 743 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) 744 + if templ_7745c5c3_Err != nil { 745 + return templ_7745c5c3_Err 746 + } 747 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" value=\"") 748 + if templ_7745c5c3_Err != nil { 749 + return templ_7745c5c3_Err 750 + } 751 + var templ_7745c5c3_Var39 string 752 + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(c) 753 + if templ_7745c5c3_Err != nil { 754 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 218, Col: 49} 755 + } 756 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) 757 + if templ_7745c5c3_Err != nil { 758 + return templ_7745c5c3_Err 759 + } 760 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" checked> ") 761 + if templ_7745c5c3_Err != nil { 762 + return templ_7745c5c3_Err 763 + } 764 + } else { 765 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<input type=\"radio\" name=\"") 766 + if templ_7745c5c3_Err != nil { 767 + return templ_7745c5c3_Err 768 + } 769 + var templ_7745c5c3_Var40 string 770 + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) 771 + if templ_7745c5c3_Err != nil { 772 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 220, Col: 37} 773 + } 774 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40) 775 + if templ_7745c5c3_Err != nil { 776 + return templ_7745c5c3_Err 777 + } 778 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" value=\"") 779 + if templ_7745c5c3_Err != nil { 780 + return templ_7745c5c3_Err 781 + } 782 + var templ_7745c5c3_Var41 string 783 + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(c) 784 + if templ_7745c5c3_Err != nil { 785 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 220, Col: 49} 786 + } 787 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41) 788 + if templ_7745c5c3_Err != nil { 789 + return templ_7745c5c3_Err 790 + } 791 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"> ") 792 + if templ_7745c5c3_Err != nil { 793 + return templ_7745c5c3_Err 794 + } 795 + } 796 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"badge-swatch-chip\" style=\"") 797 + if templ_7745c5c3_Err != nil { 798 + return templ_7745c5c3_Err 799 + } 800 + var templ_7745c5c3_Var42 string 801 + templ_7745c5c3_Var42, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background:" + c) 802 + if templ_7745c5c3_Err != nil { 803 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 222, Col: 62} 804 + } 805 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) 806 + if templ_7745c5c3_Err != nil { 807 + return templ_7745c5c3_Err 808 + } 809 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"></span> <span class=\"badge-swatch-hex\">") 810 + if templ_7745c5c3_Err != nil { 811 + return templ_7745c5c3_Err 812 + } 813 + var templ_7745c5c3_Var43 string 814 + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(c) 815 + if templ_7745c5c3_Err != nil { 816 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 223, Col: 39} 817 + } 818 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) 819 + if templ_7745c5c3_Err != nil { 820 + return templ_7745c5c3_Err 821 + } 822 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span></label>") 823 + if templ_7745c5c3_Err != nil { 824 + return templ_7745c5c3_Err 825 + } 826 + } 827 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div></div>") 828 + if templ_7745c5c3_Err != nil { 829 + return templ_7745c5c3_Err 830 + } 831 + return nil 832 + }) 833 + } 834 + 835 + var _ = templruntime.GeneratedTemplate
+173
features/admin/pages/users.templ
··· 1 + package pages 2 + 3 + import "atmoquest/features/common/layouts" 4 + 5 + // AdminUsersView is the data for /admin/users. 6 + type AdminUsersView struct { 7 + Query string 8 + AdminsOnly bool 9 + Page int 10 + Users []AdminUserRow 11 + HasNext bool 12 + } 13 + 14 + // AdminUserRow is a single row in the admin user list. Pre-formatted strings 15 + // so the template doesn't need any date math. 16 + type AdminUserRow struct { 17 + DID string 18 + Handle string 19 + DisplayName string 20 + AuthCount int 21 + FirstSeen string 22 + LastSeen string 23 + IsAdmin bool 24 + IsBanned bool 25 + } 26 + 27 + templ AdminUsers(v AdminUsersView) { 28 + @layouts.Base("admin · users — atmo.quest", "Manage atmo.quest users.") { 29 + @adminShell("users", "") { 30 + <form class="admin-search" method="get" action="/admin/users"> 31 + <input 32 + class="admin-search-input" 33 + type="text" 34 + name="q" 35 + value={ v.Query } 36 + placeholder="search handle, display name, or DID…" 37 + autofocus 38 + /> 39 + <label class="admin-search-checkbox"> 40 + <input 41 + type="checkbox" 42 + name="admins" 43 + value="1" 44 + checked?={ v.AdminsOnly } 45 + /> 46 + <span>admins only</span> 47 + </label> 48 + <button type="submit" class="btn btn-primary btn-sm">▸ search</button> 49 + </form> 50 + 51 + if len(v.Users) == 0 { 52 + <p class="admin-empty">no users match your filter.</p> 53 + } else { 54 + <div class="admin-table-wrap"> 55 + <table class="admin-table"> 56 + <thead> 57 + <tr> 58 + <th>handle</th> 59 + <th>display name</th> 60 + <th class="num">auths</th> 61 + <th>last seen</th> 62 + <th>role</th> 63 + <th></th> 64 + </tr> 65 + </thead> 66 + <tbody> 67 + for _, u := range v.Users { 68 + @adminUserTR(u, v.Query, v.AdminsOnly, v.Page) 69 + } 70 + </tbody> 71 + </table> 72 + </div> 73 + 74 + <div class="admin-pagination"> 75 + if v.Page > 1 { 76 + <a class="btn btn-ghost btn-sm" href={ templ.SafeURL(adminUsersURL(v.Query, v.AdminsOnly, v.Page-1)) }>← prev</a> 77 + } 78 + <span class="muted">page { intStr(v.Page) }</span> 79 + if v.HasNext { 80 + <a class="btn btn-ghost btn-sm" href={ templ.SafeURL(adminUsersURL(v.Query, v.AdminsOnly, v.Page+1)) }>next →</a> 81 + } 82 + </div> 83 + } 84 + } 85 + } 86 + } 87 + 88 + templ adminUserTR(u AdminUserRow, q string, admins bool, page int) { 89 + <tr> 90 + <td> 91 + if u.Handle != "" { 92 + <span class="handle">{ "@" + u.Handle }</span> 93 + } else { 94 + <span class="muted">{ shortDID(u.DID) }</span> 95 + } 96 + </td> 97 + <td> 98 + if u.DisplayName != "" { 99 + { u.DisplayName } 100 + } else { 101 + <span class="muted">—</span> 102 + } 103 + </td> 104 + <td class="num">{ intStr(u.AuthCount) }</td> 105 + <td class="nowrap muted">{ u.LastSeen }</td> 106 + <td> 107 + if u.IsAdmin { 108 + <span class="role-pill role-admin">admin</span> 109 + } else if u.IsBanned { 110 + <span class="role-pill role-banned">banned</span> 111 + } else { 112 + <span class="role-pill role-user">user</span> 113 + } 114 + </td> 115 + <td> 116 + <form method="post" action={ templ.SafeURL("/admin/users/" + u.DID + "/promote?" + adminUsersQuery(q, admins, page)) } class="admin-inline-form"> 117 + if u.IsAdmin { 118 + <input type="hidden" name="make_admin" value="0"/> 119 + <button type="submit" class="btn btn-ghost btn-sm">▾ demote</button> 120 + } else { 121 + <input type="hidden" name="make_admin" value="1"/> 122 + <button type="submit" class="btn btn-ghost btn-sm">▴ promote</button> 123 + } 124 + </form> 125 + </td> 126 + </tr> 127 + } 128 + 129 + // adminUsersURL builds the /admin/users URL preserving search state. 130 + func adminUsersURL(q string, admins bool, page int) string { 131 + return "/admin/users?" + adminUsersQuery(q, admins, page) 132 + } 133 + 134 + func adminUsersQuery(q string, admins bool, page int) string { 135 + out := "q=" + urlEscape(q) 136 + if admins { 137 + out += "&admins=1" 138 + } 139 + if page > 1 { 140 + out += "&page=" + intStr(page) 141 + } 142 + return out 143 + } 144 + 145 + // urlEscape is a minimal escaper for the query-string params we emit. We 146 + // only care about characters that would corrupt a URL — '&', '#', '?', ' ', 147 + // '=', '%'. Everything else passes through. Safe because the raw string is 148 + // user-supplied search text which we then re-display as the form input 149 + // value via templ's HTML escaping. 150 + func urlEscape(s string) string { 151 + out := make([]byte, 0, len(s)) 152 + for i := 0; i < len(s); i++ { 153 + c := s[i] 154 + switch { 155 + case c == ' ': 156 + out = append(out, '+') 157 + case c == '&' || c == '#' || c == '?' || c == '=' || c == '%' || c == '+' || c < 0x20 || c > 0x7e: 158 + out = append(out, '%') 159 + out = append(out, hexDigit(c>>4)) 160 + out = append(out, hexDigit(c&0x0f)) 161 + default: 162 + out = append(out, c) 163 + } 164 + } 165 + return string(out) 166 + } 167 + 168 + func hexDigit(n byte) byte { 169 + if n < 10 { 170 + return '0' + n 171 + } 172 + return 'a' + (n - 10) 173 + }
+413
features/admin/pages/users_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.1020 4 + package pages 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import "atmoquest/features/common/layouts" 12 + 13 + // AdminUsersView is the data for /admin/users. 14 + type AdminUsersView struct { 15 + Query string 16 + AdminsOnly bool 17 + Page int 18 + Users []AdminUserRow 19 + HasNext bool 20 + } 21 + 22 + // AdminUserRow is a single row in the admin user list. Pre-formatted strings 23 + // so the template doesn't need any date math. 24 + type AdminUserRow struct { 25 + DID string 26 + Handle string 27 + DisplayName string 28 + AuthCount int 29 + FirstSeen string 30 + LastSeen string 31 + IsAdmin bool 32 + IsBanned bool 33 + } 34 + 35 + func AdminUsers(v AdminUsersView) templ.Component { 36 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 37 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 38 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 39 + return templ_7745c5c3_CtxErr 40 + } 41 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 42 + if !templ_7745c5c3_IsBuffer { 43 + defer func() { 44 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 45 + if templ_7745c5c3_Err == nil { 46 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 47 + } 48 + }() 49 + } 50 + ctx = templ.InitializeContext(ctx) 51 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 52 + if templ_7745c5c3_Var1 == nil { 53 + templ_7745c5c3_Var1 = templ.NopComponent 54 + } 55 + ctx = templ.ClearChildren(ctx) 56 + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 57 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 58 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 59 + if !templ_7745c5c3_IsBuffer { 60 + defer func() { 61 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 62 + if templ_7745c5c3_Err == nil { 63 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 64 + } 65 + }() 66 + } 67 + ctx = templ.InitializeContext(ctx) 68 + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 69 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 70 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 71 + if !templ_7745c5c3_IsBuffer { 72 + defer func() { 73 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 74 + if templ_7745c5c3_Err == nil { 75 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 76 + } 77 + }() 78 + } 79 + ctx = templ.InitializeContext(ctx) 80 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<form class=\"admin-search\" method=\"get\" action=\"/admin/users\"><input class=\"admin-search-input\" type=\"text\" name=\"q\" value=\"") 81 + if templ_7745c5c3_Err != nil { 82 + return templ_7745c5c3_Err 83 + } 84 + var templ_7745c5c3_Var4 string 85 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Query) 86 + if templ_7745c5c3_Err != nil { 87 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 35, Col: 20} 88 + } 89 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) 90 + if templ_7745c5c3_Err != nil { 91 + return templ_7745c5c3_Err 92 + } 93 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" placeholder=\"search handle, display name, or DID…\" autofocus> <label class=\"admin-search-checkbox\"><input type=\"checkbox\" name=\"admins\" value=\"1\"") 94 + if templ_7745c5c3_Err != nil { 95 + return templ_7745c5c3_Err 96 + } 97 + if v.AdminsOnly { 98 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " checked") 99 + if templ_7745c5c3_Err != nil { 100 + return templ_7745c5c3_Err 101 + } 102 + } 103 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "> <span>admins only</span></label> <button type=\"submit\" class=\"btn btn-primary btn-sm\">▸ search</button></form>") 104 + if templ_7745c5c3_Err != nil { 105 + return templ_7745c5c3_Err 106 + } 107 + if len(v.Users) == 0 { 108 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p class=\"admin-empty\">no users match your filter.</p>") 109 + if templ_7745c5c3_Err != nil { 110 + return templ_7745c5c3_Err 111 + } 112 + } else { 113 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"admin-table-wrap\"><table class=\"admin-table\"><thead><tr><th>handle</th><th>display name</th><th class=\"num\">auths</th><th>last seen</th><th>role</th><th></th></tr></thead> <tbody>") 114 + if templ_7745c5c3_Err != nil { 115 + return templ_7745c5c3_Err 116 + } 117 + for _, u := range v.Users { 118 + templ_7745c5c3_Err = adminUserTR(u, v.Query, v.AdminsOnly, v.Page).Render(ctx, templ_7745c5c3_Buffer) 119 + if templ_7745c5c3_Err != nil { 120 + return templ_7745c5c3_Err 121 + } 122 + } 123 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</tbody></table></div><div class=\"admin-pagination\">") 124 + if templ_7745c5c3_Err != nil { 125 + return templ_7745c5c3_Err 126 + } 127 + if v.Page > 1 { 128 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a class=\"btn btn-ghost btn-sm\" href=\"") 129 + if templ_7745c5c3_Err != nil { 130 + return templ_7745c5c3_Err 131 + } 132 + var templ_7745c5c3_Var5 templ.SafeURL 133 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(adminUsersURL(v.Query, v.AdminsOnly, v.Page-1))) 134 + if templ_7745c5c3_Err != nil { 135 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 76, Col: 106} 136 + } 137 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 138 + if templ_7745c5c3_Err != nil { 139 + return templ_7745c5c3_Err 140 + } 141 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">← prev</a> ") 142 + if templ_7745c5c3_Err != nil { 143 + return templ_7745c5c3_Err 144 + } 145 + } 146 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span class=\"muted\">page ") 147 + if templ_7745c5c3_Err != nil { 148 + return templ_7745c5c3_Err 149 + } 150 + var templ_7745c5c3_Var6 string 151 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(v.Page)) 152 + if templ_7745c5c3_Err != nil { 153 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 78, Col: 46} 154 + } 155 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 156 + if templ_7745c5c3_Err != nil { 157 + return templ_7745c5c3_Err 158 + } 159 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> ") 160 + if templ_7745c5c3_Err != nil { 161 + return templ_7745c5c3_Err 162 + } 163 + if v.HasNext { 164 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a class=\"btn btn-ghost btn-sm\" href=\"") 165 + if templ_7745c5c3_Err != nil { 166 + return templ_7745c5c3_Err 167 + } 168 + var templ_7745c5c3_Var7 templ.SafeURL 169 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(adminUsersURL(v.Query, v.AdminsOnly, v.Page+1))) 170 + if templ_7745c5c3_Err != nil { 171 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 80, Col: 106} 172 + } 173 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 174 + if templ_7745c5c3_Err != nil { 175 + return templ_7745c5c3_Err 176 + } 177 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">next →</a>") 178 + if templ_7745c5c3_Err != nil { 179 + return templ_7745c5c3_Err 180 + } 181 + } 182 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>") 183 + if templ_7745c5c3_Err != nil { 184 + return templ_7745c5c3_Err 185 + } 186 + } 187 + return nil 188 + }) 189 + templ_7745c5c3_Err = adminShell("users", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) 190 + if templ_7745c5c3_Err != nil { 191 + return templ_7745c5c3_Err 192 + } 193 + return nil 194 + }) 195 + templ_7745c5c3_Err = layouts.Base("admin · users — atmo.quest", "Manage atmo.quest users.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 196 + if templ_7745c5c3_Err != nil { 197 + return templ_7745c5c3_Err 198 + } 199 + return nil 200 + }) 201 + } 202 + 203 + func adminUserTR(u AdminUserRow, q string, admins bool, page int) templ.Component { 204 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 205 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 206 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 207 + return templ_7745c5c3_CtxErr 208 + } 209 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 210 + if !templ_7745c5c3_IsBuffer { 211 + defer func() { 212 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 213 + if templ_7745c5c3_Err == nil { 214 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 215 + } 216 + }() 217 + } 218 + ctx = templ.InitializeContext(ctx) 219 + templ_7745c5c3_Var8 := templ.GetChildren(ctx) 220 + if templ_7745c5c3_Var8 == nil { 221 + templ_7745c5c3_Var8 = templ.NopComponent 222 + } 223 + ctx = templ.ClearChildren(ctx) 224 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<tr><td>") 225 + if templ_7745c5c3_Err != nil { 226 + return templ_7745c5c3_Err 227 + } 228 + if u.Handle != "" { 229 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"handle\">") 230 + if templ_7745c5c3_Err != nil { 231 + return templ_7745c5c3_Err 232 + } 233 + var templ_7745c5c3_Var9 string 234 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("@" + u.Handle) 235 + if templ_7745c5c3_Err != nil { 236 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 92, Col: 41} 237 + } 238 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 239 + if templ_7745c5c3_Err != nil { 240 + return templ_7745c5c3_Err 241 + } 242 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span>") 243 + if templ_7745c5c3_Err != nil { 244 + return templ_7745c5c3_Err 245 + } 246 + } else { 247 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"muted\">") 248 + if templ_7745c5c3_Err != nil { 249 + return templ_7745c5c3_Err 250 + } 251 + var templ_7745c5c3_Var10 string 252 + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(u.DID)) 253 + if templ_7745c5c3_Err != nil { 254 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 94, Col: 41} 255 + } 256 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 257 + if templ_7745c5c3_Err != nil { 258 + return templ_7745c5c3_Err 259 + } 260 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span>") 261 + if templ_7745c5c3_Err != nil { 262 + return templ_7745c5c3_Err 263 + } 264 + } 265 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td>") 266 + if templ_7745c5c3_Err != nil { 267 + return templ_7745c5c3_Err 268 + } 269 + if u.DisplayName != "" { 270 + var templ_7745c5c3_Var11 string 271 + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.DisplayName) 272 + if templ_7745c5c3_Err != nil { 273 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 99, Col: 19} 274 + } 275 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 276 + if templ_7745c5c3_Err != nil { 277 + return templ_7745c5c3_Err 278 + } 279 + } else { 280 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"muted\">—</span>") 281 + if templ_7745c5c3_Err != nil { 282 + return templ_7745c5c3_Err 283 + } 284 + } 285 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"num\">") 286 + if templ_7745c5c3_Err != nil { 287 + return templ_7745c5c3_Err 288 + } 289 + var templ_7745c5c3_Var12 string 290 + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(u.AuthCount)) 291 + if templ_7745c5c3_Err != nil { 292 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 104, Col: 39} 293 + } 294 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) 295 + if templ_7745c5c3_Err != nil { 296 + return templ_7745c5c3_Err 297 + } 298 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</td><td class=\"nowrap muted\">") 299 + if templ_7745c5c3_Err != nil { 300 + return templ_7745c5c3_Err 301 + } 302 + var templ_7745c5c3_Var13 string 303 + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(u.LastSeen) 304 + if templ_7745c5c3_Err != nil { 305 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 105, Col: 39} 306 + } 307 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 308 + if templ_7745c5c3_Err != nil { 309 + return templ_7745c5c3_Err 310 + } 311 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td>") 312 + if templ_7745c5c3_Err != nil { 313 + return templ_7745c5c3_Err 314 + } 315 + if u.IsAdmin { 316 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"role-pill role-admin\">admin</span>") 317 + if templ_7745c5c3_Err != nil { 318 + return templ_7745c5c3_Err 319 + } 320 + } else if u.IsBanned { 321 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"role-pill role-banned\">banned</span>") 322 + if templ_7745c5c3_Err != nil { 323 + return templ_7745c5c3_Err 324 + } 325 + } else { 326 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"role-pill role-user\">user</span>") 327 + if templ_7745c5c3_Err != nil { 328 + return templ_7745c5c3_Err 329 + } 330 + } 331 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td><form method=\"post\" action=\"") 332 + if templ_7745c5c3_Err != nil { 333 + return templ_7745c5c3_Err 334 + } 335 + var templ_7745c5c3_Var14 templ.SafeURL 336 + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/users/" + u.DID + "/promote?" + adminUsersQuery(q, admins, page))) 337 + if templ_7745c5c3_Err != nil { 338 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 116, Col: 119} 339 + } 340 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) 341 + if templ_7745c5c3_Err != nil { 342 + return templ_7745c5c3_Err 343 + } 344 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"admin-inline-form\">") 345 + if templ_7745c5c3_Err != nil { 346 + return templ_7745c5c3_Err 347 + } 348 + if u.IsAdmin { 349 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<input type=\"hidden\" name=\"make_admin\" value=\"0\"> <button type=\"submit\" class=\"btn btn-ghost btn-sm\">▾ demote</button>") 350 + if templ_7745c5c3_Err != nil { 351 + return templ_7745c5c3_Err 352 + } 353 + } else { 354 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<input type=\"hidden\" name=\"make_admin\" value=\"1\"> <button type=\"submit\" class=\"btn btn-ghost btn-sm\">▴ promote</button>") 355 + if templ_7745c5c3_Err != nil { 356 + return templ_7745c5c3_Err 357 + } 358 + } 359 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</form></td></tr>") 360 + if templ_7745c5c3_Err != nil { 361 + return templ_7745c5c3_Err 362 + } 363 + return nil 364 + }) 365 + } 366 + 367 + // adminUsersURL builds the /admin/users URL preserving search state. 368 + func adminUsersURL(q string, admins bool, page int) string { 369 + return "/admin/users?" + adminUsersQuery(q, admins, page) 370 + } 371 + 372 + func adminUsersQuery(q string, admins bool, page int) string { 373 + out := "q=" + urlEscape(q) 374 + if admins { 375 + out += "&admins=1" 376 + } 377 + if page > 1 { 378 + out += "&page=" + intStr(page) 379 + } 380 + return out 381 + } 382 + 383 + // urlEscape is a minimal escaper for the query-string params we emit. We 384 + // only care about characters that would corrupt a URL — '&', '#', '?', ' ', 385 + // '=', '%'. Everything else passes through. Safe because the raw string is 386 + // user-supplied search text which we then re-display as the form input 387 + // value via templ's HTML escaping. 388 + func urlEscape(s string) string { 389 + out := make([]byte, 0, len(s)) 390 + for i := 0; i < len(s); i++ { 391 + c := s[i] 392 + switch { 393 + case c == ' ': 394 + out = append(out, '+') 395 + case c == '&' || c == '#' || c == '?' || c == '=' || c == '%' || c == '+' || c < 0x20 || c > 0x7e: 396 + out = append(out, '%') 397 + out = append(out, hexDigit(c>>4)) 398 + out = append(out, hexDigit(c&0x0f)) 399 + default: 400 + out = append(out, c) 401 + } 402 + } 403 + return string(out) 404 + } 405 + 406 + func hexDigit(n byte) byte { 407 + if n < 10 { 408 + return '0' + n 409 + } 410 + return 'a' + (n - 10) 411 + } 412 + 413 + var _ = templruntime.GeneratedTemplate
+36
features/admin/routes.go
··· 1 + package admin 2 + 3 + import ( 4 + "database/sql" 5 + 6 + "github.com/go-chi/chi/v5" 7 + 8 + "atmoquest/features/auth" 9 + "atmoquest/internal/admincrypto" 10 + ) 11 + 12 + // SetupRoutes wires the admin feature's routes behind RequireAdmin. 13 + // 14 + // - GET /admin — dashboard stats 15 + // - GET /admin/users — user list (?q=, ?admins=, ?page=) 16 + // - POST /admin/users/{did}/promote — toggle is_admin 17 + // - GET /admin/events — event list 18 + // - GET /admin/events/new — new-event form 19 + // - POST /admin/events — create event + redirect to badge designer 20 + // - GET /admin/events/{token}/badge — badge designer 21 + // - POST /admin/events/{token}/badge — save badge design (signed) 22 + func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, signer *admincrypto.Signer) { 23 + h := NewHandlers(conn, authH, signer) 24 + 25 + router.Route("/admin", func(r chi.Router) { 26 + r.Use(h.RequireAdmin) 27 + r.Get("/", h.AdminDashboard) 28 + r.Get("/users", h.AdminUsers) 29 + r.Post("/users/{did}/promote", h.AdminUsersPromote) 30 + r.Get("/events", h.AdminEvents) 31 + r.Get("/events/new", h.AdminEventNew) 32 + r.Post("/events", h.AdminEventCreate) 33 + r.Get("/events/{token}/badge", h.AdminEventBadge) 34 + r.Post("/events/{token}/badge", h.AdminEventBadgeSave) 35 + }) 36 + }
+127
internal/admincrypto/signer.go
··· 1 + // Package admincrypto loads the server's Ed25519 signing key used to 2 + // authenticate admin-issued artifacts (currently: event badge designs). 3 + // 4 + // Key resolution order: 5 + // 6 + // 1. `ADMIN_SIGNING_KEY` env var — base64-encoded 64-byte Ed25519 seed 7 + // (i.e. the private key from `ed25519.GenerateKey`). This is the 8 + // production path: rotate by changing the env var. 9 + // 10 + // 2. A file at `data/admin_signing.key` relative to the working 11 + // directory. Auto-generated on first boot in dev mode if the env 12 + // var is unset. The file is gitignored. 13 + // 14 + // We deliberately keep the key in-memory only at runtime — there's no 15 + // API for callers to read the raw bytes. Sign() / Verify() are the 16 + // public surface. 17 + package admincrypto 18 + 19 + import ( 20 + "crypto/ed25519" 21 + "crypto/rand" 22 + "encoding/base64" 23 + "errors" 24 + "fmt" 25 + "os" 26 + "path/filepath" 27 + "sync" 28 + ) 29 + 30 + // Signer holds the Ed25519 keypair and exposes Sign/Verify. 31 + type Signer struct { 32 + priv ed25519.PrivateKey 33 + pub ed25519.PublicKey 34 + // KeyID is a short label used in stored signatures so we can rotate 35 + // keys later by tagging signatures with which key generated them. 36 + // Always "v1" for now. 37 + KeyID string 38 + } 39 + 40 + // Load resolves an admin signing key from env or disk. If `allowGenerate` 41 + // is true and neither source is present, a fresh keypair is generated 42 + // and persisted to `data/admin_signing.key`. Production deployments 43 + // should set ADMIN_SIGNING_KEY and pass allowGenerate=false. 44 + func Load(allowGenerate bool) (*Signer, error) { 45 + // 1. env var 46 + if raw := os.Getenv("ADMIN_SIGNING_KEY"); raw != "" { 47 + seed, err := base64.StdEncoding.DecodeString(raw) 48 + if err != nil { 49 + return nil, fmt.Errorf("admincrypto: ADMIN_SIGNING_KEY not valid base64: %w", err) 50 + } 51 + if len(seed) != ed25519.SeedSize { 52 + return nil, fmt.Errorf("admincrypto: ADMIN_SIGNING_KEY wrong size; want %d bytes, got %d", ed25519.SeedSize, len(seed)) 53 + } 54 + priv := ed25519.NewKeyFromSeed(seed) 55 + return &Signer{priv: priv, pub: priv.Public().(ed25519.PublicKey), KeyID: "v1"}, nil 56 + } 57 + 58 + // 2. file (dev mode) 59 + const path = "data/admin_signing.key" 60 + if seed, err := os.ReadFile(path); err == nil { 61 + if len(seed) != ed25519.SeedSize { 62 + return nil, fmt.Errorf("admincrypto: %s wrong size; want %d bytes, got %d", path, ed25519.SeedSize, len(seed)) 63 + } 64 + priv := ed25519.NewKeyFromSeed(seed) 65 + return &Signer{priv: priv, pub: priv.Public().(ed25519.PublicKey), KeyID: "v1"}, nil 66 + } 67 + 68 + // 3. generate (dev mode opt-in) 69 + if !allowGenerate { 70 + return nil, errors.New("admincrypto: no ADMIN_SIGNING_KEY env var and no data/admin_signing.key file") 71 + } 72 + pub, priv, err := ed25519.GenerateKey(rand.Reader) 73 + if err != nil { 74 + return nil, fmt.Errorf("admincrypto: generate: %w", err) 75 + } 76 + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 77 + return nil, fmt.Errorf("admincrypto: mkdir: %w", err) 78 + } 79 + if err := os.WriteFile(path, priv.Seed(), 0o600); err != nil { 80 + return nil, fmt.Errorf("admincrypto: write key: %w", err) 81 + } 82 + return &Signer{priv: priv, pub: pub, KeyID: "v1"}, nil 83 + } 84 + 85 + // MustLoad is a convenience wrapper for main() that panics on failure. 86 + func MustLoad(allowGenerate bool) *Signer { 87 + s, err := Load(allowGenerate) 88 + if err != nil { 89 + panic(err) 90 + } 91 + return s 92 + } 93 + 94 + // Sign returns a base64-encoded Ed25519 signature over msg. 95 + func (s *Signer) Sign(msg []byte) string { 96 + if s == nil { 97 + return "" 98 + } 99 + sig := ed25519.Sign(s.priv, msg) 100 + return base64.StdEncoding.EncodeToString(sig) 101 + } 102 + 103 + // Verify returns true if encodedSig (base64) is a valid Ed25519 signature 104 + // over msg under this signer's public key. 105 + func (s *Signer) Verify(msg []byte, encodedSig string) bool { 106 + if s == nil { 107 + return false 108 + } 109 + sig, err := base64.StdEncoding.DecodeString(encodedSig) 110 + if err != nil { 111 + return false 112 + } 113 + return ed25519.Verify(s.pub, msg, sig) 114 + } 115 + 116 + // PublicKeyBase64 returns the base64-encoded public key — useful for an 117 + // admin debug page that wants to show what the server is verifying with. 118 + func (s *Signer) PublicKeyBase64() string { 119 + if s == nil { 120 + return "" 121 + } 122 + return base64.StdEncoding.EncodeToString(s.pub) 123 + } 124 + 125 + // guard against accidental concurrent rotation. Unused for now; reserved 126 + // for a future RotateKey method. 127 + var _ sync.Mutex
+215
internal/badge/badge.go
··· 1 + // Package badge stores and renders signed event badge designs. 2 + // 3 + // A badge design is the visual template an admin defines for an event: 4 + // shape + colors + label. When an admin saves a design we compute a 5 + // canonical JSON representation, sign it with the server's Ed25519 6 + // admin signing key, and persist (design + signature) together. On 7 + // every read we re-verify the signature so any tampering with the 8 + // underlying SQLite row is detected. 9 + // 10 + // Issuance (binding a badge to a specific user as a credential) is a 11 + // separate concern and not implemented in this package — that's badge 12 + // *award* and lives in a future package. Per the spec, v1 only signs 13 + // the *design*. 14 + package badge 15 + 16 + import ( 17 + "context" 18 + "database/sql" 19 + "encoding/json" 20 + "errors" 21 + "fmt" 22 + "time" 23 + 24 + "atmoquest/internal/admincrypto" 25 + ) 26 + 27 + // Default palette options shown in the admin badge designer. Kept 28 + // small so the UI stays a single-screen form — a wider palette is a 29 + // future enhancement. 30 + var ( 31 + // DefaultShapes are the four shape options the admin can pick. 32 + DefaultShapes = []string{"circle", "shield", "star", "hexagon"} 33 + 34 + // DefaultPrimaryColors / DefaultAccentColors / DefaultRibbonColors 35 + // are Catppuccin-ish swatches chosen to read well at small sizes. 36 + DefaultPrimaryColors = []string{"#fab387", "#f9e2af", "#a6e3a1", "#89b4fa", "#cba6f7", "#f38ba8"} 37 + DefaultAccentColors = []string{"#11111b", "#1e1e2e", "#313244", "#cdd6f4"} 38 + DefaultRibbonColors = []string{"#f38ba8", "#94e2d5", "#89dceb", "#fab387", "#a6e3a1"} 39 + ) 40 + 41 + // ErrNotFound is returned by Get when no badge exists for the event. 42 + var ErrNotFound = errors.New("badge: not found") 43 + 44 + // ErrInvalidSignature is returned when a stored signature fails verification 45 + // on read. Callers should treat this as a security incident — the row was 46 + // tampered with after creation. 47 + var ErrInvalidSignature = errors.New("badge: signature failed verification") 48 + 49 + // Design is the persisted badge configuration plus signature metadata. 50 + type Design struct { 51 + EventURI string 52 + Shape string 53 + PrimaryColor string 54 + AccentColor string 55 + RibbonColor string 56 + Label string 57 + Signature string // base64 58 + SigningKeyID string // typically "v1" 59 + CreatedAt time.Time 60 + UpdatedAt time.Time 61 + } 62 + 63 + // canonicalPayload is the deterministic JSON we sign. It deliberately 64 + // excludes timestamps (which change on update) and signature fields 65 + // (which depend on this payload). Field order is alphabetical because 66 + // Go's encoding/json sorts struct field encoding by the struct order, 67 + // so we list fields alphabetically here for stability. 68 + type canonicalPayload struct { 69 + AccentColor string `json:"accent_color"` 70 + EventURI string `json:"event_uri"` 71 + Label string `json:"label"` 72 + PrimaryColor string `json:"primary_color"` 73 + RibbonColor string `json:"ribbon_color"` 74 + Shape string `json:"shape"` 75 + } 76 + 77 + // canonicalBytes returns the deterministic byte representation of d for 78 + // signing / verification. Any change to the design produces a fresh 79 + // signature. 80 + func canonicalBytes(d Design) ([]byte, error) { 81 + p := canonicalPayload{ 82 + AccentColor: d.AccentColor, 83 + EventURI: d.EventURI, 84 + Label: d.Label, 85 + PrimaryColor: d.PrimaryColor, 86 + RibbonColor: d.RibbonColor, 87 + Shape: d.Shape, 88 + } 89 + return json.Marshal(p) 90 + } 91 + 92 + // Save upserts a badge design for the given event, signing it with the 93 + // supplied admincrypto.Signer. Replaces any prior design for the same 94 + // event — admins iterate freely until they're happy. 95 + func Save(ctx context.Context, db *sql.DB, signer *admincrypto.Signer, d Design) (Design, error) { 96 + if signer == nil { 97 + return Design{}, errors.New("badge: nil signer") 98 + } 99 + if err := validate(d); err != nil { 100 + return Design{}, err 101 + } 102 + 103 + body, err := canonicalBytes(d) 104 + if err != nil { 105 + return Design{}, fmt.Errorf("badge: canonicalize: %w", err) 106 + } 107 + d.Signature = signer.Sign(body) 108 + d.SigningKeyID = signer.KeyID 109 + 110 + _, err = db.ExecContext(ctx, ` 111 + INSERT INTO event_badges ( 112 + event_uri, shape, primary_color, accent_color, ribbon_color, 113 + label, signature, signing_key_id, created_at, updated_at 114 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) 115 + ON CONFLICT(event_uri) DO UPDATE SET 116 + shape = excluded.shape, 117 + primary_color = excluded.primary_color, 118 + accent_color = excluded.accent_color, 119 + ribbon_color = excluded.ribbon_color, 120 + label = excluded.label, 121 + signature = excluded.signature, 122 + signing_key_id = excluded.signing_key_id, 123 + updated_at = CURRENT_TIMESTAMP 124 + `, 125 + d.EventURI, d.Shape, d.PrimaryColor, d.AccentColor, d.RibbonColor, 126 + d.Label, d.Signature, d.SigningKeyID, 127 + ) 128 + if err != nil { 129 + return Design{}, fmt.Errorf("badge: save: %w", err) 130 + } 131 + return d, nil 132 + } 133 + 134 + // Get returns the badge design for an event AND verifies its signature. 135 + // Returns ErrNotFound when no design is saved; ErrInvalidSignature when 136 + // the stored signature doesn't verify under signer. 137 + func Get(ctx context.Context, db *sql.DB, signer *admincrypto.Signer, eventURI string) (Design, error) { 138 + row := db.QueryRowContext(ctx, ` 139 + SELECT event_uri, shape, primary_color, accent_color, ribbon_color, 140 + label, signature, signing_key_id, created_at, updated_at 141 + FROM event_badges 142 + WHERE event_uri = ? 143 + `, eventURI) 144 + var d Design 145 + err := row.Scan( 146 + &d.EventURI, &d.Shape, &d.PrimaryColor, &d.AccentColor, &d.RibbonColor, 147 + &d.Label, &d.Signature, &d.SigningKeyID, &d.CreatedAt, &d.UpdatedAt, 148 + ) 149 + if errors.Is(err, sql.ErrNoRows) { 150 + return Design{}, ErrNotFound 151 + } 152 + if err != nil { 153 + return Design{}, err 154 + } 155 + 156 + if signer != nil { 157 + body, err := canonicalBytes(d) 158 + if err != nil { 159 + return Design{}, err 160 + } 161 + if !signer.Verify(body, d.Signature) { 162 + return Design{}, ErrInvalidSignature 163 + } 164 + } 165 + return d, nil 166 + } 167 + 168 + // validate enforces the small set of constraints we want at the storage 169 + // layer; the admin form will also surface these errors at submit. 170 + func validate(d Design) error { 171 + if d.EventURI == "" { 172 + return errors.New("badge: empty event URI") 173 + } 174 + if !contains(DefaultShapes, d.Shape) { 175 + return fmt.Errorf("badge: shape %q not in defaults", d.Shape) 176 + } 177 + if !isHex(d.PrimaryColor) || !isHex(d.AccentColor) || !isHex(d.RibbonColor) { 178 + return errors.New("badge: colors must be #RRGGBB hex") 179 + } 180 + if d.Label == "" { 181 + return errors.New("badge: empty label") 182 + } 183 + if len(d.Label) > 40 { 184 + return errors.New("badge: label too long (max 40 chars)") 185 + } 186 + return nil 187 + } 188 + 189 + func contains(haystack []string, needle string) bool { 190 + for _, h := range haystack { 191 + if h == needle { 192 + return true 193 + } 194 + } 195 + return false 196 + } 197 + 198 + // isHex matches "#RRGGBB" — a strict 7-char form. Short-form (#RGB) is 199 + // rejected so the SVG renderer doesn't need to expand it. 200 + func isHex(s string) bool { 201 + if len(s) != 7 || s[0] != '#' { 202 + return false 203 + } 204 + for i := 1; i < 7; i++ { 205 + c := s[i] 206 + switch { 207 + case c >= '0' && c <= '9': 208 + case c >= 'a' && c <= 'f': 209 + case c >= 'A' && c <= 'F': 210 + default: 211 + return false 212 + } 213 + } 214 + return true 215 + }
+192
internal/badge/render.go
··· 1 + package badge 2 + 3 + import ( 4 + "fmt" 5 + "html" 6 + "strings" 7 + ) 8 + 9 + // RenderSVG returns a self-contained SVG string rendering this badge 10 + // design at the given pixel size. The output uses only inline 11 + // attributes (no <style> block) so it can be safely embedded inside an 12 + // HTML page via `templ.Raw`. 13 + // 14 + // The visual is intentionally simple — a colored medal shape with a 15 + // ribbon hanging behind it and the label centered on top — so the 16 + // admin's design choices read clearly without needing complex 17 + // gradients or filters. We'll layer richer presets on top of this in 18 + // a future iteration once badges get more screen real estate. 19 + func RenderSVG(d Design, size int) string { 20 + if size <= 0 { 21 + size = 160 22 + } 23 + // Layout constants scaled to the requested size so the badge looks 24 + // the same at 80px or 400px. 25 + w := size 26 + h := size + size/3 // leave room for the ribbon below the shape 27 + cx := w / 2 28 + medalY := h * 2 / 5 29 + medalR := size * 2 / 5 30 + 31 + label := html.EscapeString(strings.TrimSpace(d.Label)) 32 + primary := safeColor(d.PrimaryColor, "#fab387") 33 + accent := safeColor(d.AccentColor, "#11111b") 34 + ribbon := safeColor(d.RibbonColor, "#f38ba8") 35 + 36 + var shape string 37 + switch d.Shape { 38 + case "circle": 39 + shape = fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="%s" stroke="%s" stroke-width="3"/>`, 40 + cx, medalY, medalR, primary, accent) 41 + case "shield": 42 + shape = shieldPath(cx, medalY, medalR, primary, accent) 43 + case "star": 44 + shape = starPath(cx, medalY, medalR, primary, accent) 45 + case "hexagon": 46 + shape = hexagonPath(cx, medalY, medalR, primary, accent) 47 + default: 48 + shape = fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="%s" stroke="%s" stroke-width="3"/>`, 49 + cx, medalY, medalR, primary, accent) 50 + } 51 + 52 + // Ribbon: two triangular tails behind the medal. Drawn first so the 53 + // medal sits on top. 54 + ribbonLeft := fmt.Sprintf( 55 + `<polygon points="%d,%d %d,%d %d,%d %d,%d" fill="%s"/>`, 56 + cx-medalR/2, medalY, 57 + cx-medalR/3, medalY+medalR+size/6, 58 + cx-medalR/8, medalY+medalR+size/12, 59 + cx, medalY+medalR/3, 60 + ribbon, 61 + ) 62 + ribbonRight := fmt.Sprintf( 63 + `<polygon points="%d,%d %d,%d %d,%d %d,%d" fill="%s"/>`, 64 + cx+medalR/2, medalY, 65 + cx+medalR/3, medalY+medalR+size/6, 66 + cx+medalR/8, medalY+medalR+size/12, 67 + cx, medalY+medalR/3, 68 + shade(ribbon), 69 + ) 70 + 71 + // Centered label on the medal. The font-size scales with the medal 72 + // radius so longer labels still fit reasonably; the admin form 73 + // enforces a 40-char max. 74 + fontSize := medalR / 4 75 + if fontSize < 10 { 76 + fontSize = 10 77 + } 78 + labelEl := fmt.Sprintf( 79 + `<text x="%d" y="%d" text-anchor="middle" dominant-baseline="middle" font-family="ui-monospace,monospace" font-size="%d" fill="%s" font-weight="600">%s</text>`, 80 + cx, medalY, fontSize, accent, label, 81 + ) 82 + 83 + return fmt.Sprintf( 84 + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" role="img" aria-label="event badge: %s">%s%s%s%s</svg>`, 85 + w, h, w, h, label, 86 + ribbonLeft, ribbonRight, shape, labelEl, 87 + ) 88 + } 89 + 90 + // shieldPath draws a tapered shield centered on (cx, cy) with radius r. 91 + func shieldPath(cx, cy, r int, fill, stroke string) string { 92 + top := cy - r 93 + bot := cy + r 94 + half := r * 9 / 10 95 + return fmt.Sprintf( 96 + `<path d="M %d %d Q %d %d %d %d L %d %d Q %d %d %d %d Q %d %d %d %d L %d %d Q %d %d %d %d Z" fill="%s" stroke="%s" stroke-width="3"/>`, 97 + cx-half, top, 98 + cx, top-r/8, cx+half, top, 99 + cx+half, cy+r/4, 100 + cx+half, cy+r/4, cx, bot, 101 + cx, bot, cx-half, cy+r/4, 102 + cx-half, cy+r/4, 103 + cx-half, cy+r/4, cx-half, top, 104 + fill, stroke, 105 + ) 106 + } 107 + 108 + // starPath draws a 5-pointed star centered on (cx, cy) with outer radius r. 109 + func starPath(cx, cy, r int, fill, stroke string) string { 110 + // Pre-computed unit-circle points for a 5-pointed star (alternating 111 + // outer and inner vertices). Multiplied by r at render time. We use 112 + // floats here for the trig values but stringify to int for SVG. 113 + pts := []struct{ x, y float64 }{ 114 + {0, -1.0}, {0.225, -0.309}, {0.951, -0.309}, {0.363, 0.118}, 115 + {0.588, 0.809}, {0, 0.4}, {-0.588, 0.809}, {-0.363, 0.118}, 116 + {-0.951, -0.309}, {-0.225, -0.309}, 117 + } 118 + var b strings.Builder 119 + for i, p := range pts { 120 + if i > 0 { 121 + b.WriteByte(' ') 122 + } 123 + fmt.Fprintf(&b, "%d,%d", cx+int(p.x*float64(r)), cy+int(p.y*float64(r))) 124 + } 125 + return fmt.Sprintf(`<polygon points="%s" fill="%s" stroke="%s" stroke-width="3" stroke-linejoin="round"/>`, 126 + b.String(), fill, stroke) 127 + } 128 + 129 + // hexagonPath draws a regular hexagon (point-up) centered on (cx, cy). 130 + func hexagonPath(cx, cy, r int, fill, stroke string) string { 131 + // 6 vertices, each at 60° increments. Point-up means the first vertex 132 + // is at the top (angle = -90°). 133 + pts := []struct{ x, y float64 }{ 134 + {0, -1.0}, {0.866, -0.5}, {0.866, 0.5}, 135 + {0, 1.0}, {-0.866, 0.5}, {-0.866, -0.5}, 136 + } 137 + var b strings.Builder 138 + for i, p := range pts { 139 + if i > 0 { 140 + b.WriteByte(' ') 141 + } 142 + fmt.Fprintf(&b, "%d,%d", cx+int(p.x*float64(r)), cy+int(p.y*float64(r))) 143 + } 144 + return fmt.Sprintf(`<polygon points="%s" fill="%s" stroke="%s" stroke-width="3" stroke-linejoin="round"/>`, 145 + b.String(), fill, stroke) 146 + } 147 + 148 + // safeColor returns hex if it looks like #RRGGBB, otherwise fallback. 149 + // Guards the SVG output against junk that bypassed validation. 150 + func safeColor(hex, fallback string) string { 151 + if isHex(hex) { 152 + return hex 153 + } 154 + return fallback 155 + } 156 + 157 + // shade darkens a hex color by ~20% so the right ribbon tail reads 158 + // slightly different from the left, suggesting fold/depth without a 159 + // gradient. Returns the input unchanged on parse failure. 160 + func shade(hex string) string { 161 + if !isHex(hex) { 162 + return hex 163 + } 164 + r := hexByte(hex[1:3]) 165 + g := hexByte(hex[3:5]) 166 + b := hexByte(hex[5:7]) 167 + return fmt.Sprintf("#%02x%02x%02x", r*4/5, g*4/5, b*4/5) 168 + } 169 + 170 + func hexByte(s string) int { 171 + if len(s) != 2 { 172 + return 0 173 + } 174 + hi := hexNibble(s[0]) 175 + lo := hexNibble(s[1]) 176 + if hi < 0 || lo < 0 { 177 + return 0 178 + } 179 + return hi*16 + lo 180 + } 181 + 182 + func hexNibble(c byte) int { 183 + switch { 184 + case c >= '0' && c <= '9': 185 + return int(c - '0') 186 + case c >= 'a' && c <= 'f': 187 + return int(c-'a') + 10 188 + case c >= 'A' && c <= 'F': 189 + return int(c-'A') + 10 190 + } 191 + return -1 192 + }
+4
router/router.go
··· 12 12 "github.com/starfederation/datastar-go/datastar" 13 13 14 14 "atmoquest/config" 15 + "atmoquest/features/admin" 15 16 "atmoquest/features/auth" 16 17 "atmoquest/features/connect" 17 18 "atmoquest/features/event" 18 19 "atmoquest/features/index" 19 20 "atmoquest/features/profile" 21 + "atmoquest/internal/admincrypto" 20 22 "atmoquest/internal/connection" 21 23 "atmoquest/internal/session" 22 24 "atmoquest/web/resources" ··· 34 36 oauthApp *oauth.ClientApp, 35 37 ns *embeddednats.Server, 36 38 conn *sql.DB, 39 + signer *admincrypto.Signer, 37 40 ) (err error) { 38 41 39 42 if config.Global.Environment == config.Dev { ··· 51 54 profile.SetupRoutes(router, conn, authH) 52 55 connect.SetupRoutes(router, conn, authH, connQueue) 53 56 event.SetupRoutes(router, conn, authH) 57 + admin.SetupRoutes(router, conn, authH, signer) 54 58 index.SetupRoutes(router, conn) 55 59 56 60 return nil