feat(go): M6.1 — template skeleton, embed.FS, HTML routes #26

Merged
kacerr merged 1 commits from feat/go-m6-1-template-skeleton into main 2026-05-08 00:48:22 +02:00
17 changed files with 820 additions and 17 deletions

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
- `go/internal/web/static/css/app.css`: terminal-green-on-black theme extracted once from Python `templates/adults.html` — shared by all Go HTML pages via `<link>`.
- `go/internal/web/assets.go`: `//go:embed templates static` for single-binary deployment.
- `go/internal/web/render.go`: `Renderer` parses a fresh `*template.Template` per page at startup; `Render(w, name, data)` executes the "base" template block.
- `go/internal/web/html_handler.go`: `HTMLHandler` with one method per route (`ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeSync`, `ServeFlushCache`).
- `go/internal/web/server.go`: drops `helloHandler`; `GET /{$}` now redirects to `/adults`; HTML + `/static/` routes registered alongside the existing `/api/*` routes.
- `go/internal/web/html_handler_test.go`: smoke test — each route returns 200 `text/html` with exactly one `class="active"` on the matching nav link.
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky ## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`. - `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.

View File

@@ -2,9 +2,9 @@
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md). Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
**Current milestone:** M5JSON-only `/api/...` routes ✅ **Current milestone:** M6Go-native HTML frontend
**Started:** 2026-05-04 **Started:** 2026-05-04
**Last updated:** 2026-05-07 (M5.4) **Last updated:** 2026-05-08 (M6.1)
## How to use ## How to use
@@ -110,7 +110,7 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port. Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` - [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` - [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering - [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view - [ ] **M6.4** `/payments` page: grouped-by-person ledger view

View File

@@ -0,0 +1,88 @@
# Plan: Go rewrite — M6.1 Template skeleton
## Context
M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106).
M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+.
This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge").
## Current Go web state (what we're building on)
- [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields.
- No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate.
## Approach
1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`.
2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`.
3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505).
4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `<link>` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting.
5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data.
6. **Replace the existing `helloHandler`**: `GET /{$}``http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)).
7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`.
8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it.
## Files to create / modify
```
go/internal/web/
├── assets.go NEW — //go:embed templates/* static/*
├── render.go NEW — Renderer wraps parsed *template.Template
├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/
├── html/
│ ├── handler.go NEW — html.Handler + page methods
│ └── handler_test.go NEW — httptest smoke per route
├── templates/
│ ├── base.tmpl NEW — <html>/<head>/<body>; nav + content + footer blocks
│ ├── partials/
│ │ ├── nav.tmpl NEW — three-tier nav, active highlighting
│ │ └── footer.tmpl NEW — build meta line
│ ├── adults.tmpl NEW — placeholder "Coming in M6.2"
│ ├── juniors.tmpl NEW — placeholder "Coming in M6.3"
│ ├── payments.tmpl NEW — placeholder "Coming in M6.4"
│ ├── sync.tmpl NEW — placeholder "Coming in M6.6"
│ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6"
└── static/
└── css/
└── app.css NEW — verbatim from templates/adults.html:8-487
```
## Key design notes
- **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request.
- **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid.
- **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it).
- **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed.
- **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery.
- **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look.
- **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory.
## Critical files to read
- [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1).
- [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim.
- [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers).
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`.
## Verification
End-to-end smoke from a fresh `make go-build`:
1. `make web-go &` — server boots; no template-parse errors in slog output.
2. `curl -i localhost:8080/``302` to `/adults`.
3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only.
4. `curl -sI localhost:8080/static/css/app.css``200`, `Content-Type: text/css`.
5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`.
6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows.
7. `make go-test``web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor.
8. `make go-lint` clean.
9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`).
10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA.
## Branch & MR
`feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.

View File

@@ -0,0 +1,9 @@
package web
import "embed"
//go:embed templates
var templateFS embed.FS
//go:embed static
var staticFS embed.FS

View File

@@ -0,0 +1,34 @@
package web
import "net/http"
// HTMLHandler serves the Go-native HTML frontend.
type HTMLHandler struct {
renderer *Renderer
build BuildInfo
}
// NewHTMLHandler constructs an HTMLHandler.
func NewHTMLHandler(r *Renderer, b BuildInfo) *HTMLHandler {
return &HTMLHandler{renderer: r, build: b}
}
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "adults", PageData{Active: "adults", Build: h.build})
}
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "juniors", PageData{Active: "juniors", Build: h.build})
}
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build})
}
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
}
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
}

View File

@@ -0,0 +1,54 @@
package web_test
import (
"fmt"
"fuj-management/go/internal/web"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHTMLHandlerSmoke(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b)
cases := []struct {
path string
handler http.HandlerFunc
}{
{"/adults", h.ServeAdults},
{"/juniors", h.ServeJuniors},
{"/payments", h.ServePayments},
{"/sync-bank", h.ServeSync},
{"/flush-cache", h.ServeFlushCache},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
tc.handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
body := w.Body.String()
if n := strings.Count(body, `class="active"`); n != 1 {
t.Errorf(`class="active" count = %d, want 1`, n)
}
want := fmt.Sprintf(`href="%s" class="active"`, tc.path)
if !strings.Contains(body, want) {
t.Errorf("missing active link %q in body", want)
}
})
}
}

53
go/internal/web/render.go Normal file
View File

@@ -0,0 +1,53 @@
package web
import (
"fmt"
"html/template"
"log/slog"
"net/http"
)
// PageData is the view model passed to every HTML template.
type PageData struct {
Active string
Build BuildInfo
}
// Renderer parses and executes HTML templates from the embedded FS.
type Renderer struct {
tmpls map[string]*template.Template
}
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
// NewRenderer parses all templates from the embedded FS.
// A parse failure should be treated as a startup-time fatal error.
func NewRenderer() (*Renderer, error) {
tmpls := make(map[string]*template.Template, len(pageNames))
for _, name := range pageNames {
t, err := template.New("").ParseFS(templateFS,
"templates/base.tmpl",
"templates/partials/nav.tmpl",
"templates/partials/footer.tmpl",
"templates/"+name+".tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse template %q: %w", name, err)
}
tmpls[name] = t
}
return &Renderer{tmpls: tmpls}, nil
}
// Render executes the named template with data, writing to w.
func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
t, ok := r.tmpls[name]
if !ok {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
slog.Error("render template", "name", name, "err", err)
}
}

View File

@@ -6,6 +6,7 @@ import (
"fuj-management/go/internal/services/membership" "fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web/api" "fuj-management/go/internal/web/api"
"fuj-management/go/internal/web/middleware" "fuj-management/go/internal/web/middleware"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
) )
@@ -19,7 +20,12 @@ type BuildInfo struct {
// Run registers routes and starts the HTTP server on addr. // Run registers routes and starts the HTTP server on addr.
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error { func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
h := &api.Handler{ renderer, err := NewRenderer()
if err != nil {
return fmt.Errorf("init templates: %w", err)
}
ah := &api.Handler{
BuildVersion: build.Version, BuildVersion: build.Version,
BuildCommit: build.Commit, BuildCommit: build.Commit,
BuildDate: build.BuildDate, BuildDate: build.BuildDate,
@@ -27,22 +33,34 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
Config: cfg, Config: cfg,
Logger: logger, Logger: logger,
} }
hh := NewHTMLHandler(renderer, build)
staticSubFS, err := fs.Sub(staticFS, "static")
if err != nil {
return fmt.Errorf("static subfs: %w", err)
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", helloHandler(build))
mux.HandleFunc("GET /api/version", h.ServeVersion) // HTML routes
mux.HandleFunc("GET /api/adults", h.ServeAdults) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /api/juniors", h.ServeJuniors) http.Redirect(w, r, "/adults", http.StatusFound)
mux.HandleFunc("GET /api/payments", h.ServePayments) })
mux.HandleFunc("GET /adults", hh.ServeAdults)
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
mux.HandleFunc("GET /payments", hh.ServePayments)
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// JSON API routes
mux.HandleFunc("GET /api/version", ah.ServeVersion)
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
mux.HandleFunc("GET /api/payments", ah.ServePayments)
logger.Info("starting server", "addr", addr) logger.Info("starting server", "addr", addr)
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux)) return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
} }
func helloHandler(build BuildInfo) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
build.Version, build.Commit, build.BuildDate)
}
}

View File

@@ -0,0 +1,477 @@
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
h2 {
color: #00ff00;
font-size: 12px;
margin-top: 30px;
margin-bottom: 10px;
text-transform: uppercase;
width: 100%;
max-width: 1200px;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.nav {
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex;
gap: 15px;
align-items: center;
}
.nav a {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
border-color: #555;
}
.nav-archived a {
font-size: 10px;
color: #666;
border-color: #222;
}
.nav-archived a.active {
color: #ccc;
background-color: #333;
border-color: #555;
}
.nav-archived a:hover {
color: #999;
border-color: #444;
}
.description {
margin-bottom: 20px;
text-align: center;
color: #888;
max-width: 800px;
}
.description a {
color: #00ff00;
text-decoration: none;
}
.description a:hover {
text-decoration: underline;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.balance-pos {
color: #00ff00;
}
.balance-neg {
color: #ff3333;
}
.cell-ok {
color: #00ff00;
}
.cell-unpaid {
color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.cell-unpaid-current {
color: #994444;
background-color: rgba(153, 68, 68, 0.05);
position: relative;
}
.cell-overridden {
color: #ffa500 !important;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
}
.cell-empty {
color: #444444;
}
.list-container {
width: 100%;
max-width: 1200px;
color: #888;
margin-bottom: 40px;
}
.list-item {
display: flex;
justify-content: flex-start;
gap: 20px;
padding: 1px 0;
border-bottom: 1px dashed #222;
}
.list-item-name {
color: #ccc;
min-width: 200px;
}
.list-item-val {
color: #00ff00;
}
.unmatched-row {
font-family: inherit;
display: grid;
grid-template-columns: 100px 100px 200px 1fr;
gap: 15px;
color: #888;
padding: 2px 0;
border-bottom: 1px dashed #222;
}
.unmatched-header {
color: #555;
border-bottom: 1px solid #333;
margin-bottom: 5px;
}
.filter-container {
width: 100%;
max-width: 1200px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.filter-input {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
width: 250px;
outline: none;
}
.filter-input:focus {
border-color: #00ff00;
}
.filter-select {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
outline: none;
}
.filter-select:focus {
border-color: #00ff00;
}
.month-hidden {
display: none !important;
}
.filter-label {
color: #888;
text-transform: lowercase;
}
.info-icon {
color: #00ff00;
cursor: pointer;
margin-left: 5px;
font-size: 10px;
opacity: 0.5;
}
.info-icon:hover {
opacity: 1;
}
/* Modal Styles */
#memberModal {
display: none !important;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
}
#memberModal.active {
display: flex !important;
}
.modal-content {
background-color: #0c0c0c;
border: 1px solid #00ff00;
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
position: relative;
}
.modal-header {
border-bottom: 1px solid #333;
margin-bottom: 20px;
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
color: #00ff00;
font-size: 14px;
text-transform: uppercase;
}
.close-btn {
color: #ff3333;
cursor: pointer;
font-size: 14px;
text-transform: lowercase;
}
.modal-section {
margin-bottom: 25px;
}
.modal-section-title {
color: #555;
text-transform: uppercase;
font-size: 10px;
margin-bottom: 8px;
border-bottom: 1px dashed #222;
}
.raw-toggle {
color: #333;
font-size: 9px;
text-transform: lowercase;
margin-left: 8px;
text-decoration: none;
letter-spacing: 0;
}
.raw-toggle:hover {
color: #666;
}
.modal-table {
width: 100%;
border-collapse: collapse;
}
.modal-table th,
.modal-table td {
text-align: left;
padding: 4px 0;
border-bottom: 1px dashed #1a1a1a;
}
.modal-table th {
color: #666;
font-weight: normal;
font-size: 10px;
}
.tx-list {
list-style: none;
padding: 0;
margin: 0;
}
.tx-item {
padding: 8px 0;
border-bottom: 1px dashed #222;
}
.tx-meta {
color: #555;
font-size: 10px;
margin-bottom: 4px;
}
.tx-main {
display: flex;
justify-content: space-between;
gap: 20px;
}
.tx-amount {
color: #00ff00;
}
.tx-sender {
color: #ccc;
}
.tx-msg {
color: #888;
font-style: italic;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Adults{{end}}
{{define "content"}}
<h1>Adults Dashboard</h1>
<p class="description">Coming in M6.2</p>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ — {{template "title" .}}</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
{{template "nav" .}}
{{template "content" .}}
{{template "footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Flush Cache{{end}}
{{define "content"}}
<h1>Flush Cache</h1>
<p class="description">Coming in M6.6</p>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Juniors{{end}}
{{define "content"}}
<h1>Juniors Dashboard</h1>
<p class="description">Coming in M6.3</p>
{{end}}

View File

@@ -0,0 +1,3 @@
{{define "footer"}}
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "nav"}}
<div class="nav">
<div>
<a href="/adults"{{if eq .Active "adults"}} class="active"{{end}}>[Adults]</a>
<a href="/juniors"{{if eq .Active "juniors"}} class="active"{{end}}>[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/payments"{{if eq .Active "payments"}} class="active"{{end}}>[Payments Ledger]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank"{{if eq .Active "sync"}} class="active"{{end}}>[Sync Bank Data]</a>
<a href="/flush-cache"{{if eq .Active "flush"}} class="active"{{end}}>[Flush Cache]</a>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
<h1>Payments Ledger</h1>
<p class="description">Coming in M6.4</p>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Sync Bank Data{{end}}
{{define "content"}}
<h1>Sync Bank Data</h1>
<p class="description">Coming in M6.6</p>
{{end}}