Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1deb0c8a27 | |||
| fcbe848b8a | |||
| 6b0db4616a | |||
| c77650659e | |||
| 6b9935ddbe | |||
| 51b5d57222 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
nebula
|
||||
.osgrep
|
||||
|
||||
292
AGENTS.md
292
AGENTS.md
@@ -1,227 +1,133 @@
|
||||
# AGENTS.md - Nebula Project Guidelines
|
||||
# NEBULA PROJECT KNOWLEDGE BASE
|
||||
|
||||
## Project Overview
|
||||
**Generated:** 2026-01-11 | **Commit:** fcbe848 | **Branch:** feat/pkg
|
||||
|
||||
Nebula is a Go/Templ wallet application with HTMX 4 for server-driven UI and WebAwesome (wa-*) web components.
|
||||
## OVERVIEW
|
||||
|
||||
**Stack**: Go 1.25+ | templ v0.3.977 | htmx 4.0.0-alpha5 | WebAwesome | SQLite WASM
|
||||
Go/Templ wallet UI framework using HTMX 4 for server-driven interactions and WebAwesome (`wa-*`) web components. Hybrid: root is a library (`package nebula`), `cmd/server/` is the reference app.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
```bash
|
||||
# Generate Go code from .templ files (REQUIRED after any .templ changes)
|
||||
templ generate
|
||||
|
||||
# Build the project
|
||||
go build ./...
|
||||
|
||||
# Run development server (serves on :8080)
|
||||
go run main.go
|
||||
|
||||
# Watch mode for templ files
|
||||
templ generate --watch
|
||||
|
||||
# Full build + run + open browser
|
||||
make all
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
nebula/
|
||||
├── models/ # Data types (MVC models)
|
||||
├── views/ # Page templates (.templ)
|
||||
├── layouts/ # Base layouts (DashboardLayout, CenteredLayout)
|
||||
├── components/ # Reusable UI components
|
||||
├── handlers/ # HTTP route handlers
|
||||
├── _migrate/ # HTML prototypes to convert
|
||||
├── HTMX.md # htmx 4 patterns documentation
|
||||
└── SQLC.md # Database schema documentation
|
||||
├── cmd/server/ # App entry: main.go + routes.go (handlers HERE, not handlers/)
|
||||
├── views/ # Page templates (.templ) - XxxPage, XxxContent, XxxWithStepper
|
||||
├── components/ # Reusable UI (sidebar, stepper, navbar, charts)
|
||||
├── layouts/ # Base (HTML shell), App (dashboard), Centered (auth)
|
||||
├── models/ # Data structs + DefaultXxxData() factories
|
||||
├── htmx/ # HTMX 4 context/request/response helpers
|
||||
├── pkg/config/ # Config models for middleware injection
|
||||
├── _migrate/ # HTML prototypes pending conversion (technical debt)
|
||||
├── middleware.go # Injects HTMX + Config into request context
|
||||
├── mount.go # Mount() helper for handler registration
|
||||
├── options.go # WithXxx() functional options
|
||||
└── config.go # DefaultConfig() + Config struct
|
||||
```
|
||||
|
||||
## Code Style
|
||||
## WHERE TO LOOK
|
||||
|
||||
### Go Files
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add route | `cmd/server/routes.go` | Go 1.22+ patterns: `GET /path/{param}` |
|
||||
| Add page | `views/` + `models/` | Create XxxPage + model, register in routes.go |
|
||||
| HTMX patterns | `HTMX.md` | `<hx-partial>`, morphing, SSE, OOB updates |
|
||||
| Modify layout | `layouts/base.templ` | CDN scripts, htmx-config meta tag |
|
||||
| WebAwesome ref | [webawesome.com](https://webawesome.com) | All `wa-*` components |
|
||||
| Migrate HTML | `_migrate/` -> `views/` | See MIGRATION.md |
|
||||
|
||||
- Standard `gofmt` formatting
|
||||
- Package-level types in `models/` directory
|
||||
- Handlers in `handlers/routes.go` with pattern: `handleX(w http.ResponseWriter, r *http.Request)`
|
||||
- Use `r.Header.Get("HX-Request") == "true"` to detect HTMX requests
|
||||
- Return partials for HTMX, full pages for direct navigation
|
||||
## CONVENTIONS
|
||||
|
||||
### Templ Files (.templ)
|
||||
### Naming
|
||||
|
||||
- One main page template per file: `PageName(data ModelType) templ.Component`
|
||||
- Helper functions lowercase: `helperName()`
|
||||
- CSS-in-templ using `templ css` blocks or `<style>` tags
|
||||
- Component parameters use Go types from `models/` package
|
||||
| Type | Pattern | Example |
|
||||
|------|---------|---------|
|
||||
| Page template | `XxxPage(data)` | `DashboardPage(data, tab)` |
|
||||
| Partial (HTMX) | `XxxContent`, `XxxTab` | `DashboardContent(data, tab)` |
|
||||
| OOB update | `XxxWithStepper`, `XxxWithOOB` | `WelcomeStepWithStepper(step)` |
|
||||
| Model | `XxxData`, `XxxState` | `DashboardData`, `LoginState` |
|
||||
| Factory | `DefaultXxxData()` | `DefaultDashboardData()` |
|
||||
| Handler | `handleXxx` | `handleDashboard` |
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Page template | `XxxPage` | `SettingsPage`, `DashboardPage` |
|
||||
| Partial template | `XxxContent`, `XxxTab` | `DashboardContent`, `ProfileTab` |
|
||||
| OOB update | `XxxWithOOB`, `XxxWithStepper` | `LoginStepWithOOB` |
|
||||
| Model struct | PascalCase | `SettingsData`, `ProfileSettings` |
|
||||
| Handler func | `handleXxx` | `handleSettings`, `handleDashboard` |
|
||||
|
||||
## HTMX 4 Patterns
|
||||
|
||||
### Partial Updates
|
||||
|
||||
```html
|
||||
<div hx-get="/endpoint" hx-target="#target" hx-swap="innerHTML">
|
||||
```
|
||||
|
||||
### Out-of-Band Updates
|
||||
### HTMX 4 Request Pattern
|
||||
|
||||
```go
|
||||
templ ComponentWithOOB() {
|
||||
@MainContent()
|
||||
<div id="sidebar" hx-swap-oob="true">
|
||||
@UpdatedSidebar()
|
||||
func handleXxx(w http.ResponseWriter, r *http.Request) {
|
||||
data := models.DefaultXxxData()
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
views.XxxContent(data).Render(r.Context(), w) // Partial
|
||||
return
|
||||
}
|
||||
views.XxxPage(data).Render(r.Context(), w) // Full page
|
||||
}
|
||||
```
|
||||
|
||||
### OOB Updates (Multi-Target)
|
||||
|
||||
```templ
|
||||
templ XxxStepWithStepper(step int) {
|
||||
@XxxStepContent(step)
|
||||
<div id="stepper-container" hx-swap-oob="innerHTML">
|
||||
@components.Stepper(step, steps)
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
### HTMX Request Detection
|
||||
### Templ Syntax Quick Ref
|
||||
|
||||
```go
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
views.PartialContent(data).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
views.FullPage(data).Render(r.Context(), w)
|
||||
```templ
|
||||
if cond { } // Conditional
|
||||
for _, item := range items { } // Loop
|
||||
<input disabled?={ isDisabled } /> // Bool attr
|
||||
<div class={ "base " + extra }> // String interpolation
|
||||
@ChildComponent(props) // Composition
|
||||
@templ.Raw(html) // Raw HTML (avoid)
|
||||
```
|
||||
|
||||
## WebAwesome Components
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
Use `wa-*` web components for UI:
|
||||
| DO NOT | Why |
|
||||
|--------|-----|
|
||||
| Edit `*_templ.go` files | Generated by `templ generate` - changes purged |
|
||||
| Use separate `htmx-ext-*` packages | HTMX 4 bundles extensions at `/dist/ext/` |
|
||||
| Rely on implicit inheritance | Use `:inherited` modifier (`hx-target:inherited`) |
|
||||
| Use `TopBar` or `TopBarStyles` | DEPRECATED - use `wa-page` slots instead |
|
||||
| Manual DOM manipulation | Use `hx-*` attributes; server controls state |
|
||||
| Put handlers in `handlers/` | Docs outdated - use `cmd/server/routes.go` |
|
||||
|
||||
```html
|
||||
<wa-button variant="brand">Click me</wa-button>
|
||||
<wa-card>Content</wa-card>
|
||||
<wa-tab-group>
|
||||
<wa-tab panel="one">Tab 1</wa-tab>
|
||||
<wa-tab-panel name="one">Panel 1</wa-tab-panel>
|
||||
</wa-tab-group>
|
||||
<wa-icon name="user"></wa-icon>
|
||||
<wa-avatar initials="JD"></wa-avatar>
|
||||
```
|
||||
|
||||
## Route Registration
|
||||
|
||||
All routes in `handlers/routes.go`:
|
||||
|
||||
```go
|
||||
func RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /path", handlePath)
|
||||
mux.HandleFunc("POST /path/action", handleAction)
|
||||
}
|
||||
```
|
||||
|
||||
Go 1.22+ routing patterns:
|
||||
- `GET /path` - method prefix
|
||||
- `GET /path/{param}` - path parameters via `r.PathValue("param")`
|
||||
- `GET /path/{param...}` - catch-all
|
||||
|
||||
## Model Pattern
|
||||
|
||||
Models in `models/` package with `DefaultXxxData()` factory functions:
|
||||
|
||||
```go
|
||||
// models/example.go
|
||||
package models
|
||||
|
||||
type ExampleData struct {
|
||||
Field1 string
|
||||
Field2 int
|
||||
Items []Item
|
||||
}
|
||||
|
||||
func DefaultExampleData() ExampleData {
|
||||
return ExampleData{
|
||||
Field1: "default",
|
||||
Items: []Item{...},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Pattern (HTML to Templ)
|
||||
|
||||
When converting `_migrate/*.html` files:
|
||||
|
||||
1. Create model types in `models/xxx.go`
|
||||
2. Create view in `views/xxx.templ`
|
||||
3. Add route in `handlers/routes.go`
|
||||
4. Update sidebar active state in `components/sidebar.templ`
|
||||
|
||||
### Templ Syntax Reference
|
||||
|
||||
```go
|
||||
// Conditionals
|
||||
if condition {
|
||||
<div>shown</div>
|
||||
}
|
||||
|
||||
// Loops
|
||||
for _, item := range items {
|
||||
<div>{ item.Name }</div>
|
||||
}
|
||||
|
||||
// Boolean attributes
|
||||
<input disabled?={ isDisabled } />
|
||||
<wa-tab active?={ tab == "profile" }>
|
||||
|
||||
// String interpolation
|
||||
<div class={ "base " + extraClass }>
|
||||
<div data-id={ item.ID }>
|
||||
|
||||
// Raw HTML (use sparingly)
|
||||
@templ.Raw(htmlString)
|
||||
|
||||
// Component composition
|
||||
@ChildComponent(props)
|
||||
```
|
||||
|
||||
## Testing
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
# Generate templ (REQUIRED after .templ changes)
|
||||
templ generate
|
||||
|
||||
# Run with verbose
|
||||
go test -v ./...
|
||||
# Dev server on :8080
|
||||
go run ./cmd/server
|
||||
# OR
|
||||
make start
|
||||
|
||||
# Run specific package
|
||||
go test ./handlers/...
|
||||
# Full cycle: generate + start + open browser
|
||||
make all
|
||||
|
||||
# Build binary
|
||||
make build # outputs ./nebula
|
||||
|
||||
# Watch mode
|
||||
templ generate --watch
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
## STACK VERSIONS
|
||||
|
||||
### Add New Page
|
||||
- **Go**: 1.25.5 (cutting-edge)
|
||||
- **templ**: v0.3.977
|
||||
- **htmx**: 4.0.0-alpha5 (CDN)
|
||||
- **WebAwesome**: Kit 47c7425b971f443c (CDN)
|
||||
- **D3.js**: v7 (charts)
|
||||
|
||||
1. Create `models/newpage.go` with data types
|
||||
2. Create `views/newpage.templ` with page template
|
||||
3. Add handler in `handlers/routes.go`
|
||||
4. Add nav item in `components/sidebar.templ` if needed
|
||||
## NOTES
|
||||
|
||||
### Add New Component
|
||||
|
||||
1. Create in `components/newcomp.templ`
|
||||
2. Import in views: `import "nebula/components"`
|
||||
3. Use: `@components.NewComp(props)`
|
||||
|
||||
### Modify Layout
|
||||
|
||||
Layouts in `layouts/`:
|
||||
- `base.templ` - HTML document wrapper with htmx CDN
|
||||
- `app.templ` - Dashboard app shell with sidebar
|
||||
- `centered.templ` - Centered auth pages
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **templ**: Template engine - `go install github.com/a-h/templ/cmd/templ@latest`
|
||||
- **htmx 4**: Loaded from CDN in `layouts/base.templ`
|
||||
- **WebAwesome**: Loaded from CDN in `layouts/base.templ`
|
||||
- **No tests exist** - `go test ./...` returns "no test files"
|
||||
- **No CI/CD** - No `.github/workflows/`
|
||||
- **SQLite WASM** mentioned in docs but not implemented
|
||||
- **Documentation references `handlers/`** - doesn't exist; handlers are in `cmd/server/routes.go`
|
||||
- Config split: root `config.go` (app config) vs `pkg/config/` (HTMX feature flags)
|
||||
- Middleware chain: `nebula.Middleware(opts...)(mux)` injects context for templates
|
||||
|
||||
7
Makefile
7
Makefile
@@ -4,10 +4,13 @@ all: gen start open
|
||||
gen:
|
||||
@templ generate
|
||||
|
||||
build:
|
||||
@go build -o nebula ./cmd/server
|
||||
|
||||
start:
|
||||
@go run main.go
|
||||
@go run ./cmd/server
|
||||
|
||||
open:
|
||||
@open http://localhost:8080
|
||||
|
||||
.PHONY: gen start
|
||||
.PHONY: gen build start open all
|
||||
|
||||
29
cmd/server/main.go
Normal file
29
cmd/server/main.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"nebula"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Register application routes
|
||||
registerRoutes(mux)
|
||||
|
||||
// Apply nebula middleware
|
||||
handler := nebula.Middleware(
|
||||
nebula.WithBasePath("/"),
|
||||
nebula.WithAppTitle("Sonr Wallet"),
|
||||
nebula.WithTheme("dark"),
|
||||
nebula.WithTransitions(true),
|
||||
nebula.WithPreload(true),
|
||||
)(mux)
|
||||
|
||||
addr := ":8080"
|
||||
fmt.Printf("Starting server at http://localhost%s\n", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, handler))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"nebula/views"
|
||||
)
|
||||
|
||||
func RegisterRoutes(mux *http.ServeMux) {
|
||||
func registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /", handleWelcome)
|
||||
mux.HandleFunc("GET /welcome", handleWelcome)
|
||||
mux.HandleFunc("GET /welcome/step/{step}", handleWelcomeStep)
|
||||
19
config.go
Normal file
19
config.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"nebula/pkg/config"
|
||||
)
|
||||
|
||||
type Config = config.Config
|
||||
type HTMXConfig = config.HTMXConfig
|
||||
type SSEConfig = config.SSEConfig
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return config.Default()
|
||||
}
|
||||
|
||||
func ConfigFromContext(ctx context.Context) *Config {
|
||||
return config.FromContext(ctx)
|
||||
}
|
||||
36
htmx/context.go
Normal file
36
htmx/context.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package htmx
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKey string
|
||||
|
||||
const htmxContextKey contextKey = "htmx"
|
||||
|
||||
type Context struct {
|
||||
IsHTMX bool
|
||||
IsPartial bool
|
||||
IsBoosted bool
|
||||
Target string
|
||||
Trigger string
|
||||
TriggerID string
|
||||
CurrentURL string
|
||||
}
|
||||
|
||||
func WithContext(ctx context.Context, hc *Context) context.Context {
|
||||
return context.WithValue(ctx, htmxContextKey, hc)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *Context {
|
||||
if hc, ok := ctx.Value(htmxContextKey).(*Context); ok {
|
||||
return hc
|
||||
}
|
||||
return &Context{}
|
||||
}
|
||||
|
||||
func IsHTMXRequest(ctx context.Context) bool {
|
||||
return FromContext(ctx).IsHTMX
|
||||
}
|
||||
|
||||
func IsPartialRequest(ctx context.Context) bool {
|
||||
return FromContext(ctx).IsPartial
|
||||
}
|
||||
51
htmx/request.go
Normal file
51
htmx/request.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package htmx
|
||||
|
||||
import "net/http"
|
||||
|
||||
func IsRequest(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
func IsPartial(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request-Type") == "partial"
|
||||
}
|
||||
|
||||
func IsBoosted(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Boosted") == "true"
|
||||
}
|
||||
|
||||
func IsHistoryRestore(r *http.Request) bool {
|
||||
return r.Header.Get("HX-History-Restore-Request") == "true"
|
||||
}
|
||||
|
||||
func GetTarget(r *http.Request) string {
|
||||
return r.Header.Get("HX-Target")
|
||||
}
|
||||
|
||||
func GetTrigger(r *http.Request) string {
|
||||
return r.Header.Get("HX-Trigger")
|
||||
}
|
||||
|
||||
func GetTriggerName(r *http.Request) string {
|
||||
return r.Header.Get("HX-Trigger-Name")
|
||||
}
|
||||
|
||||
func GetCurrentURL(r *http.Request) string {
|
||||
return r.Header.Get("HX-Current-URL")
|
||||
}
|
||||
|
||||
func GetPromptResponse(r *http.Request) string {
|
||||
return r.Header.Get("HX-Prompt")
|
||||
}
|
||||
|
||||
func ExtractContext(r *http.Request) *Context {
|
||||
return &Context{
|
||||
IsHTMX: IsRequest(r),
|
||||
IsPartial: IsPartial(r),
|
||||
IsBoosted: IsBoosted(r),
|
||||
Target: GetTarget(r),
|
||||
Trigger: GetTrigger(r),
|
||||
TriggerID: GetTrigger(r),
|
||||
CurrentURL: GetCurrentURL(r),
|
||||
}
|
||||
}
|
||||
103
htmx/response.go
Normal file
103
htmx/response.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package htmx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
w http.ResponseWriter
|
||||
}
|
||||
|
||||
func NewResponse(w http.ResponseWriter) *Response {
|
||||
return &Response{w: w}
|
||||
}
|
||||
|
||||
func (r *Response) Header() http.Header {
|
||||
return r.w.Header()
|
||||
}
|
||||
|
||||
func (r *Response) Location(url string) *Response {
|
||||
r.w.Header().Set("HX-Location", url)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) LocationWithTarget(url, target string) *Response {
|
||||
data, _ := json.Marshal(map[string]string{"path": url, "target": target})
|
||||
r.w.Header().Set("HX-Location", string(data))
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) PushURL(url string) *Response {
|
||||
r.w.Header().Set("HX-Push-Url", url)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) ReplaceURL(url string) *Response {
|
||||
r.w.Header().Set("HX-Replace-Url", url)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) Redirect(url string) *Response {
|
||||
r.w.Header().Set("HX-Redirect", url)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) Refresh() *Response {
|
||||
r.w.Header().Set("HX-Refresh", "true")
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) Reswap(strategy string) *Response {
|
||||
r.w.Header().Set("HX-Reswap", strategy)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) ReswapWithModifiers(strategy string) *Response {
|
||||
r.w.Header().Set("HX-Reswap", strategy)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) Retarget(selector string) *Response {
|
||||
r.w.Header().Set("HX-Retarget", selector)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) Reselect(selector string) *Response {
|
||||
r.w.Header().Set("HX-Reselect", selector)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) Trigger(event string) *Response {
|
||||
r.w.Header().Set("HX-Trigger", event)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) TriggerWithData(event string, data any) *Response {
|
||||
payload, _ := json.Marshal(map[string]any{event: data})
|
||||
r.w.Header().Set("HX-Trigger", string(payload))
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) TriggerAfterSwap(event string) *Response {
|
||||
r.w.Header().Set("HX-Trigger-After-Swap", event)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) TriggerAfterSettle(event string) *Response {
|
||||
r.w.Header().Set("HX-Trigger-After-Settle", event)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) StopPolling() *Response {
|
||||
r.w.WriteHeader(286)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Response) NoContent() {
|
||||
r.w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (r *Response) PreventSwap() *Response {
|
||||
return r.Reswap("none")
|
||||
}
|
||||
@@ -1,36 +1,44 @@
|
||||
package layouts
|
||||
|
||||
// Base provides the HTML document structure for all pages
|
||||
import "nebula/pkg/config"
|
||||
|
||||
templ Base(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="wa-cloak wa-theme-dark">
|
||||
<html lang="en" class={ "wa-cloak", config.FromContext(ctx).ThemeClass() }>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="color-scheme" content="dark light"/>
|
||||
<title>{ title } - Sonr Motr Wallet</title>
|
||||
<meta name="htmx-config" content={ config.FromContext(ctx).HTMXConfigJSON() }/>
|
||||
<title>{ title } - { config.FromContext(ctx).AppTitle }</title>
|
||||
<script src="https://kit.webawesome.com/47c7425b971f443c.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js"></script>
|
||||
if config.FromContext(ctx).Preload {
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js"></script>
|
||||
}
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
<style>
|
||||
:root {
|
||||
--wa-color-primary: #17c2ff;
|
||||
--sidebar-width: 64px;
|
||||
}
|
||||
html, body {
|
||||
min-height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: default;
|
||||
}
|
||||
wa-button, a, [onclick], [hx-get], [hx-post] {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@baseStyles()
|
||||
</head>
|
||||
<body>
|
||||
{ children... }
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ baseStyles() {
|
||||
<style>
|
||||
:root {
|
||||
--wa-color-primary: #17c2ff;
|
||||
--sidebar-width: 64px;
|
||||
}
|
||||
html, body {
|
||||
min-height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: default;
|
||||
}
|
||||
wa-button, a, [onclick], [hx-get], [hx-post] {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ package layouts
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
// Base provides the HTML document structure for all pages
|
||||
import "nebula/pkg/config"
|
||||
|
||||
func Base(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
@@ -30,20 +31,86 @@ func Base(title string) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"wa-cloak wa-theme-dark\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"color-scheme\" content=\"dark light\"><title>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 11, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
var templ_7745c5c3_Var2 = []any{"wa-cloak", config.FromContext(ctx).ThemeClass()}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Sonr Motr Wallet</title><script src=\"https://kit.webawesome.com/47c7425b971f443c.js\" crossorigin=\"anonymous\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js\"></script><script src=\"https://cdn.jsdelivr.net/npm/d3@7\"></script><style>\n\t\t\t\t:root {\n\t\t\t\t\t--wa-color-primary: #17c2ff;\n\t\t\t\t\t--sidebar-width: 64px;\n\t\t\t\t}\n\t\t\t\thtml, body {\n\t\t\t\t\tmin-height: 100%;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tcursor: default;\n\t\t\t\t}\n\t\t\t\twa-button, a, [onclick], [hx-get], [hx-post] {\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t}\n\t\t\t</style></head><body>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<html lang=\"en\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"color-scheme\" content=\"dark light\"><meta name=\"htmx-config\" content=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(config.FromContext(ctx).HTMXConfigJSON())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 12, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 13, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " - ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(config.FromContext(ctx).AppTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 13, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</title><script src=\"https://kit.webawesome.com/47c7425b971f443c.js\" crossorigin=\"anonymous\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js\"></script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if config.FromContext(ctx).Preload {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js\"></script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<script src=\"https://cdn.jsdelivr.net/npm/d3@7\"></script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = baseStyles().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</head><body>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -51,7 +118,36 @@ func Base(title string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func baseStyles() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<style>\n\t\t:root {\n\t\t\t--wa-color-primary: #17c2ff;\n\t\t\t--sidebar-width: 64px;\n\t\t}\n\t\thtml, body {\n\t\t\tmin-height: 100%;\n\t\t\tpadding: 0;\n\t\t\tmargin: 0;\n\t\t\tcursor: default;\n\t\t}\n\t\twa-button, a, [onclick], [hx-get], [hx-post] {\n\t\t\tcursor: pointer;\n\t\t}\n\t</style>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
18
main.go
18
main.go
@@ -1,18 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"nebula/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
handlers.RegisterRoutes(mux)
|
||||
|
||||
addr := ":8080"
|
||||
fmt.Printf("Starting server at http://localhost%s\n", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
38
middleware.go
Normal file
38
middleware.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"nebula/htmx"
|
||||
"nebula/pkg/config"
|
||||
)
|
||||
|
||||
func Middleware(opts ...Option) func(http.Handler) http.Handler {
|
||||
cfg := DefaultConfig()
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hxCtx := htmx.ExtractContext(r)
|
||||
ctx := htmx.WithContext(r.Context(), hxCtx)
|
||||
ctx = config.WithContext(ctx, &cfg)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func SSEMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept") == "text/event-stream" {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
40
mount.go
Normal file
40
mount.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Handler creates a new http.Handler with nebula middleware applied.
|
||||
// The handler parameter is wrapped with HTMX context and config middleware.
|
||||
func Handler(handler http.Handler, opts ...Option) http.Handler {
|
||||
cfg := DefaultConfig()
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
var h http.Handler = handler
|
||||
h = Middleware(opts...)(h)
|
||||
|
||||
if cfg.SSE.Enabled {
|
||||
h = SSEMiddleware()(h)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// Mount registers a handler at the configured base path on the provided mux.
|
||||
// Use this to mount your application routes with nebula middleware.
|
||||
func Mount(mux *http.ServeMux, handler http.Handler, opts ...Option) {
|
||||
cfg := DefaultConfig()
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
h := Handler(handler, opts...)
|
||||
|
||||
if cfg.BasePath == "/" {
|
||||
mux.Handle("/", h)
|
||||
} else {
|
||||
mux.Handle(cfg.BasePath+"/", http.StripPrefix(cfg.BasePath, h))
|
||||
}
|
||||
}
|
||||
78
options.go
Normal file
78
options.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package nebula
|
||||
|
||||
type Option func(*Config)
|
||||
|
||||
func WithBasePath(path string) Option {
|
||||
return func(c *Config) {
|
||||
c.BasePath = path
|
||||
}
|
||||
}
|
||||
|
||||
func WithAppTitle(title string) Option {
|
||||
return func(c *Config) {
|
||||
c.AppTitle = title
|
||||
}
|
||||
}
|
||||
|
||||
func WithTheme(theme string) Option {
|
||||
return func(c *Config) {
|
||||
c.Theme = theme
|
||||
}
|
||||
}
|
||||
|
||||
func WithTransitions(enabled bool) Option {
|
||||
return func(c *Config) {
|
||||
c.HTMX.Transitions = enabled
|
||||
}
|
||||
}
|
||||
|
||||
func WithImplicitInheritance(enabled bool) Option {
|
||||
return func(c *Config) {
|
||||
c.HTMX.ImplicitInheritance = enabled
|
||||
}
|
||||
}
|
||||
|
||||
func WithSelfRequestsOnly(enabled bool) Option {
|
||||
return func(c *Config) {
|
||||
c.HTMX.SelfRequestsOnly = enabled
|
||||
}
|
||||
}
|
||||
|
||||
func WithTimeout(ms int) Option {
|
||||
return func(c *Config) {
|
||||
c.HTMX.Timeout = ms
|
||||
}
|
||||
}
|
||||
|
||||
func WithNoSwap(codes ...string) Option {
|
||||
return func(c *Config) {
|
||||
c.HTMX.NoSwap = codes
|
||||
}
|
||||
}
|
||||
|
||||
func WithSSE(enabled bool) Option {
|
||||
return func(c *Config) {
|
||||
c.SSE.Enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
func WithSSEReconnect(enabled bool, delay, maxDelay, maxAttempts int) Option {
|
||||
return func(c *Config) {
|
||||
c.SSE.Reconnect = enabled
|
||||
c.SSE.ReconnectDelay = delay
|
||||
c.SSE.ReconnectMaxDelay = maxDelay
|
||||
c.SSE.ReconnectMaxAttempts = maxAttempts
|
||||
}
|
||||
}
|
||||
|
||||
func WithPreload(enabled bool) Option {
|
||||
return func(c *Config) {
|
||||
c.Preload = enabled
|
||||
}
|
||||
}
|
||||
|
||||
func WithMorphing(enabled bool) Option {
|
||||
return func(c *Config) {
|
||||
c.Morphing = enabled
|
||||
}
|
||||
}
|
||||
106
pkg/config/config.go
Normal file
106
pkg/config/config.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
type Config struct {
|
||||
BasePath string
|
||||
AppTitle string
|
||||
Theme string
|
||||
HTMX HTMXConfig
|
||||
SSE SSEConfig
|
||||
Preload bool
|
||||
Morphing bool
|
||||
}
|
||||
|
||||
type HTMXConfig struct {
|
||||
Transitions bool
|
||||
ImplicitInheritance bool
|
||||
SelfRequestsOnly bool
|
||||
Timeout int
|
||||
NoSwap []string
|
||||
}
|
||||
|
||||
type SSEConfig struct {
|
||||
Enabled bool
|
||||
Reconnect bool
|
||||
ReconnectDelay int
|
||||
ReconnectMaxDelay int
|
||||
ReconnectMaxAttempts int
|
||||
ReconnectJitter float64
|
||||
PauseInBackground bool
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
BasePath: "/",
|
||||
AppTitle: "Sonr Wallet",
|
||||
Theme: "dark",
|
||||
HTMX: HTMXConfig{
|
||||
Transitions: true,
|
||||
ImplicitInheritance: false,
|
||||
SelfRequestsOnly: true,
|
||||
Timeout: 0,
|
||||
NoSwap: []string{"204", "304"},
|
||||
},
|
||||
SSE: SSEConfig{
|
||||
Enabled: true,
|
||||
Reconnect: true,
|
||||
ReconnectDelay: 500,
|
||||
ReconnectMaxDelay: 60000,
|
||||
ReconnectMaxAttempts: 10,
|
||||
ReconnectJitter: 0.3,
|
||||
PauseInBackground: false,
|
||||
},
|
||||
Preload: true,
|
||||
Morphing: true,
|
||||
}
|
||||
}
|
||||
|
||||
func WithContext(ctx context.Context, cfg *Config) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, cfg)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *Config {
|
||||
if cfg, ok := ctx.Value(contextKey{}).(*Config); ok {
|
||||
return cfg
|
||||
}
|
||||
defaultCfg := Default()
|
||||
return &defaultCfg
|
||||
}
|
||||
|
||||
func (c *Config) HTMXConfigJSON() string {
|
||||
cfg := map[string]any{
|
||||
"transitions": c.HTMX.Transitions,
|
||||
"implicitInheritance": c.HTMX.ImplicitInheritance,
|
||||
"selfRequestsOnly": c.HTMX.SelfRequestsOnly,
|
||||
"timeout": c.HTMX.Timeout,
|
||||
"noSwap": c.HTMX.NoSwap,
|
||||
}
|
||||
|
||||
if c.SSE.Enabled {
|
||||
cfg["streams"] = map[string]any{
|
||||
"reconnect": c.SSE.Reconnect,
|
||||
"reconnectDelay": c.SSE.ReconnectDelay,
|
||||
"reconnectMaxDelay": c.SSE.ReconnectMaxDelay,
|
||||
"reconnectMaxAttempts": c.SSE.ReconnectMaxAttempts,
|
||||
"reconnectJitter": c.SSE.ReconnectJitter,
|
||||
"pauseInBackground": c.SSE.PauseInBackground,
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (c *Config) ThemeClass() string {
|
||||
return fmt.Sprintf("wa-theme-%s", c.Theme)
|
||||
}
|
||||
Reference in New Issue
Block a user