diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae03da..3d7bbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 ``. +- `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 - `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`. diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md index ead897e..c3133cc 100644 --- a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -2,9 +2,9 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md). -**Current milestone:** M5 — JSON-only `/api/...` routes ✅ +**Current milestone:** M6 — Go-native HTML frontend **Started:** 2026-05-04 -**Last updated:** 2026-05-07 (M5.4) +**Last updated:** 2026-05-08 (M6.1) ## 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. -- [ ] **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.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering - [ ] **M6.4** `/payments` page: grouped-by-person ledger view diff --git a/docs/plans/2026-05-08-0034-go-rewrite-m6-1-template-skeleton.md b/docs/plans/2026-05-08-0034-go-rewrite-m6-1-template-skeleton.md new file mode 100644 index 0000000..7c2f824 --- /dev/null +++ b/docs/plans/2026-05-08-0034-go-rewrite-m6-1-template-skeleton.md @@ -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 `` 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 — //; 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. diff --git a/go/internal/web/assets.go b/go/internal/web/assets.go new file mode 100644 index 0000000..de8b1aa --- /dev/null +++ b/go/internal/web/assets.go @@ -0,0 +1,9 @@ +package web + +import "embed" + +//go:embed templates +var templateFS embed.FS + +//go:embed static +var staticFS embed.FS diff --git a/go/internal/web/html_handler.go b/go/internal/web/html_handler.go new file mode 100644 index 0000000..04d22ec --- /dev/null +++ b/go/internal/web/html_handler.go @@ -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}) +} diff --git a/go/internal/web/html_handler_test.go b/go/internal/web/html_handler_test.go new file mode 100644 index 0000000..5b20ae2 --- /dev/null +++ b/go/internal/web/html_handler_test.go @@ -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) + } + }) + } +} diff --git a/go/internal/web/render.go b/go/internal/web/render.go new file mode 100644 index 0000000..a8ae58c --- /dev/null +++ b/go/internal/web/render.go @@ -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) + } +} diff --git a/go/internal/web/server.go b/go/internal/web/server.go index d62ab7b..df92668 100644 --- a/go/internal/web/server.go +++ b/go/internal/web/server.go @@ -6,6 +6,7 @@ import ( "fuj-management/go/internal/services/membership" "fuj-management/go/internal/web/api" "fuj-management/go/internal/web/middleware" + "io/fs" "log/slog" "net/http" ) @@ -19,7 +20,12 @@ type BuildInfo struct { // 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 { - h := &api.Handler{ + renderer, err := NewRenderer() + if err != nil { + return fmt.Errorf("init templates: %w", err) + } + + ah := &api.Handler{ BuildVersion: build.Version, BuildCommit: build.Commit, BuildDate: build.BuildDate, @@ -27,22 +33,34 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S Config: cfg, 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.HandleFunc("GET /{$}", helloHandler(build)) - mux.HandleFunc("GET /api/version", h.ServeVersion) - mux.HandleFunc("GET /api/adults", h.ServeAdults) - mux.HandleFunc("GET /api/juniors", h.ServeJuniors) - mux.HandleFunc("GET /api/payments", h.ServePayments) + + // HTML routes + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/adults", http.StatusFound) + }) + 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) 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) - } -} diff --git a/go/internal/web/static/css/app.css b/go/internal/web/static/css/app.css new file mode 100644 index 0000000..998030f --- /dev/null +++ b/go/internal/web/static/css/app.css @@ -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; +} diff --git a/go/internal/web/templates/adults.tmpl b/go/internal/web/templates/adults.tmpl new file mode 100644 index 0000000..7e8b672 --- /dev/null +++ b/go/internal/web/templates/adults.tmpl @@ -0,0 +1,5 @@ +{{define "title"}}Adults{{end}} +{{define "content"}} +

Adults Dashboard

+

Coming in M6.2

+{{end}} diff --git a/go/internal/web/templates/base.tmpl b/go/internal/web/templates/base.tmpl new file mode 100644 index 0000000..6ed3f0d --- /dev/null +++ b/go/internal/web/templates/base.tmpl @@ -0,0 +1,15 @@ +{{define "base"}} + + + + + FUJ — {{template "title" .}} + + + +{{template "nav" .}} +{{template "content" .}} +{{template "footer" .}} + + +{{end}} diff --git a/go/internal/web/templates/flush_cache.tmpl b/go/internal/web/templates/flush_cache.tmpl new file mode 100644 index 0000000..a95814e --- /dev/null +++ b/go/internal/web/templates/flush_cache.tmpl @@ -0,0 +1,5 @@ +{{define "title"}}Flush Cache{{end}} +{{define "content"}} +

Flush Cache

+

Coming in M6.6

+{{end}} diff --git a/go/internal/web/templates/juniors.tmpl b/go/internal/web/templates/juniors.tmpl new file mode 100644 index 0000000..ead1262 --- /dev/null +++ b/go/internal/web/templates/juniors.tmpl @@ -0,0 +1,5 @@ +{{define "title"}}Juniors{{end}} +{{define "content"}} +

Juniors Dashboard

+

Coming in M6.3

+{{end}} diff --git a/go/internal/web/templates/partials/footer.tmpl b/go/internal/web/templates/partials/footer.tmpl new file mode 100644 index 0000000..7421db0 --- /dev/null +++ b/go/internal/web/templates/partials/footer.tmpl @@ -0,0 +1,3 @@ +{{define "footer"}} + +{{end}} diff --git a/go/internal/web/templates/partials/nav.tmpl b/go/internal/web/templates/partials/nav.tmpl new file mode 100644 index 0000000..19e3afb --- /dev/null +++ b/go/internal/web/templates/partials/nav.tmpl @@ -0,0 +1,17 @@ +{{define "nav"}} + +{{end}} diff --git a/go/internal/web/templates/payments.tmpl b/go/internal/web/templates/payments.tmpl new file mode 100644 index 0000000..fe059c2 --- /dev/null +++ b/go/internal/web/templates/payments.tmpl @@ -0,0 +1,5 @@ +{{define "title"}}Payments Ledger{{end}} +{{define "content"}} +

Payments Ledger

+

Coming in M6.4

+{{end}} diff --git a/go/internal/web/templates/sync.tmpl b/go/internal/web/templates/sync.tmpl new file mode 100644 index 0000000..6042663 --- /dev/null +++ b/go/internal/web/templates/sync.tmpl @@ -0,0 +1,5 @@ +{{define "title"}}Sync Bank Data{{end}} +{{define "content"}} +

Sync Bank Data

+

Coming in M6.6

+{{end}}