diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..86f9266 --- /dev/null +++ b/cmd/server/main.go @@ -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)) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go new file mode 100644 index 0000000..bdbf0a2 --- /dev/null +++ b/cmd/server/routes.go @@ -0,0 +1,176 @@ +package main + +import ( + "net/http" + "strconv" + + "nebula/models" + "nebula/views" +) + +func registerRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /", handleWelcome) + mux.HandleFunc("GET /welcome", handleWelcome) + mux.HandleFunc("GET /welcome/step/{step}", handleWelcomeStep) + + mux.HandleFunc("GET /register", handleRegister) + mux.HandleFunc("GET /register/step/{step}", handleRegisterStep) + mux.HandleFunc("GET /register/capabilities", handleRegisterCapabilities) + mux.HandleFunc("POST /register/verify-code", handleRegisterVerifyCode) + + mux.HandleFunc("GET /login", handleLogin) + mux.HandleFunc("GET /login/step/{step}", handleLoginStep) + mux.HandleFunc("GET /login/qr-status", handleLoginQRStatus) + + mux.HandleFunc("GET /authorize", handleAuthorize) + mux.HandleFunc("POST /authorize/approve", handleAuthorizeApprove) + mux.HandleFunc("POST /authorize/deny", handleAuthorizeDeny) + + mux.HandleFunc("GET /dashboard", handleDashboard) + + mux.HandleFunc("GET /settings", handleSettings) +} + +// handleWelcome renders the full welcome page at step 1 +func handleWelcome(w http.ResponseWriter, r *http.Request) { + views.WelcomePage(1).Render(r.Context(), w) +} + +// handleWelcomeStep handles HTMX partial updates for step navigation +func handleWelcomeStep(w http.ResponseWriter, r *http.Request) { + stepStr := r.PathValue("step") + step, err := strconv.Atoi(stepStr) + if err != nil || step < 1 || step > 3 { + step = 1 + } + + // Check if this is an HTMX request + if r.Header.Get("HX-Request") == "true" { + // Return step content with OOB stepper update (HTMX 4 pattern) + views.WelcomeStepWithStepper(step).Render(r.Context(), w) + return + } + + views.WelcomePage(step).Render(r.Context(), w) +} + +func handleRegister(w http.ResponseWriter, r *http.Request) { + state := models.RegisterState{Step: 1} + views.RegisterPage(state).Render(r.Context(), w) +} + +func handleRegisterStep(w http.ResponseWriter, r *http.Request) { + stepStr := r.PathValue("step") + step, err := strconv.Atoi(stepStr) + if err != nil || step < 1 || step > 3 { + step = 1 + } + + method := r.URL.Query().Get("method") + if method == "" { + method = "passkey" + } + + state := models.RegisterState{Step: step, Method: method} + + if r.Header.Get("HX-Request") == "true" { + views.RegisterStepWithStepper(state).Render(r.Context(), w) + return + } + + views.RegisterPage(state).Render(r.Context(), w) +} + +func handleRegisterCapabilities(w http.ResponseWriter, r *http.Request) { + caps := models.DeviceCapabilities{ + Platform: true, + CrossPlatform: true, + Conditional: true, + } + views.CapabilitiesResult(caps).Render(r.Context(), w) +} + +func handleRegisterVerifyCode(w http.ResponseWriter, r *http.Request) { + state := models.RegisterState{Step: 3} + if r.Header.Get("HX-Request") == "true" { + views.RegisterStepWithStepper(state).Render(r.Context(), w) + return + } + views.RegisterPage(state).Render(r.Context(), w) +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + state := models.LoginState{Step: "1"} + views.LoginPage(state).Render(r.Context(), w) +} + +func handleLoginStep(w http.ResponseWriter, r *http.Request) { + step := r.PathValue("step") + if step == "" { + step = "1" + } + + state := models.LoginState{Step: step} + + if r.Header.Get("HX-Request") == "true" { + views.LoginStepWithOOB(state).Render(r.Context(), w) + return + } + + views.LoginPage(state).Render(r.Context(), w) +} + +var qrPollCount = 0 + +func handleLoginQRStatus(w http.ResponseWriter, r *http.Request) { + qrPollCount++ + if qrPollCount >= 3 { + qrPollCount = 0 + views.QRStatusSuccess().Render(r.Context(), w) + return + } + views.QRStatusWaiting().Render(r.Context(), w) +} + +func handleAuthorize(w http.ResponseWriter, r *http.Request) { + reqType := r.URL.Query().Get("type") + req := models.DefaultAuthRequest(reqType) + views.AuthorizePage(req).Render(r.Context(), w) +} + +func handleAuthorizeApprove(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + actionType := r.FormValue("type") + if actionType == "" { + actionType = "connect" + } + views.AuthResultSuccess(actionType).Render(r.Context(), w) +} + +func handleAuthorizeDeny(w http.ResponseWriter, r *http.Request) { + views.AuthResultDenied().Render(r.Context(), w) +} + +func handleDashboard(w http.ResponseWriter, r *http.Request) { + tab := r.URL.Query().Get("tab") + if tab == "" { + tab = "overview" + } + data := models.DefaultDashboardData() + + if r.Header.Get("HX-Request") == "true" { + views.DashboardContent(data, tab).Render(r.Context(), w) + return + } + + views.DashboardPage(data, tab).Render(r.Context(), w) +} + +func handleSettings(w http.ResponseWriter, r *http.Request) { + tab := r.URL.Query().Get("tab") + if tab == "" { + tab = "profile" + } + data := models.DefaultSettingsData() + views.SettingsPage(data, tab).Render(r.Context(), w) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..e11d5c8 --- /dev/null +++ b/config.go @@ -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) +} diff --git a/htmx/context.go b/htmx/context.go new file mode 100644 index 0000000..74774b6 --- /dev/null +++ b/htmx/context.go @@ -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 +} diff --git a/htmx/request.go b/htmx/request.go new file mode 100644 index 0000000..493db83 --- /dev/null +++ b/htmx/request.go @@ -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), + } +} diff --git a/htmx/response.go b/htmx/response.go new file mode 100644 index 0000000..eca170b --- /dev/null +++ b/htmx/response.go @@ -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") +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..e6631b2 --- /dev/null +++ b/middleware.go @@ -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) + }) + } +} diff --git a/mount.go b/mount.go new file mode 100644 index 0000000..e691d44 --- /dev/null +++ b/mount.go @@ -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)) + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..495dc66 --- /dev/null +++ b/options.go @@ -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 + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..d6cde65 --- /dev/null +++ b/pkg/config/config.go @@ -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) +} diff --git a/server b/server new file mode 100755 index 0000000..1d37c30 Binary files /dev/null and b/server differ