├── web ├── libs │ ├── lit │ │ ├── src │ │ │ ├── index.ts │ │ │ └── components │ │ │ │ └── sortable-example │ │ │ │ └── sortable-example.ts │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── package.json │ │ ├── tsconfig.json │ │ ├── .gitignore │ │ └── pnpm-lock.yaml │ └── web-components │ │ ├── README.md │ │ └── reverse-component │ │ └── index.ts ├── resources │ ├── static │ │ ├── assets │ │ │ ├── favicon.ico │ │ │ └── northstar.svg │ │ └── datastar │ │ │ └── datastar.js │ ├── assets.go │ ├── static_prod.go │ ├── static_dev.go │ └── styles │ │ └── styles.css └── README.md ├── config ├── config_dev.go ├── config_prod.go └── config.go ├── .vscode ├── settings.json ├── launch.json └── extensions.json ├── staticcheck.conf ├── features ├── monitor │ ├── routes.go │ ├── pages │ │ ├── monitor.templ │ │ └── monitor_templ.go │ └── handlers.go ├── index │ ├── pages │ │ ├── index.templ │ │ └── index_templ.go │ ├── routes.go │ ├── services │ │ └── todo_service.go │ ├── handlers.go │ └── components │ │ ├── todo.templ │ │ └── todo_templ.go ├── reverse │ ├── routes.go │ └── pages │ │ ├── reverse.templ │ │ └── reverse_templ.go ├── sortable │ ├── routes.go │ └── pages │ │ ├── sortable.templ │ │ └── sortable_templ.go ├── counter │ ├── routes.go │ ├── pages │ │ ├── counter.templ │ │ └── counter_templ.go │ └── handlers.go └── common │ ├── components │ ├── shared.templ │ ├── navigation.templ │ ├── shared_templ.go │ └── navigation_templ.go │ └── layouts │ ├── base.templ │ └── base_templ.go ├── Dockerfile ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── .gitignore ├── LICENSE ├── nats └── nats.go ├── router └── router.go ├── Taskfile.yml ├── cmd └── web │ ├── main.go │ ├── build │ └── main.go │ └── downloader │ └── main.go ├── README.md └── go.mod /web/libs/lit/src/index.ts: -------------------------------------------------------------------------------- 1 | export { SortableExample } from './components/sortable-example/sortable-example' -------------------------------------------------------------------------------- /web/resources/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zangster300/northstar/HEAD/web/resources/static/assets/favicon.ico -------------------------------------------------------------------------------- /config/config_dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package config 4 | 5 | func Load() *Config { 6 | cfg := loadBase() 7 | cfg.Environment = Dev 8 | return cfg 9 | } 10 | -------------------------------------------------------------------------------- /config/config_prod.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package config 4 | 5 | func Load() *Config { 6 | cfg := loadBase() 7 | cfg.Environment = Prod 8 | return cfg 9 | } 10 | -------------------------------------------------------------------------------- /web/libs/lit/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 120, 7 | "endOfLine": "lf" 8 | } -------------------------------------------------------------------------------- /web/resources/assets.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | const ( 4 | LibsDirectoryPath = "web/libs" 5 | StylesDirectoryPath = "web/resources/styles" 6 | StaticDirectoryPath = "web/resources/static" 7 | ) 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[templ]": { "editor.defaultFormatter": "a-h.templ" }, 3 | "emmet.includeLanguages": { "templ": "html" }, 4 | "go.useLanguageServer": true, 5 | "gopls": { "build.buildFlags": ["-tags=dev"] }, 6 | "tailwindCSS.includeLanguages": { "templ": "html" }, 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Debug Main", 5 | "type": "go", 6 | "request": "launch", 7 | "mode": "debug", 8 | "program": "${workspaceFolder}/cmd/web", 9 | "buildFlags": "-tags=dev", 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | # This is config file for staticcheck. 2 | # Check https://staticcheck.io/docs/checks/ for check ID. 3 | 4 | # If you need to add ignored checks, pls also add explanation in comments. 5 | 6 | checks = ["all", "-ST1000", "-ST1001", "-ST1003"] 7 | 8 | # ST1001 - Dot imports are discouraged but useful for DSLs. -------------------------------------------------------------------------------- /features/monitor/routes.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | ) 6 | 7 | func SetupRoutes(router chi.Router) error { 8 | handlers := NewHandlers() 9 | 10 | router.Get("/monitor", handlers.MonitorPage) 11 | router.Get("/monitor/events", handlers.MonitorEvents) 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /web/libs/lit/README.md: -------------------------------------------------------------------------------- 1 | # Lit 2 | 3 | This directory holds an example library setup for creating web components powered by [lit](https://lit.dev/) and driven by Datastar 4 | 5 | # Setup 6 | 7 | 1. Install Dependencies 8 | 9 | ```shell 10 | pnpm install 11 | ``` 12 | 13 | 2. [Build](../../../cmd/web/build/main.go#L34) 14 | 15 | ```shell 16 | go run cmd/web/build/main.go 17 | ``` 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/golang:1.25.0-alpine AS build 2 | 3 | RUN apk add --no-cache upx 4 | 5 | WORKDIR /src 6 | COPY . ./ 7 | RUN go mod download 8 | RUN --mount=type=cache,target=/root/.cache/go-build \ 9 | go build -ldflags="-s" -o /bin/main ./cmd/web 10 | RUN upx -9 -k /bin/main 11 | 12 | FROM scratch 13 | ENV PORT=9001 14 | COPY --from=build /bin/main / 15 | ENTRYPOINT ["/main"] 16 | -------------------------------------------------------------------------------- /web/libs/lit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@northstar/lit", 3 | "private": false, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc" 8 | }, 9 | "dependencies": { 10 | "lit": "^3.3.1", 11 | "sortablejs": "^1.15.6" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^24.8.1", 15 | "@types/sortablejs": "^1.15.8", 16 | "typescript": "^5.9.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /web/libs/web-components/README.md: -------------------------------------------------------------------------------- 1 | # Web Components 2 | 3 | This directory holds source code for building [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) driven by Datastar 4 | 5 | # Setup 6 | 7 | 1. Install Dependencies 8 | 9 | ```shell 10 | go mod tidy 11 | ``` 12 | 13 | 2. [Build](../../../cmd/web/build/main.go) 14 | 15 | ```shell 16 | go run cmd/web/build/main.go 17 | ``` 18 | -------------------------------------------------------------------------------- /features/index/pages/index.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "northstar/features/common/components" 5 | "northstar/features/common/layouts" 6 | ) 7 | 8 | templ IndexPage(title string) { 9 | @layouts.Base(title) { 10 |
11 | @components.Navigation(components.PageIndex) 12 |
13 |
14 | } 15 | } 16 | -------------------------------------------------------------------------------- /features/reverse/routes.go: -------------------------------------------------------------------------------- 1 | package reverse 2 | 3 | import ( 4 | "net/http" 5 | 6 | "northstar/features/reverse/pages" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | func SetupRoutes(router chi.Router) error { 12 | router.Get("/reverse", func(w http.ResponseWriter, r *http.Request) { 13 | if err := pages.ReversePage().Render(r.Context(), w); err != nil { 14 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 15 | } 16 | }) 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /features/sortable/routes.go: -------------------------------------------------------------------------------- 1 | package sortable 2 | 3 | import ( 4 | "net/http" 5 | 6 | "northstar/features/sortable/pages" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | func SetupRoutes(router chi.Router) error { 12 | router.Get("/sortable", func(w http.ResponseWriter, r *http.Request) { 13 | if err := pages.SortablePage().Render(r.Context(), w); err != nil { 14 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 15 | } 16 | }) 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /web/resources/static_prod.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package resources 4 | 5 | import ( 6 | "embed" 7 | "log/slog" 8 | "net/http" 9 | 10 | "github.com/benbjohnson/hashfs" 11 | ) 12 | 13 | var ( 14 | //go:embed static 15 | StaticDirectory embed.FS 16 | StaticSys = hashfs.NewFS(StaticDirectory) 17 | ) 18 | 19 | func Handler() http.Handler { 20 | slog.Debug("static assets are embedded") 21 | return hashfs.FileServer(StaticSys) 22 | } 23 | 24 | func StaticPath(path string) string { 25 | return "/" + StaticSys.HashName("static/"+path) 26 | } 27 | -------------------------------------------------------------------------------- /web/resources/static_dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package resources 4 | 5 | import ( 6 | "log/slog" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | func Handler() http.Handler { 12 | slog.Info("static assets are being served directly", "path", StaticDirectoryPath) 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | w.Header().Set("Cache-Control", "no-store") 15 | http.StripPrefix("/static/", http.FileServerFS(os.DirFS(StaticDirectoryPath))).ServeHTTP(w, r) 16 | }) 17 | } 18 | 19 | func StaticPath(path string) string { 20 | return "/static/" + path 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "a-h.templ", 8 | "antfu.iconify", 9 | "bradlc.vscode-tailwindcss", 10 | "golang.go", 11 | "runem.lit-plugin", 12 | ], 13 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 14 | "unwantedRecommendations": [ 15 | 16 | ] 17 | } -------------------------------------------------------------------------------- /features/counter/routes.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/gorilla/sessions" 6 | ) 7 | 8 | func SetupRoutes(router chi.Router, sessionStore sessions.Store) error { 9 | handlers := NewHandlers(sessionStore) 10 | 11 | router.Get("/counter", handlers.CounterPage) 12 | router.Get("/counter/data", handlers.CounterData) 13 | 14 | router.Route("/counter/increment", func(incrementRouter chi.Router) { 15 | incrementRouter.Post("/global", handlers.IncrementGlobal) 16 | incrementRouter.Post("/user", handlers.IncrementUser) 17 | }) 18 | 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /web/libs/lit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowImportingTsExtensions": true, 4 | "experimentalDecorators": true, 5 | "isolatedModules": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2022.Object"], 7 | "module": "ESNext", 8 | "moduleResolution": "bundler", 9 | "noEmit": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "target": "ES2020", 17 | "useDefineForClassFields": false 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /web/libs/web-components/reverse-component/index.ts: -------------------------------------------------------------------------------- 1 | class ReverseComponent extends HTMLElement { 2 | static get observedAttributes() { 3 | return ["name"]; 4 | } 5 | 6 | attributeChangedCallback(name: string, oldValue: string, newValue: string) { 7 | const len = newValue.length; 8 | 9 | let value: string | any[] = Array(len); 10 | 11 | let i = len - 1; 12 | 13 | for (const char of newValue) { 14 | value[i--] = char.codePointAt(0); 15 | } 16 | 17 | value = String.fromCodePoint(...value); 18 | this.dispatchEvent(new CustomEvent("reverse", { detail: { value } })); 19 | } 20 | } 21 | 22 | customElements.define("reverse-component", ReverseComponent); 23 | -------------------------------------------------------------------------------- /features/common/components/shared.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "fmt" 4 | 5 | func KVPairsAttrs(kvPairs ...string) templ.Attributes { 6 | if len(kvPairs)%2 != 0 { 7 | panic("kvPairs must be a multiple of 2") 8 | } 9 | attrs := templ.Attributes{} 10 | for i := 0; i < len(kvPairs); i += 2 { 11 | attrs[kvPairs[i]] = kvPairs[i+1] 12 | } 13 | return attrs 14 | } 15 | 16 | templ Icon(icon string, attrs ...string) { 17 | 18 | } 19 | 20 | templ SseIndicator(signalName string) { 21 |
22 | } 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **Please check if the PR fulfills these requirements** 2 | - [ ] The commit message is descriptive 3 | - [ ] Tests for the changes have been added (for bug fixes / features) 4 | - [ ] Docs have been added / updated (for bug fixes / features) 5 | 6 | 7 | * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 8 | 9 | 10 | 11 | * **What is the current behavior?** (You can also link to an open issue here) 12 | 13 | 14 | 15 | * **What is the new behavior (if this is a feature change)?** 16 | 17 | 18 | 19 | * **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?) 20 | 21 | 22 | 23 | * **Other information**: -------------------------------------------------------------------------------- /web/resources/styles/styles.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @view-transition { 4 | navigation: auto; 5 | } 6 | 7 | @plugin "./daisyui/daisyui.js" { 8 | themes: light --default, dark --prefersdark; 9 | } 10 | 11 | @plugin "./daisyui/daisyui-theme.js" { 12 | name: "datastar"; 13 | 14 | --color-base-100: "#0b1325"; 15 | --color-base-200: "#1e304a"; 16 | --color-base-300: "#3a506b"; 17 | --color-primary: "#c9a75f"; 18 | --color-secondary: "#bfdbfe"; 19 | --color-accent: "#7dd3fc"; 20 | --color-neutral: "#444"; 21 | --color-neutral-content: "#fff"; 22 | --color-info: "#0369a1"; 23 | --color-success: "#69c383"; 24 | --color-warning: "#facc15"; 25 | --color-error: "#e11d48"; 26 | 27 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # source: https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 2 | # ------------------------------------------------------------------------------------------------- 3 | # Allowlisting gitignore template for GO projects prevents us 4 | # from adding various unwanted local files, such as generated 5 | # files, developer configurations or IDE-specific files etc. 6 | # 7 | # Recommended: Go.AllowList.gitignore 8 | 9 | # Ignore everything 10 | * 11 | 12 | # But not these files... 13 | !/.gitignore 14 | 15 | !*.go 16 | !*.templ 17 | !go.sum 18 | !go.mod 19 | 20 | !README.md 21 | !LICENSE 22 | 23 | !web/**/* 24 | 25 | web/resources/static/libs/* 26 | web/resources/static/index.css 27 | 28 | # !Makefile 29 | 30 | # ...even if they are in subdirectories 31 | !*/ -------------------------------------------------------------------------------- /features/reverse/pages/reverse.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "northstar/features/common/components" 5 | "northstar/features/common/layouts" 6 | "northstar/web/resources" 7 | ) 8 | 9 | templ ReversePage() { 10 | @layouts.Base("Reverse Web Component") { 11 | @components.Navigation(components.PageReverse) 12 |
15 |
16 | 20 |

21 | 22 |
23 |
24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - **I'm submitting a ...** 2 | 3 | - [ ] bug report 4 | - [ ] feature request 5 | 6 | - **What is the current behavior?** 7 | 8 | - **If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem** 9 | 10 | - **What is the expected behavior?** 11 | 12 | - **What is the motivation / use case for changing the behavior?** 13 | 14 | - **Please describe your environment:** 15 | 16 | - OS: [ MacOS, Linux, Windows] 17 | - Browser: [all | Chrome XX | Firefox XX | IE XX | Safari XX | Mobile Chrome XX | Android X.X Web Browser | iOS XX Safari | iOS XX UIWebView | iOS XX WKWebView ] 18 | 19 | - **Did you research the bug you are experiencing and read the README at the root of the project in its entirety?:** 20 | 21 | - [ ] Yes 22 | 23 | - **Other information** (e.g. detailed explanation, screenshots, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, gitter, etc) 24 | -------------------------------------------------------------------------------- /features/common/components/navigation.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | type page int 4 | 5 | const ( 6 | PageIndex page = iota 7 | PageCounter 8 | PageMonitor 9 | PageReverse 10 | PageSortable 11 | ) 12 | 13 | templ Navigation(page page) { 14 | 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Nicholas Zanghi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /features/index/routes.go: -------------------------------------------------------------------------------- 1 | package index 2 | 3 | import ( 4 | "northstar/features/index/services" 5 | 6 | "github.com/delaneyj/toolbelt/embeddednats" 7 | "github.com/go-chi/chi/v5" 8 | "github.com/gorilla/sessions" 9 | ) 10 | 11 | func SetupRoutes(router chi.Router, store sessions.Store, ns *embeddednats.Server) error { 12 | todoService, err := services.NewTodoService(ns, store) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | handlers := NewHandlers(todoService) 18 | 19 | router.Get("/", handlers.IndexPage) 20 | 21 | router.Route("/api", func(apiRouter chi.Router) { 22 | apiRouter.Route("/todos", func(todosRouter chi.Router) { 23 | todosRouter.Get("/", handlers.TodosSSE) 24 | todosRouter.Put("/reset", handlers.ResetTodos) 25 | todosRouter.Put("/cancel", handlers.CancelEdit) 26 | todosRouter.Put("/mode/{mode}", handlers.SetMode) 27 | 28 | todosRouter.Route("/{idx}", func(todoRouter chi.Router) { 29 | todoRouter.Post("/toggle", handlers.ToggleTodo) 30 | todoRouter.Route("/edit", func(editRouter chi.Router) { 31 | editRouter.Get("/", handlers.StartEdit) 32 | editRouter.Put("/", handlers.SaveEdit) 33 | }) 34 | todoRouter.Delete("/", handlers.DeleteTodo) 35 | }) 36 | }) 37 | }) 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "sync" 7 | 8 | "github.com/joho/godotenv" 9 | ) 10 | 11 | type Environment string 12 | 13 | const ( 14 | Dev Environment = "dev" 15 | Prod Environment = "prod" 16 | ) 17 | 18 | type Config struct { 19 | Environment Environment 20 | Host string 21 | Port string 22 | LogLevel slog.Level 23 | SessionSecret string 24 | } 25 | 26 | var ( 27 | Global *Config 28 | once sync.Once 29 | ) 30 | 31 | func init() { 32 | once.Do(func() { 33 | Global = Load() 34 | }) 35 | } 36 | 37 | func getEnv(key, fallback string) string { 38 | if val, ok := os.LookupEnv(key); ok { 39 | return val 40 | } 41 | return fallback 42 | } 43 | 44 | func loadBase() *Config { 45 | godotenv.Load() 46 | 47 | return &Config{ 48 | Host: getEnv("HOST", "0.0.0.0"), 49 | Port: getEnv("PORT", "8080"), 50 | LogLevel: func() slog.Level { 51 | switch os.Getenv("LOG_LEVEL") { 52 | case "DEBUG": 53 | return slog.LevelDebug 54 | case "INFO": 55 | return slog.LevelInfo 56 | case "WARN": 57 | return slog.LevelWarn 58 | case "ERROR": 59 | return slog.LevelError 60 | default: 61 | return slog.LevelInfo 62 | } 63 | }(), 64 | SessionSecret: getEnv("SESSION_SECRET", "session-secret"), 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /features/common/layouts/base.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import ( 4 | "northstar/config" 5 | "northstar/web/resources" 6 | ) 7 | 8 | templ Base(title string) { 9 | 10 | 11 | 12 | { title } 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | if config.Global.Environment == config.Dev { 24 |
25 | } 26 | { children... } 27 | 28 | 29 | } 30 | -------------------------------------------------------------------------------- /features/sortable/pages/sortable.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "northstar/features/common/components" 5 | "northstar/features/common/layouts" 6 | "northstar/web/resources" 7 | ) 8 | 9 | templ SortablePage() { 10 | @layouts.Base("Sortable") { 11 | @components.Navigation(components.PageSortable) 12 |
13 |
14 | @components.Icon("material-symbols:warning") 15 |
16 | This example uses lit and SortableJS, you will need to download both libraries before this example will work 17 | Check out this README to learn more 18 |
19 |
20 | 27 | 28 |
29 | } 30 | } 31 | -------------------------------------------------------------------------------- /nats/nats.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | "os" 9 | "strconv" 10 | 11 | "github.com/delaneyj/toolbelt" 12 | "github.com/delaneyj/toolbelt/embeddednats" 13 | natsserver "github.com/nats-io/nats-server/v2/server" 14 | ) 15 | 16 | func SetupNATS(ctx context.Context) (*embeddednats.Server, error) { 17 | natsPort, err := getFreeNatsPort() 18 | if err != nil { 19 | return nil, fmt.Errorf("error obtaining NATS port: %w", err) 20 | } 21 | 22 | ns, err := embeddednats.New(ctx, embeddednats.WithNATSServerOptions(&natsserver.Options{ 23 | JetStream: true, 24 | NoSigs: true, 25 | Port: natsPort, 26 | StoreDir: "data/nats", 27 | })) 28 | 29 | if err != nil { 30 | return nil, fmt.Errorf("error creating embedded nats server: %w", err) 31 | } 32 | 33 | ns.WaitForServer() 34 | slog.Info("NATS started", "port", natsPort) 35 | 36 | return ns, nil 37 | } 38 | 39 | func getFreeNatsPort() (int, error) { 40 | if p, ok := os.LookupEnv("NATS_PORT"); ok { 41 | natsPort, err := strconv.Atoi(p) 42 | if err != nil { 43 | return 0, fmt.Errorf("error parsing NATS_PORT: %w", err) 44 | } 45 | if isPortFree(natsPort) { 46 | return natsPort, nil 47 | } 48 | } 49 | return toolbelt.FreePort() 50 | } 51 | 52 | func isPortFree(port int) bool { 53 | address := fmt.Sprintf(":%d", port) 54 | 55 | ln, err := net.Listen("tcp", address) 56 | if err != nil { 57 | return false 58 | } 59 | 60 | if err := ln.Close(); err != nil { 61 | return false 62 | } 63 | 64 | return true 65 | } 66 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Purpose 2 | 3 | This directory holds web resources 4 | 5 | # Organization 6 | 7 | > [!WARNING] 8 | > If any pathing is updated, make sure to update esbuild [entrypoints](../cmd/web/build/main.go#L28) and pathing in the [`Taskfile.yml` ](../Taskfile.yml) 9 | 10 | ## Libs 11 | 12 | This directory serves as an entrypoint for any custom JS/TS libraries needed for the project to run 13 | 14 | Currently it is being used to hold the following: 15 | 16 | - [Custom Element](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) [Web Components](./libs/web-components/) 17 | - [LitElement](https://lit.dev/) [Web Components](./libs/lit/src/components/) 18 | 19 | ## Resources 20 | 21 | ### Static 22 | 23 | This directory contains static web assets 24 | 25 | ### Styles 26 | 27 | This directory is responsible for styling, it is currently setup to use [TailwindCSS](https://tailwindcss.com/) 28 | 29 | ### `assets.go` 30 | 31 | This file adds some useful pathing variables and sets up the embedded static directory using [hashfs](https://github.com/benbjohnson/hashfs) 32 | 33 | ### `static_dev.go` 34 | 35 | When using the `-tags=dev` build tag, this file supplies an http handler function that serves static assets directly without embedding them, and a function for locating them 36 | 37 | ### `static_prod.go` 38 | 39 | When using the `-tags=prod` build tag (or no build tag), this file supplies an http handler function that embeds static assets directly into the binary using [hashfs](https://github.com/benbjohnson/hashfs), and a function for locating them 40 | -------------------------------------------------------------------------------- /features/monitor/pages/monitor.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/starfederation/datastar-go/datastar" 5 | "northstar/features/common/components" 6 | "northstar/features/common/layouts" 7 | ) 8 | 9 | type SystemMonitorSignals struct { 10 | MemTotal string `json:"memTotal,omitempty"` 11 | MemUsed string `json:"memUsed,omitempty"` 12 | MemUsedPercent string `json:"memUsedPercent,omitempty"` 13 | CpuUser string `json:"cpuUser,omitempty"` 14 | CpuSystem string `json:"cpuSystem,omitempty"` 15 | CpuIdle string `json:"cpuIdle,omitempty"` 16 | } 17 | 18 | templ MonitorPage() { 19 | @layouts.Base("System Monitoring") { 20 | @components.Navigation(components.PageMonitor) 21 |
27 |
28 |
29 |

Memory

30 |

Total:

31 |

Used:

32 |

Used (%):

33 |
34 |
35 |

CPU

36 |

User:

37 |

System:

38 |

Idle:

39 |
40 |
41 |
42 | } 43 | } 44 | -------------------------------------------------------------------------------- /features/counter/pages/counter.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/starfederation/datastar-go/datastar" 5 | "northstar/features/common/components" 6 | "northstar/features/common/layouts" 7 | ) 8 | 9 | type CounterSignals struct { 10 | Global uint32 `json:"global"` 11 | User uint32 `json:"user"` 12 | } 13 | 14 | templ CounterButtons() { 15 |
16 | 22 | 28 |
29 | } 30 | 31 | templ CounterCounts() { 32 |
33 |
34 |
Global
35 |
36 |
37 |
38 |
User
39 |
40 |
41 |
42 | } 43 | 44 | templ Counter(signals CounterSignals) { 45 |
50 | @CounterButtons() 51 | @CounterCounts() 52 |
53 | } 54 | 55 | templ CounterPage() { 56 | @layouts.Base("Counter") { 57 | @components.Navigation(components.PageCounter) 58 |
59 |
60 |
61 | } 62 | } 63 | -------------------------------------------------------------------------------- /web/libs/lit/src/components/sortable-example/sortable-example.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit' 2 | import { customElement, property, query } from 'lit/decorators.js' 3 | import Sortable from 'sortablejs' 4 | 5 | interface SortableItem { 6 | name: string 7 | } 8 | 9 | @customElement('sortable-example') 10 | export class SortableExample extends LitElement { 11 | @query('#sortable-container') 12 | sortContainer!: HTMLElement 13 | 14 | @property({ type: String }) title: string = '' 15 | @property({ type: String }) value: string = '' 16 | @property({ type: Array }) items: SortableItem[] = [] 17 | 18 | firstUpdated() { 19 | new Sortable(this.sortContainer, { 20 | animation: 150, 21 | ghostClass: 'opacity-25', 22 | onEnd: (evt) => { 23 | this.value = `Moved from ${evt.oldIndex} to ${evt.newIndex}` 24 | this.dispatchEvent( 25 | new CustomEvent('change', { 26 | detail: `Moved from ${evt.oldIndex} to ${evt.newIndex}`, 27 | }), 28 | ) 29 | }, 30 | }) 31 | } 32 | 33 | protected createRenderRoot() { 34 | return this 35 | } 36 | 37 | render() { 38 | return html` 39 |
40 |
${this.title}: ${this.value}
41 |
Open your console to see event results
42 |
43 | ${this.items?.length > 0 && this.items.map( 44 | (item) => html`
${item.name}
`, 45 | )} 46 |
47 |
48 | ` 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "sync" 9 | 10 | "northstar/config" 11 | counterFeature "northstar/features/counter" 12 | indexFeature "northstar/features/index" 13 | monitorFeature "northstar/features/monitor" 14 | reverseFeature "northstar/features/reverse" 15 | sortableFeature "northstar/features/sortable" 16 | "northstar/web/resources" 17 | 18 | "github.com/delaneyj/toolbelt/embeddednats" 19 | "github.com/go-chi/chi/v5" 20 | "github.com/gorilla/sessions" 21 | "github.com/starfederation/datastar-go/datastar" 22 | ) 23 | 24 | func SetupRoutes(ctx context.Context, router chi.Router, sessionStore *sessions.CookieStore, ns *embeddednats.Server) (err error) { 25 | 26 | if config.Global.Environment == config.Dev { 27 | setupReload(router) 28 | } 29 | 30 | router.Handle("/static/*", resources.Handler()) 31 | 32 | if err := errors.Join( 33 | indexFeature.SetupRoutes(router, sessionStore, ns), 34 | counterFeature.SetupRoutes(router, sessionStore), 35 | monitorFeature.SetupRoutes(router), 36 | sortableFeature.SetupRoutes(router), 37 | reverseFeature.SetupRoutes(router), 38 | ); err != nil { 39 | return fmt.Errorf("error setting up routes: %w", err) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func setupReload(router chi.Router) { 46 | reloadChan := make(chan struct{}, 1) 47 | var hotReloadOnce sync.Once 48 | 49 | router.Get("/reload", func(w http.ResponseWriter, r *http.Request) { 50 | sse := datastar.NewSSE(w, r) 51 | reload := func() { sse.ExecuteScript("window.location.reload()") } 52 | hotReloadOnce.Do(reload) 53 | select { 54 | case <-reloadChan: 55 | reload() 56 | case <-r.Context().Done(): 57 | } 58 | }) 59 | 60 | router.Get("/hotreload", func(w http.ResponseWriter, r *http.Request) { 61 | select { 62 | case reloadChan <- struct{}{}: 63 | default: 64 | } 65 | w.WriteHeader(http.StatusOK) 66 | w.Write([]byte("OK")) 67 | }) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /web/resources/static/assets/northstar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 22 | 23 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | env: 4 | NATS_PORT: 4222 5 | STATIC_DIR: "web/resources/static" 6 | 7 | tasks: 8 | # The `build:` tasks below are used together for production builds of a project 9 | build:templ: 10 | cmds: 11 | - go tool templ generate 12 | sources: 13 | - "**/*.templ" 14 | generates: 15 | - "**/*_templ.go" 16 | 17 | build:web:styles: 18 | cmds: 19 | - go tool gotailwind -i web/resources/styles/styles.css -o $STATIC_DIR/index.css 20 | sources: 21 | - "./web/resources/**/*.{html,ts,templ,go,css}" 22 | generates: 23 | - "{{.STATIC_DIR}}/index.css" 24 | 25 | build:web:bundle: 26 | cmds: 27 | - go run cmd/web/build/main.go 28 | sources: 29 | - "./web/libs/**/**/*.{html,css,ts}" 30 | generates: 31 | - "{{.STATIC_DIR}}/libs/**" 32 | 33 | build: 34 | cmds: 35 | - go build -tags=prod -o bin/main ./cmd/web 36 | deps: 37 | - build:templ 38 | - build:web:styles 39 | - build:web:bundle 40 | 41 | # Use this task to debug with the delve debugger 42 | debug: 43 | cmds: 44 | - go tool dlv exec ./bin/main 45 | deps: 46 | - build 47 | 48 | # Use this task to download latest version of client libs 49 | download: 50 | cmds: 51 | - go run cmd/downloader/main.go 52 | 53 | # The `live:` tasks below are used together for development builds and will live-reload the server 54 | live:templ: 55 | cmds: 56 | - go tool templ generate -watch 57 | 58 | live:web:styles: 59 | cmds: 60 | - go tool gotailwind -i web/resources/styles/styles.css -o $STATIC_DIR/index.css -w 61 | 62 | live:web:bundle: 63 | cmds: 64 | - go run cmd/web/build/main.go -watch 65 | 66 | live:server: 67 | cmds: 68 | - | 69 | go tool air \ 70 | -build.cmd "go build -tags=dev -o tmp/bin/main ./cmd/web" \ 71 | -build.bin "tmp/bin/main" \ 72 | -build.exclude_dir "data,node_modules,web/resources/libs/datastar/node_modules,web/resources/libs/lit/node_modules" \ 73 | -build.include_ext "go,templ" \ 74 | -misc.clean_on_exit "true" 75 | 76 | live: 77 | deps: 78 | - live:templ 79 | - live:web:styles 80 | - live:web:bundle 81 | - live:server 82 | 83 | run: 84 | cmds: 85 | - ./bin/main 86 | deps: 87 | - build 88 | 89 | default: 90 | cmds: 91 | - task: live 92 | -------------------------------------------------------------------------------- /cmd/web/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | "net/http" 9 | "northstar/config" 10 | "northstar/nats" 11 | "northstar/router" 12 | "os" 13 | "os/signal" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/go-chi/chi/v5" 18 | "github.com/go-chi/chi/v5/middleware" 19 | "github.com/gorilla/sessions" 20 | "golang.org/x/sync/errgroup" 21 | ) 22 | 23 | func main() { 24 | 25 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 26 | defer cancel() 27 | 28 | logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 29 | Level: config.Global.LogLevel, 30 | })) 31 | slog.SetDefault(logger) 32 | 33 | if err := run(ctx); err != nil && err != http.ErrServerClosed { 34 | slog.Error("error running server", "error", err) 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | func run(ctx context.Context) error { 40 | 41 | addr := fmt.Sprintf("%s:%s", config.Global.Host, config.Global.Port) 42 | slog.Info("server started", "addr", addr) 43 | defer slog.Info("server shutdown complete") 44 | 45 | eg, egctx := errgroup.WithContext(ctx) 46 | 47 | r := chi.NewMux() 48 | r.Use( 49 | middleware.Logger, 50 | middleware.Recoverer, 51 | ) 52 | 53 | sessionStore := sessions.NewCookieStore([]byte(config.Global.SessionSecret)) 54 | sessionStore.MaxAge(86400 * 30) 55 | sessionStore.Options.Path = "/" 56 | sessionStore.Options.HttpOnly = true 57 | sessionStore.Options.Secure = false 58 | sessionStore.Options.SameSite = http.SameSiteLaxMode 59 | 60 | ns, err := nats.SetupNATS(ctx) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | if err := router.SetupRoutes(egctx, r, sessionStore, ns); err != nil { 66 | return fmt.Errorf("error setting up routes: %w", err) 67 | } 68 | 69 | srv := &http.Server{ 70 | Addr: addr, 71 | Handler: r, 72 | BaseContext: func(l net.Listener) context.Context { 73 | return egctx 74 | }, 75 | ErrorLog: slog.NewLogLogger( 76 | slog.Default().Handler(), 77 | slog.LevelError, 78 | ), 79 | } 80 | 81 | eg.Go(func() error { 82 | err := srv.ListenAndServe() 83 | if err != nil && err != http.ErrServerClosed { 84 | return fmt.Errorf("server error: %w", err) 85 | } 86 | return nil 87 | }) 88 | 89 | eg.Go(func() error { 90 | <-egctx.Done() 91 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 92 | defer cancel() 93 | 94 | slog.Debug("shutting down server...") 95 | 96 | if err := srv.Shutdown(shutdownCtx); err != nil { 97 | slog.Error("error during shutdown", "error", err) 98 | return err 99 | } 100 | 101 | return nil 102 | }) 103 | 104 | return eg.Wait() 105 | } 106 | -------------------------------------------------------------------------------- /web/libs/lit/.gitignore: -------------------------------------------------------------------------------- 1 | # source: https://github.com/github/gitignore/tree/main 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | # ------------------------------------------------------------------ -------------------------------------------------------------------------------- /features/counter/handlers.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "net/http" 5 | "sync/atomic" 6 | 7 | "northstar/features/counter/pages" 8 | 9 | "github.com/Jeffail/gabs/v2" 10 | "github.com/gorilla/sessions" 11 | "github.com/starfederation/datastar-go/datastar" 12 | ) 13 | 14 | const ( 15 | sessionKey = "counter" 16 | countKey = "count" 17 | ) 18 | 19 | type Handlers struct { 20 | globalCounter atomic.Uint32 21 | sessionStore sessions.Store 22 | } 23 | 24 | func NewHandlers(sessionStore sessions.Store) *Handlers { 25 | return &Handlers{ 26 | sessionStore: sessionStore, 27 | } 28 | } 29 | 30 | func (h *Handlers) CounterPage(w http.ResponseWriter, r *http.Request) { 31 | if err := pages.CounterPage().Render(r.Context(), w); err != nil { 32 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 33 | } 34 | } 35 | 36 | func (h *Handlers) CounterData(w http.ResponseWriter, r *http.Request) { 37 | userCount, _, err := h.getUserValue(r) 38 | if err != nil { 39 | http.Error(w, err.Error(), http.StatusInternalServerError) 40 | return 41 | } 42 | 43 | store := pages.CounterSignals{ 44 | Global: h.globalCounter.Load(), 45 | User: userCount, 46 | } 47 | 48 | if err := datastar.NewSSE(w, r).PatchElementTempl(pages.Counter(store)); err != nil { 49 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 50 | } 51 | } 52 | 53 | func (h *Handlers) IncrementGlobal(w http.ResponseWriter, r *http.Request) { 54 | update := gabs.New() 55 | h.updateGlobal(update) 56 | 57 | if err := datastar.NewSSE(w, r).MarshalAndPatchSignals(update); err != nil { 58 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 59 | } 60 | } 61 | 62 | func (h *Handlers) IncrementUser(w http.ResponseWriter, r *http.Request) { 63 | val, sess, err := h.getUserValue(r) 64 | if err != nil { 65 | http.Error(w, err.Error(), http.StatusInternalServerError) 66 | return 67 | } 68 | 69 | val++ 70 | sess.Values[countKey] = val 71 | if err := sess.Save(r, w); err != nil { 72 | http.Error(w, err.Error(), http.StatusInternalServerError) 73 | return 74 | } 75 | 76 | update := gabs.New() 77 | h.updateGlobal(update) 78 | if _, err := update.Set(val, "user"); err != nil { 79 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 80 | return 81 | } 82 | 83 | if err := datastar.NewSSE(w, r).MarshalAndPatchSignals(update); err != nil { 84 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 85 | } 86 | } 87 | 88 | func (h *Handlers) getUserValue(r *http.Request) (uint32, *sessions.Session, error) { 89 | session, err := h.sessionStore.Get(r, sessionKey) 90 | if err != nil { 91 | return 0, nil, err 92 | } 93 | 94 | val, ok := session.Values[countKey].(uint32) 95 | if !ok { 96 | val = 0 97 | } 98 | return val, session, nil 99 | } 100 | 101 | func (h *Handlers) updateGlobal(store *gabs.Container) { 102 | _, _ = store.Set(h.globalCounter.Add(1), "global") 103 | } 104 | -------------------------------------------------------------------------------- /features/monitor/handlers.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "math" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "northstar/features/monitor/pages" 12 | 13 | "github.com/dustin/go-humanize" 14 | "github.com/starfederation/datastar-go/datastar" 15 | 16 | "github.com/shirou/gopsutil/v4/cpu" 17 | "github.com/shirou/gopsutil/v4/mem" 18 | ) 19 | 20 | type Handlers struct{} 21 | 22 | func NewHandlers() *Handlers { 23 | return &Handlers{} 24 | } 25 | 26 | func (h *Handlers) MonitorPage(w http.ResponseWriter, r *http.Request) { 27 | if err := pages.MonitorPage().Render(r.Context(), w); err != nil { 28 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 29 | } 30 | } 31 | 32 | func (h *Handlers) MonitorEvents(w http.ResponseWriter, r *http.Request) { 33 | memT := time.NewTicker(time.Second) 34 | defer memT.Stop() 35 | 36 | cpuT := time.NewTicker(time.Second) 37 | defer cpuT.Stop() 38 | 39 | sse := datastar.NewSSE(w, r) 40 | for { 41 | select { 42 | case <-r.Context().Done(): 43 | slog.Debug("client disconnected") 44 | return 45 | 46 | case <-memT.C: 47 | vm, err := mem.VirtualMemory() 48 | if err != nil { 49 | slog.Error("unable to get mem stats", slog.String("error", err.Error())) 50 | return 51 | } 52 | 53 | memStats := pages.SystemMonitorSignals{ 54 | MemTotal: humanize.Bytes(vm.Total), 55 | MemUsed: humanize.Bytes(vm.Used), 56 | MemUsedPercent: fmt.Sprintf("%.2f%%", vm.UsedPercent), 57 | } 58 | 59 | if err := sse.MarshalAndPatchSignals(memStats); err != nil { 60 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 61 | } 62 | 63 | case <-cpuT.C: 64 | cpuTimes, err := cpu.Times(false) 65 | if err != nil { 66 | slog.Error("unable to get cpu stats", slog.String("error", err.Error())) 67 | return 68 | } 69 | 70 | cpuStats := pages.SystemMonitorSignals{ 71 | CpuUser: h.relativeTime(cpuTimes[0].User), 72 | CpuSystem: h.relativeTime(cpuTimes[0].System), 73 | CpuIdle: h.relativeTime(cpuTimes[0].Idle), 74 | } 75 | 76 | if err := sse.MarshalAndPatchSignals(cpuStats); err != nil { 77 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 78 | } 79 | } 80 | } 81 | } 82 | 83 | func (h *Handlers) relativeTime(totalSeconds float64) string { 84 | seconds := int64(math.Round(totalSeconds)) 85 | 86 | days := seconds / (24 * 3600) 87 | seconds %= 24 * 3600 88 | 89 | hours := seconds / 3600 90 | seconds %= 3600 91 | 92 | minutes := seconds / 60 93 | seconds %= 60 94 | 95 | var parts []string 96 | if days > 0 { 97 | parts = append(parts, fmt.Sprintf("%dd", days)) 98 | } 99 | if hours > 0 || len(parts) > 0 { 100 | parts = append(parts, fmt.Sprintf("%dh", hours)) 101 | } 102 | if minutes > 0 || len(parts) > 0 { 103 | parts = append(parts, fmt.Sprintf("%dm", minutes)) 104 | } 105 | parts = append(parts, fmt.Sprintf("%ds", seconds)) 106 | 107 | return strings.Join(parts, " ") 108 | } 109 | -------------------------------------------------------------------------------- /cmd/web/build/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log/slog" 9 | "net/http" 10 | "northstar/config" 11 | "northstar/web/resources" 12 | "os" 13 | "os/signal" 14 | "syscall" 15 | 16 | "github.com/evanw/esbuild/pkg/api" 17 | "golang.org/x/sync/errgroup" 18 | ) 19 | 20 | var ( 21 | watch = false 22 | ) 23 | 24 | func main() { 25 | flag.BoolVar(&watch, "watch", watch, "Enable watcher mode") 26 | flag.Parse() 27 | 28 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) 29 | defer stop() 30 | 31 | if err := run(ctx); err != nil { 32 | slog.Error("failure", "error", err) 33 | os.Exit(1) 34 | } 35 | } 36 | 37 | func run(ctx context.Context) error { 38 | eg, egctx := errgroup.WithContext(ctx) 39 | 40 | eg.Go(func() error { 41 | return build(egctx) 42 | }) 43 | 44 | return eg.Wait() 45 | } 46 | 47 | func build(ctx context.Context) error { 48 | opts := api.BuildOptions{ 49 | EntryPointsAdvanced: []api.EntryPoint{ 50 | { 51 | InputPath: resources.LibsDirectoryPath + "/web-components/reverse-component/index.ts", 52 | OutputPath: "libs/reverse-component", 53 | }, 54 | /* 55 | uncomment the entrypoint below after running pnpm install in the resources.LibsDirectoryPath + /lit directory 56 | esbuild will only be able to find the lit + sortable libraries after doing so 57 | */ 58 | // { 59 | // InputPath: resources.LibsDirectoryPath + "/lit/src/index.ts", 60 | // OutputPath: "libs/sortable-example", 61 | // }, 62 | }, 63 | Bundle: true, 64 | Format: api.FormatESModule, 65 | LogLevel: api.LogLevelInfo, 66 | MinifyIdentifiers: true, 67 | MinifySyntax: true, 68 | MinifyWhitespace: true, 69 | Outdir: resources.StaticDirectoryPath, 70 | Sourcemap: api.SourceMapLinked, 71 | Target: api.ESNext, 72 | Write: true, 73 | } 74 | 75 | if watch { 76 | slog.Info("watching...") 77 | 78 | opts.Plugins = append(opts.Plugins, api.Plugin{ 79 | Name: "hotreload", 80 | Setup: func(build api.PluginBuild) { 81 | build.OnEnd(func(result *api.BuildResult) (api.OnEndResult, error) { 82 | slog.Info("build complete", "errors", len(result.Errors), "warnings", len(result.Warnings)) 83 | if len(result.Errors) == 0 { 84 | http.Get(fmt.Sprintf("http://%s:%s/hotreload", config.Global.Host, config.Global.Port)) 85 | } 86 | return api.OnEndResult{}, nil 87 | }) 88 | }, 89 | }) 90 | 91 | buildCtx, err := api.Context(opts) 92 | if err != nil { 93 | return err 94 | } 95 | defer buildCtx.Dispose() 96 | 97 | if err := buildCtx.Watch(api.WatchOptions{}); err != nil { 98 | return err 99 | } 100 | 101 | <-ctx.Done() 102 | return nil 103 | } 104 | 105 | slog.Info("building...") 106 | 107 | result := api.Build(opts) 108 | 109 | if len(result.Errors) > 0 { 110 | errs := make([]error, len(result.Errors)) 111 | for i, err := range result.Errors { 112 | errs[i] = errors.New(err.Text) 113 | } 114 | return errors.Join(errs...) 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /web/libs/lit/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | lit: 12 | specifier: ^3.3.1 13 | version: 3.3.1 14 | sortablejs: 15 | specifier: ^1.15.6 16 | version: 1.15.6 17 | devDependencies: 18 | '@types/node': 19 | specifier: ^24.8.1 20 | version: 24.8.1 21 | '@types/sortablejs': 22 | specifier: ^1.15.8 23 | version: 1.15.8 24 | typescript: 25 | specifier: ^5.9.3 26 | version: 5.9.3 27 | 28 | packages: 29 | 30 | '@lit-labs/ssr-dom-shim@1.4.0': 31 | resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==} 32 | 33 | '@lit/reactive-element@2.1.1': 34 | resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==} 35 | 36 | '@types/node@24.8.1': 37 | resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==} 38 | 39 | '@types/sortablejs@1.15.8': 40 | resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} 41 | 42 | '@types/trusted-types@2.0.7': 43 | resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 44 | 45 | lit-element@4.2.1: 46 | resolution: {integrity: sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==} 47 | 48 | lit-html@3.3.1: 49 | resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==} 50 | 51 | lit@3.3.1: 52 | resolution: {integrity: sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==} 53 | 54 | sortablejs@1.15.6: 55 | resolution: {integrity: sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==} 56 | 57 | typescript@5.9.3: 58 | resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 59 | engines: {node: '>=14.17'} 60 | hasBin: true 61 | 62 | undici-types@7.14.0: 63 | resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} 64 | 65 | snapshots: 66 | 67 | '@lit-labs/ssr-dom-shim@1.4.0': {} 68 | 69 | '@lit/reactive-element@2.1.1': 70 | dependencies: 71 | '@lit-labs/ssr-dom-shim': 1.4.0 72 | 73 | '@types/node@24.8.1': 74 | dependencies: 75 | undici-types: 7.14.0 76 | 77 | '@types/sortablejs@1.15.8': {} 78 | 79 | '@types/trusted-types@2.0.7': {} 80 | 81 | lit-element@4.2.1: 82 | dependencies: 83 | '@lit-labs/ssr-dom-shim': 1.4.0 84 | '@lit/reactive-element': 2.1.1 85 | lit-html: 3.3.1 86 | 87 | lit-html@3.3.1: 88 | dependencies: 89 | '@types/trusted-types': 2.0.7 90 | 91 | lit@3.3.1: 92 | dependencies: 93 | '@lit/reactive-element': 2.1.1 94 | lit-element: 4.2.1 95 | lit-html: 3.3.1 96 | 97 | sortablejs@1.15.6: {} 98 | 99 | typescript@5.9.3: {} 100 | 101 | undici-types@7.14.0: {} 102 | -------------------------------------------------------------------------------- /features/index/pages/index_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "northstar/features/common/components" 13 | "northstar/features/common/layouts" 14 | ) 15 | 16 | func IndexPage(title string) templ.Component { 17 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 18 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 19 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 20 | return templ_7745c5c3_CtxErr 21 | } 22 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 23 | if !templ_7745c5c3_IsBuffer { 24 | defer func() { 25 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 26 | if templ_7745c5c3_Err == nil { 27 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 28 | } 29 | }() 30 | } 31 | ctx = templ.InitializeContext(ctx) 32 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 33 | if templ_7745c5c3_Var1 == nil { 34 | templ_7745c5c3_Var1 = templ.NopComponent 35 | } 36 | ctx = templ.ClearChildren(ctx) 37 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 38 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 39 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 40 | if !templ_7745c5c3_IsBuffer { 41 | defer func() { 42 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 43 | if templ_7745c5c3_Err == nil { 44 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 45 | } 46 | }() 47 | } 48 | ctx = templ.InitializeContext(ctx) 49 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 50 | if templ_7745c5c3_Err != nil { 51 | return templ_7745c5c3_Err 52 | } 53 | templ_7745c5c3_Err = components.Navigation(components.PageIndex).Render(ctx, templ_7745c5c3_Buffer) 54 | if templ_7745c5c3_Err != nil { 55 | return templ_7745c5c3_Err 56 | } 57 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") 71 | if templ_7745c5c3_Err != nil { 72 | return templ_7745c5c3_Err 73 | } 74 | return nil 75 | }) 76 | templ_7745c5c3_Err = layouts.Base(title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 77 | if templ_7745c5c3_Err != nil { 78 | return templ_7745c5c3_Err 79 | } 80 | return nil 81 | }) 82 | } 83 | 84 | var _ = templruntime.GeneratedTemplate 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NORTHSTAR 2 | 3 | # Stack 4 | 5 | - [Go](https://go.dev/doc/) 6 | - [NATS](https://docs.nats.io/) 7 | - [Datastar](https://github.com/starfederation/datastar) 8 | - [Templ](https://templ.guide/) 9 | - [Tailwind](https://tailwindcss.com/) x [DaisyUI](https://daisyui.com/) 10 | 11 | # Setup 12 | 13 | 1. Clone this repository 14 | 15 | ```shell 16 | git clone https://github.com/zangster300/northstar.git 17 | ``` 18 | 19 | 2. Install Dependencies 20 | 21 | ```shell 22 | go mod tidy 23 | ``` 24 | 25 | 3. Create 🚀 26 | 27 | # Development 28 | 29 | Live Reload is setup out of the box - powered by [Air](https://github.com/air-verse/air) + [esbuild](cmd/web/build/main.go) 30 | 31 | Use the [live task](./Taskfile.yml#L76) from the [Taskfile](https://taskfile.dev/) to start with live reload setup 32 | 33 | ```shell 34 | go tool task live 35 | ``` 36 | 37 | Navigate to [`http://localhost:8080`](http://localhost:8080) in your favorite web browser to begin 38 | 39 | ## Debugging 40 | 41 | The [debug task](./Taskfile.yml#L42) will launch [delve](https://github.com/go-delve/delve) to begin a debugging session with your project's binary 42 | 43 | ```shell 44 | go tool task debug 45 | ``` 46 | 47 | ## IDE Support 48 | 49 | - [Templ / TailwindCSS Support](https://templ.guide/commands-and-tools/ide-support) 50 | 51 | ### Visual Studio Code Integration 52 | 53 | [Reference](https://code.visualstudio.com/docs/languages/go) 54 | 55 | - [launch.json](./.vscode/launch.json) 56 | - [settings.json](./.vscode/settings.json) 57 | 58 | a `Debug Main` configuration has been added to the [launch.json](./.vscode/launch.json) 59 | 60 | # Starting the Server 61 | 62 | ```shell 63 | go tool task run 64 | ``` 65 | 66 | Navigate to [`http://localhost:8080`](http://localhost:8080) in your favorite web browser 67 | 68 | # Deployment 69 | 70 | ## Building an Executable 71 | 72 | The `task build` [task](./Taskfile.yml#L33) will assemble and build a binary 73 | 74 | ## Docker 75 | 76 | ```shell 77 | # build an image 78 | docker build -t northstar:latest . 79 | 80 | # run the image in a container 81 | docker run --name northstar -p 8080:9001 northstar:latest 82 | ``` 83 | 84 | [Dockerfile](./Dockerfile) 85 | 86 | # Contributing 87 | 88 | Completely open to PR's and feature requests 89 | 90 | # References 91 | 92 | ## Server 93 | 94 | - [go](https://go.dev/) 95 | - [nats](https://docs.nats.io/) 96 | - [datastar sdk](https://github.com/starfederation/datastar/tree/develop/sdk) 97 | - [templ](https://templ.guide/) 98 | 99 | ### Embedded NATS 100 | 101 | The NATS server that powers the `TODO` application is [embedded into the web server](./cmd/web/main.go#L60) 102 | 103 | To interface with it, you should install the [nats-cli](https://github.com/nats-io/natscli) 104 | 105 | Here are some commands to inspect and make changes to the bucket backing the `TODO` app: 106 | 107 | ```shell 108 | # list key value buckets 109 | nats kv ls 110 | 111 | # list keys in the `todos` bucket 112 | nats kv ls todos 113 | 114 | # get the value for [key] 115 | nats kv get --raw todos [key] 116 | 117 | # put a value into [key] 118 | nats kv put todos [key] '{"todos":[{"text":"Hello, NATS!","completed":true}],"editingIdx":-1,"mode":0}' 119 | ``` 120 | 121 | ## Web Components x Datastar 122 | 123 | [🔗 Vanilla Web Components Setup](./web/libs/web-components/README.md) 124 | 125 | [🔗 Lit Web Components Setup](./web/libs/lit/README.md) 126 | 127 | ## Client 128 | 129 | - [datastar](https://www.jsdelivr.com/package/gh/starfederation/datastar) 130 | - [tailwindcss](https://tailwindcss.com/) 131 | - [daisyui](https://daisyui.com/) 132 | - [esbuild](https://esbuild.github.io/) 133 | - [lit](https://lit.dev/) 134 | -------------------------------------------------------------------------------- /features/reverse/pages/reverse_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "northstar/features/common/components" 13 | "northstar/features/common/layouts" 14 | "northstar/web/resources" 15 | ) 16 | 17 | func ReversePage() templ.Component { 18 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 19 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 20 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 21 | return templ_7745c5c3_CtxErr 22 | } 23 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 24 | if !templ_7745c5c3_IsBuffer { 25 | defer func() { 26 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 27 | if templ_7745c5c3_Err == nil { 28 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 29 | } 30 | }() 31 | } 32 | ctx = templ.InitializeContext(ctx) 33 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 34 | if templ_7745c5c3_Var1 == nil { 35 | templ_7745c5c3_Var1 = templ.NopComponent 36 | } 37 | ctx = templ.ClearChildren(ctx) 38 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 39 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 40 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 41 | if !templ_7745c5c3_IsBuffer { 42 | defer func() { 43 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 44 | if templ_7745c5c3_Err == nil { 45 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 46 | } 47 | }() 48 | } 49 | ctx = templ.InitializeContext(ctx) 50 | templ_7745c5c3_Err = components.Navigation(components.PageReverse).Render(ctx, templ_7745c5c3_Buffer) 51 | if templ_7745c5c3_Err != nil { 52 | return templ_7745c5c3_Err 53 | } 54 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") 68 | if templ_7745c5c3_Err != nil { 69 | return templ_7745c5c3_Err 70 | } 71 | return nil 72 | }) 73 | templ_7745c5c3_Err = layouts.Base("Reverse Web Component").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 74 | if templ_7745c5c3_Err != nil { 75 | return templ_7745c5c3_Err 76 | } 77 | return nil 78 | }) 79 | } 80 | 81 | var _ = templruntime.GeneratedTemplate 82 | -------------------------------------------------------------------------------- /cmd/web/downloader/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "northstar/web/resources" 10 | "os" 11 | "path/filepath" 12 | "sync" 13 | ) 14 | 15 | func main() { 16 | if err := run(); err != nil { 17 | slog.Error("failure", "error", err) 18 | os.Exit(1) 19 | } 20 | } 21 | 22 | func run() error { 23 | files := map[string]string{ 24 | "https://raw.githubusercontent.com/starfederation/datastar/develop/bundles/datastar.js": resources.StaticDirectoryPath + "/datastar/datastar.js", 25 | "https://raw.githubusercontent.com/starfederation/datastar/develop/bundles/datastar.js.map": resources.StaticDirectoryPath + "/datastar/datastar.js.map", 26 | "https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js": resources.StylesDirectoryPath + "/daisyui/daisyui.js", 27 | "https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js": resources.StylesDirectoryPath + "/daisyui/daisyui-theme.js", 28 | } 29 | 30 | directories := []string{ 31 | resources.StaticDirectoryPath + "/datastar", 32 | resources.StylesDirectoryPath + "/daisyui", 33 | } 34 | 35 | if err := removeDirectories(directories); err != nil { 36 | return err 37 | } 38 | 39 | if err := createDirectories(directories); err != nil { 40 | return err 41 | } 42 | 43 | if err := download(files); err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func removeDirectories(dirs []string) error { 51 | var wg sync.WaitGroup 52 | errCh := make(chan error, len(dirs)) 53 | 54 | for _, path := range dirs { 55 | wg.Go(func() { 56 | if err := os.RemoveAll(path); err != nil { 57 | errCh <- fmt.Errorf("failed to remove static directory [%s]: %w", path, err) 58 | } 59 | }) 60 | } 61 | 62 | wg.Wait() 63 | close(errCh) 64 | 65 | var errs []error 66 | for err := range errCh { 67 | errs = append(errs, err) 68 | } 69 | 70 | if len(errs) > 0 { 71 | return errors.Join(errs...) 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func createDirectories(dirs []string) error { 78 | var wg sync.WaitGroup 79 | errCh := make(chan error, len(dirs)) 80 | 81 | for _, path := range dirs { 82 | wg.Go(func() { 83 | if err := os.MkdirAll(path, 0755); err != nil { 84 | errCh <- fmt.Errorf("failed to create static directory [%s]: %w", path, err) 85 | } 86 | }) 87 | } 88 | 89 | wg.Wait() 90 | close(errCh) 91 | 92 | var errs []error 93 | for err := range errCh { 94 | errs = append(errs, err) 95 | } 96 | 97 | if len(errs) > 0 { 98 | return errors.Join(errs...) 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func download(files map[string]string) error { 105 | var wg sync.WaitGroup 106 | errCh := make(chan error, len(files)) 107 | 108 | for url, filename := range files { 109 | wg.Go(func() { 110 | base := filepath.Base(filename) 111 | slog.Info("downloading...", "file", base, "url", url) 112 | if err := downloadFile(url, filename); err != nil { 113 | errCh <- fmt.Errorf("failed to download [%s]: %w", base, err) 114 | } else { 115 | slog.Info("finished", "file", base) 116 | } 117 | }) 118 | } 119 | 120 | wg.Wait() 121 | close(errCh) 122 | 123 | var errs []error 124 | for err := range errCh { 125 | errs = append(errs, err) 126 | } 127 | 128 | if len(errs) > 0 { 129 | return errors.Join(errs...) 130 | } 131 | 132 | return nil 133 | } 134 | 135 | func downloadFile(url, filename string) error { 136 | resp, err := http.Get(url) 137 | if err != nil { 138 | return fmt.Errorf("failed to download file [%s]: %w", url, err) 139 | } 140 | defer resp.Body.Close() 141 | 142 | if resp.StatusCode != http.StatusOK { 143 | return fmt.Errorf("http status was not OK downloading file [%s]: %s", url, resp.Status) 144 | } 145 | 146 | out, err := os.Create(filename) 147 | if err != nil { 148 | return fmt.Errorf("failed to create file [%s]: %w", filename, err) 149 | } 150 | defer out.Close() 151 | 152 | if _, err := io.Copy(out, resp.Body); err != nil { 153 | return fmt.Errorf("failed to write file [%s]: %w", filename, err) 154 | } 155 | 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /features/monitor/pages/monitor_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/starfederation/datastar-go/datastar" 13 | "northstar/features/common/components" 14 | "northstar/features/common/layouts" 15 | ) 16 | 17 | type SystemMonitorSignals struct { 18 | MemTotal string `json:"memTotal,omitempty"` 19 | MemUsed string `json:"memUsed,omitempty"` 20 | MemUsedPercent string `json:"memUsedPercent,omitempty"` 21 | CpuUser string `json:"cpuUser,omitempty"` 22 | CpuSystem string `json:"cpuSystem,omitempty"` 23 | CpuIdle string `json:"cpuIdle,omitempty"` 24 | } 25 | 26 | func MonitorPage() templ.Component { 27 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 28 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 29 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 30 | return templ_7745c5c3_CtxErr 31 | } 32 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 33 | if !templ_7745c5c3_IsBuffer { 34 | defer func() { 35 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 36 | if templ_7745c5c3_Err == nil { 37 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 38 | } 39 | }() 40 | } 41 | ctx = templ.InitializeContext(ctx) 42 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 43 | if templ_7745c5c3_Var1 == nil { 44 | templ_7745c5c3_Var1 = templ.NopComponent 45 | } 46 | ctx = templ.ClearChildren(ctx) 47 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 48 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 49 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 50 | if !templ_7745c5c3_IsBuffer { 51 | defer func() { 52 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 53 | if templ_7745c5c3_Err == nil { 54 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 55 | } 56 | }() 57 | } 58 | ctx = templ.InitializeContext(ctx) 59 | templ_7745c5c3_Err = components.Navigation(components.PageMonitor).Render(ctx, templ_7745c5c3_Buffer) 60 | if templ_7745c5c3_Err != nil { 61 | return templ_7745c5c3_Err 62 | } 63 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Memory

Total:

Used:

Used (%):

CPU

User:

System:

Idle:

") 77 | if templ_7745c5c3_Err != nil { 78 | return templ_7745c5c3_Err 79 | } 80 | return nil 81 | }) 82 | templ_7745c5c3_Err = layouts.Base("System Monitoring").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 83 | if templ_7745c5c3_Err != nil { 84 | return templ_7745c5c3_Err 85 | } 86 | return nil 87 | }) 88 | } 89 | 90 | var _ = templruntime.GeneratedTemplate 91 | -------------------------------------------------------------------------------- /features/sortable/pages/sortable_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "northstar/features/common/components" 13 | "northstar/features/common/layouts" 14 | "northstar/web/resources" 15 | ) 16 | 17 | func SortablePage() templ.Component { 18 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 19 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 20 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 21 | return templ_7745c5c3_CtxErr 22 | } 23 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 24 | if !templ_7745c5c3_IsBuffer { 25 | defer func() { 26 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 27 | if templ_7745c5c3_Err == nil { 28 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 29 | } 30 | }() 31 | } 32 | ctx = templ.InitializeContext(ctx) 33 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 34 | if templ_7745c5c3_Var1 == nil { 35 | templ_7745c5c3_Var1 = templ.NopComponent 36 | } 37 | ctx = templ.ClearChildren(ctx) 38 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 39 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 40 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 41 | if !templ_7745c5c3_IsBuffer { 42 | defer func() { 43 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 44 | if templ_7745c5c3_Err == nil { 45 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 46 | } 47 | }() 48 | } 49 | ctx = templ.InitializeContext(ctx) 50 | templ_7745c5c3_Err = components.Navigation(components.PageSortable).Render(ctx, templ_7745c5c3_Buffer) 51 | if templ_7745c5c3_Err != nil { 52 | return templ_7745c5c3_Err 53 | } 54 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 55 | if templ_7745c5c3_Err != nil { 56 | return templ_7745c5c3_Err 57 | } 58 | templ_7745c5c3_Err = components.Icon("material-symbols:warning").Render(ctx, templ_7745c5c3_Buffer) 59 | if templ_7745c5c3_Err != nil { 60 | return templ_7745c5c3_Err 61 | } 62 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
This example uses lit and SortableJS, you will need to download both libraries before this example will work Check out this README to learn more
") 76 | if templ_7745c5c3_Err != nil { 77 | return templ_7745c5c3_Err 78 | } 79 | return nil 80 | }) 81 | templ_7745c5c3_Err = layouts.Base("Sortable").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 82 | if templ_7745c5c3_Err != nil { 83 | return templ_7745c5c3_Err 84 | } 85 | return nil 86 | }) 87 | } 88 | 89 | var _ = templruntime.GeneratedTemplate 90 | -------------------------------------------------------------------------------- /features/common/components/shared_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package components 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import "fmt" 12 | 13 | func KVPairsAttrs(kvPairs ...string) templ.Attributes { 14 | if len(kvPairs)%2 != 0 { 15 | panic("kvPairs must be a multiple of 2") 16 | } 17 | attrs := templ.Attributes{} 18 | for i := 0; i < len(kvPairs); i += 2 { 19 | attrs[kvPairs[i]] = kvPairs[i+1] 20 | } 21 | return attrs 22 | } 23 | 24 | func Icon(icon string, attrs ...string) templ.Component { 25 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 26 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 27 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 28 | return templ_7745c5c3_CtxErr 29 | } 30 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 31 | if !templ_7745c5c3_IsBuffer { 32 | defer func() { 33 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 34 | if templ_7745c5c3_Err == nil { 35 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 36 | } 37 | }() 38 | } 39 | ctx = templ.InitializeContext(ctx) 40 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 41 | if templ_7745c5c3_Var1 == nil { 42 | templ_7745c5c3_Var1 = templ.NopComponent 43 | } 44 | ctx = templ.ClearChildren(ctx) 45 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 67 | if templ_7745c5c3_Err != nil { 68 | return templ_7745c5c3_Err 69 | } 70 | return nil 71 | }) 72 | } 73 | 74 | func SseIndicator(signalName string) templ.Component { 75 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 76 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 77 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 78 | return templ_7745c5c3_CtxErr 79 | } 80 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 81 | if !templ_7745c5c3_IsBuffer { 82 | defer func() { 83 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 84 | if templ_7745c5c3_Err == nil { 85 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 86 | } 87 | }() 88 | } 89 | ctx = templ.InitializeContext(ctx) 90 | templ_7745c5c3_Var3 := templ.GetChildren(ctx) 91 | if templ_7745c5c3_Var3 == nil { 92 | templ_7745c5c3_Var3 = templ.NopComponent 93 | } 94 | ctx = templ.ClearChildren(ctx) 95 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") 109 | if templ_7745c5c3_Err != nil { 110 | return templ_7745c5c3_Err 111 | } 112 | return nil 113 | }) 114 | } 115 | 116 | var _ = templruntime.GeneratedTemplate 117 | -------------------------------------------------------------------------------- /features/index/services/todo_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "northstar/features/index/components" 11 | 12 | "github.com/delaneyj/toolbelt" 13 | "github.com/delaneyj/toolbelt/embeddednats" 14 | "github.com/gorilla/sessions" 15 | "github.com/nats-io/nats.go/jetstream" 16 | "github.com/samber/lo" 17 | ) 18 | 19 | type TodoService struct { 20 | kv jetstream.KeyValue 21 | store sessions.Store 22 | } 23 | 24 | func NewTodoService(ns *embeddednats.Server, store sessions.Store) (*TodoService, error) { 25 | nc, err := ns.Client() 26 | if err != nil { 27 | return nil, fmt.Errorf("error creating nats client: %w", err) 28 | } 29 | 30 | js, err := jetstream.New(nc) 31 | if err != nil { 32 | return nil, fmt.Errorf("error creating jetstream client: %w", err) 33 | } 34 | 35 | kv, err := js.CreateOrUpdateKeyValue(context.Background(), jetstream.KeyValueConfig{ 36 | Bucket: "todos", 37 | Description: "Datastar Todos", 38 | Compression: true, 39 | TTL: time.Hour, 40 | MaxBytes: 16 * 1024 * 1024, 41 | }) 42 | 43 | if err != nil { 44 | return nil, fmt.Errorf("error creating key value: %w", err) 45 | } 46 | 47 | return &TodoService{ 48 | kv: kv, 49 | store: store, 50 | }, nil 51 | } 52 | 53 | func (s *TodoService) GetSessionMVC(w http.ResponseWriter, r *http.Request) (string, *components.TodoMVC, error) { 54 | ctx := r.Context() 55 | sessionID, err := s.upsertSessionID(r, w) 56 | if err != nil { 57 | return "", nil, fmt.Errorf("failed to get session id: %w", err) 58 | } 59 | 60 | mvc := &components.TodoMVC{} 61 | if entry, err := s.kv.Get(ctx, sessionID); err != nil { 62 | if err != jetstream.ErrKeyNotFound { 63 | return "", nil, fmt.Errorf("failed to get key value: %w", err) 64 | } 65 | s.resetMVC(mvc) 66 | 67 | if err := s.saveMVC(ctx, sessionID, mvc); err != nil { 68 | return "", nil, fmt.Errorf("failed to save mvc: %w", err) 69 | } 70 | } else { 71 | if err := json.Unmarshal(entry.Value(), mvc); err != nil { 72 | return "", nil, fmt.Errorf("failed to unmarshal mvc: %w", err) 73 | } 74 | } 75 | return sessionID, mvc, nil 76 | } 77 | 78 | func (s *TodoService) SaveMVC(ctx context.Context, sessionID string, mvc *components.TodoMVC) error { 79 | return s.saveMVC(ctx, sessionID, mvc) 80 | } 81 | 82 | func (s *TodoService) ResetMVC(mvc *components.TodoMVC) { 83 | s.resetMVC(mvc) 84 | } 85 | 86 | func (s *TodoService) WatchUpdates(ctx context.Context, sessionID string) (jetstream.KeyWatcher, error) { 87 | return s.kv.Watch(ctx, sessionID) 88 | } 89 | 90 | func (s *TodoService) ToggleTodo(mvc *components.TodoMVC, index int) { 91 | if index < 0 { 92 | setCompletedTo := false 93 | for _, todo := range mvc.Todos { 94 | if !todo.Completed { 95 | setCompletedTo = true 96 | break 97 | } 98 | } 99 | for _, todo := range mvc.Todos { 100 | todo.Completed = setCompletedTo 101 | } 102 | } else if index < len(mvc.Todos) { 103 | todo := mvc.Todos[index] 104 | todo.Completed = !todo.Completed 105 | } 106 | } 107 | 108 | func (s *TodoService) EditTodo(mvc *components.TodoMVC, index int, text string) { 109 | if index >= 0 && index < len(mvc.Todos) { 110 | mvc.Todos[index].Text = text 111 | } else if index < 0 { 112 | mvc.Todos = append(mvc.Todos, &components.Todo{ 113 | Text: text, 114 | Completed: false, 115 | }) 116 | } 117 | mvc.EditingIdx = -1 118 | } 119 | 120 | func (s *TodoService) DeleteTodo(mvc *components.TodoMVC, index int) { 121 | if index >= 0 && index < len(mvc.Todos) { 122 | mvc.Todos = append(mvc.Todos[:index], mvc.Todos[index+1:]...) 123 | } else if index < 0 { 124 | mvc.Todos = lo.Filter(mvc.Todos, func(todo *components.Todo, i int) bool { 125 | return !todo.Completed 126 | }) 127 | } 128 | } 129 | 130 | func (s *TodoService) SetMode(mvc *components.TodoMVC, mode components.TodoViewMode) { 131 | mvc.Mode = mode 132 | } 133 | 134 | func (s *TodoService) StartEditing(mvc *components.TodoMVC, index int) { 135 | mvc.EditingIdx = index 136 | } 137 | 138 | func (s *TodoService) CancelEditing(mvc *components.TodoMVC) { 139 | mvc.EditingIdx = -1 140 | } 141 | 142 | func (s *TodoService) saveMVC(ctx context.Context, sessionID string, mvc *components.TodoMVC) error { 143 | b, err := json.Marshal(mvc) 144 | if err != nil { 145 | return fmt.Errorf("failed to marshal mvc: %w", err) 146 | } 147 | if _, err := s.kv.Put(ctx, sessionID, b); err != nil { 148 | return fmt.Errorf("failed to put key value: %w", err) 149 | } 150 | return nil 151 | } 152 | 153 | func (s *TodoService) resetMVC(mvc *components.TodoMVC) { 154 | mvc.Mode = components.TodoViewModeAll 155 | mvc.Todos = []*components.Todo{ 156 | {Text: "Learn any backend language", Completed: true}, 157 | {Text: "Learn Datastar", Completed: false}, 158 | {Text: "Create Hypermedia", Completed: false}, 159 | {Text: "???", Completed: false}, 160 | {Text: "Profit", Completed: false}, 161 | } 162 | mvc.EditingIdx = -1 163 | } 164 | 165 | func (s *TodoService) upsertSessionID(r *http.Request, w http.ResponseWriter) (string, error) { 166 | sess, err := s.store.Get(r, "connections") 167 | if err != nil { 168 | return "", fmt.Errorf("failed to get session: %w", err) 169 | } 170 | 171 | id, ok := sess.Values["id"].(string) 172 | 173 | if !ok { 174 | id = toolbelt.NextEncodedID() 175 | sess.Values["id"] = id 176 | if err := sess.Save(r, w); err != nil { 177 | return "", fmt.Errorf("failed to save session: %w", err) 178 | } 179 | } 180 | 181 | return id, nil 182 | } 183 | -------------------------------------------------------------------------------- /features/common/layouts/base_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package layouts 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "northstar/config" 13 | "northstar/web/resources" 14 | ) 15 | 16 | func Base(title string) templ.Component { 17 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 18 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 19 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 20 | return templ_7745c5c3_CtxErr 21 | } 22 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 23 | if !templ_7745c5c3_IsBuffer { 24 | defer func() { 25 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 26 | if templ_7745c5c3_Err == nil { 27 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 28 | } 29 | }() 30 | } 31 | ctx = templ.InitializeContext(ctx) 32 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 33 | if templ_7745c5c3_Var1 == nil { 34 | templ_7745c5c3_Var1 = templ.NopComponent 35 | } 36 | ctx = templ.ClearChildren(ctx) 37 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 38 | if templ_7745c5c3_Err != nil { 39 | return templ_7745c5c3_Err 40 | } 41 | var templ_7745c5c3_Var2 string 42 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) 43 | if templ_7745c5c3_Err != nil { 44 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/common/layouts/base.templ`, Line: 12, Col: 17} 45 | } 46 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 47 | if templ_7745c5c3_Err != nil { 48 | return templ_7745c5c3_Err 49 | } 50 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") 90 | if templ_7745c5c3_Err != nil { 91 | return templ_7745c5c3_Err 92 | } 93 | if config.Global.Environment == config.Dev { 94 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") 95 | if templ_7745c5c3_Err != nil { 96 | return templ_7745c5c3_Err 97 | } 98 | } 99 | templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) 100 | if templ_7745c5c3_Err != nil { 101 | return templ_7745c5c3_Err 102 | } 103 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") 104 | if templ_7745c5c3_Err != nil { 105 | return templ_7745c5c3_Err 106 | } 107 | return nil 108 | }) 109 | } 110 | 111 | var _ = templruntime.GeneratedTemplate 112 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module northstar 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.2.3 7 | golang.org/x/sync v0.19.0 8 | ) 9 | 10 | require ( 11 | github.com/Jeffail/gabs/v2 v2.7.0 12 | github.com/a-h/templ v0.3.960 13 | github.com/benbjohnson/hashfs v0.2.2 14 | github.com/delaneyj/toolbelt v0.7.5 15 | github.com/dustin/go-humanize v1.0.1 16 | github.com/evanw/esbuild v0.27.2 17 | github.com/gorilla/sessions v1.4.0 18 | github.com/joho/godotenv v1.5.1 19 | github.com/nats-io/nats-server/v2 v2.12.3 20 | github.com/nats-io/nats.go v1.48.0 21 | github.com/samber/lo v1.52.0 22 | github.com/shirou/gopsutil/v4 v4.25.11 23 | github.com/starfederation/datastar-go v1.1.0 24 | ) 25 | 26 | require ( 27 | dario.cat/mergo v1.0.1 // indirect 28 | github.com/CAFxX/httpcompression v0.0.9 // indirect 29 | github.com/Ladicle/tabwriter v1.0.0 // indirect 30 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 31 | github.com/Microsoft/go-winio v0.6.2 // indirect 32 | github.com/ProtonMail/go-crypto v1.1.5 // indirect 33 | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect 34 | github.com/air-verse/air v1.61.7 // indirect 35 | github.com/alecthomas/chroma/v2 v2.15.0 // indirect 36 | github.com/andybalholm/brotli v1.2.0 // indirect 37 | github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect 38 | github.com/bep/godartsass v1.2.0 // indirect 39 | github.com/bep/godartsass/v2 v2.1.0 // indirect 40 | github.com/bep/golibsass v1.2.0 // indirect 41 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 42 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 43 | github.com/chainguard-dev/git-urls v1.0.2 // indirect 44 | github.com/chewxy/math32 v1.11.1 // indirect 45 | github.com/cilium/ebpf v0.11.0 // indirect 46 | github.com/cli/browser v1.3.0 // indirect 47 | github.com/cli/safeexec v1.0.1 // indirect 48 | github.com/cloudflare/circl v1.6.0 // indirect 49 | github.com/cosiner/argv v0.1.0 // indirect 50 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 51 | github.com/creack/pty v1.1.24 // indirect 52 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 53 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 54 | github.com/denisbrodbeck/machineid v1.0.1 // indirect 55 | github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d // indirect 56 | github.com/dlclark/regexp2 v1.11.5 // indirect 57 | github.com/dominikbraun/graph v0.23.0 // indirect 58 | github.com/ebitengine/purego v0.9.1 // indirect 59 | github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect 60 | github.com/emirpasic/gods v1.18.1 // indirect 61 | github.com/fatih/color v1.18.0 // indirect 62 | github.com/fsnotify/fsnotify v1.7.0 // indirect 63 | github.com/go-delve/delve v1.24.1 // indirect 64 | github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62 // indirect 65 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 66 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 67 | github.com/go-git/go-git/v5 v5.14.0 // indirect 68 | github.com/go-ole/go-ole v1.3.0 // indirect 69 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 70 | github.com/go-task/task/v3 v3.42.1 // indirect 71 | github.com/go-task/template v0.1.0 // indirect 72 | github.com/gobwas/glob v0.2.3 // indirect 73 | github.com/gohugoio/hugo v0.134.3 // indirect 74 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 75 | github.com/google/go-dap v0.12.0 // indirect 76 | github.com/google/go-tpm v0.9.7 // indirect 77 | github.com/google/uuid v1.6.0 // indirect 78 | github.com/gorilla/securecookie v1.1.2 // indirect 79 | github.com/hashicorp/golang-lru v1.0.2 // indirect 80 | github.com/hookenz/gotailwind/v4 v4.1.13 // indirect 81 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 82 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 83 | github.com/kevinburke/ssh_config v1.2.0 // indirect 84 | github.com/klauspost/compress v1.18.2 // indirect 85 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 86 | github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect 87 | github.com/mattn/go-colorable v0.1.13 // indirect 88 | github.com/mattn/go-isatty v0.0.20 // indirect 89 | github.com/mattn/go-runewidth v0.0.16 // indirect 90 | github.com/mattn/go-zglob v0.0.6 // indirect 91 | github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect 92 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 93 | github.com/natefinch/atomic v1.0.1 // indirect 94 | github.com/nats-io/jwt/v2 v2.8.0 // indirect 95 | github.com/nats-io/nkeys v0.4.12 // indirect 96 | github.com/nats-io/nuid v1.0.1 // indirect 97 | github.com/ncruces/go-strftime v1.0.0 // indirect 98 | github.com/pelletier/go-toml v1.9.5 // indirect 99 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 100 | github.com/pjbgf/sha1cd v0.3.2 // indirect 101 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 102 | github.com/radovskyb/watcher v1.0.7 // indirect 103 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 104 | github.com/rivo/uniseg v0.4.7 // indirect 105 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 106 | github.com/rzajac/zflake v0.8.1 // indirect 107 | github.com/sajari/fuzzy v1.0.0 // indirect 108 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 109 | github.com/skeema/knownhosts v1.3.1 // indirect 110 | github.com/spf13/afero v1.11.0 // indirect 111 | github.com/spf13/cast v1.7.0 // indirect 112 | github.com/spf13/cobra v1.9.1 // indirect 113 | github.com/spf13/pflag v1.0.6 // indirect 114 | github.com/tdewolff/parse/v2 v2.7.15 // indirect 115 | github.com/tklauser/go-sysconf v0.3.16 // indirect 116 | github.com/tklauser/numcpus v0.11.0 // indirect 117 | github.com/valyala/bytebufferpool v1.0.0 // indirect 118 | github.com/xanzy/ssh-agent v0.3.3 // indirect 119 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 120 | github.com/zeebo/xxh3 v1.0.2 // indirect 121 | go.starlark.net v0.0.0-20231101134539-556fd59b42f6 // indirect 122 | golang.org/x/arch v0.11.0 // indirect 123 | golang.org/x/crypto v0.46.0 // indirect 124 | golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect 125 | golang.org/x/mod v0.31.0 // indirect 126 | golang.org/x/net v0.48.0 // indirect 127 | golang.org/x/sys v0.39.0 // indirect 128 | golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect 129 | golang.org/x/term v0.38.0 // indirect 130 | golang.org/x/text v0.32.0 // indirect 131 | golang.org/x/time v0.14.0 // indirect 132 | golang.org/x/tools v0.40.0 // indirect 133 | google.golang.org/protobuf v1.36.11 // indirect 134 | gopkg.in/warnings.v0 v0.1.2 // indirect 135 | gopkg.in/yaml.v3 v3.0.1 // indirect 136 | modernc.org/libc v1.67.1 // indirect 137 | modernc.org/mathutil v1.7.1 // indirect 138 | modernc.org/memory v1.11.0 // indirect 139 | modernc.org/sqlite v1.41.0 // indirect 140 | mvdan.cc/sh/v3 v3.11.0 // indirect 141 | zombiezen.com/go/sqlite v1.4.2 // indirect 142 | ) 143 | 144 | tool ( 145 | github.com/a-h/templ/cmd/templ 146 | github.com/air-verse/air 147 | github.com/go-delve/delve/cmd/dlv 148 | github.com/go-task/task/v3/cmd/task 149 | github.com/hookenz/gotailwind/v4 150 | ) 151 | -------------------------------------------------------------------------------- /features/index/handlers.go: -------------------------------------------------------------------------------- 1 | package index 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "northstar/features/index/components" 9 | "northstar/features/index/pages" 10 | "northstar/features/index/services" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "github.com/starfederation/datastar-go/datastar" 14 | ) 15 | 16 | type Handlers struct { 17 | todoService *services.TodoService 18 | } 19 | 20 | func NewHandlers(todoService *services.TodoService) *Handlers { 21 | return &Handlers{ 22 | todoService: todoService, 23 | } 24 | } 25 | 26 | func (h *Handlers) IndexPage(w http.ResponseWriter, r *http.Request) { 27 | if err := pages.IndexPage("Northstar").Render(r.Context(), w); err != nil { 28 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 29 | } 30 | } 31 | 32 | func (h *Handlers) TodosSSE(w http.ResponseWriter, r *http.Request) { 33 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 34 | if err != nil { 35 | http.Error(w, err.Error(), http.StatusInternalServerError) 36 | return 37 | } 38 | 39 | sse := datastar.NewSSE(w, r) 40 | 41 | // Watch for updates 42 | ctx := r.Context() 43 | watcher, err := h.todoService.WatchUpdates(ctx, sessionID) 44 | if err != nil { 45 | http.Error(w, err.Error(), http.StatusInternalServerError) 46 | return 47 | } 48 | defer watcher.Stop() 49 | 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | return 54 | case entry := <-watcher.Updates(): 55 | if entry == nil { 56 | continue 57 | } 58 | if err := json.Unmarshal(entry.Value(), mvc); err != nil { 59 | http.Error(w, err.Error(), http.StatusInternalServerError) 60 | return 61 | } 62 | c := components.TodosMVCView(mvc) 63 | if err := sse.PatchElementTempl(c); err != nil { 64 | if err := sse.ConsoleError(err); err != nil { 65 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 66 | } 67 | return 68 | } 69 | } 70 | } 71 | } 72 | 73 | func (h *Handlers) ResetTodos(w http.ResponseWriter, r *http.Request) { 74 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 75 | if err != nil { 76 | http.Error(w, err.Error(), http.StatusInternalServerError) 77 | return 78 | } 79 | 80 | h.todoService.ResetMVC(mvc) 81 | if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil { 82 | http.Error(w, err.Error(), http.StatusInternalServerError) 83 | return 84 | } 85 | } 86 | 87 | func (h *Handlers) CancelEdit(w http.ResponseWriter, r *http.Request) { 88 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 89 | sse := datastar.NewSSE(w, r) 90 | if err != nil { 91 | if err := sse.ConsoleError(err); err != nil { 92 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 93 | } 94 | return 95 | } 96 | 97 | h.todoService.CancelEditing(mvc) 98 | if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil { 99 | if err := sse.ConsoleError(err); err != nil { 100 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 101 | } 102 | return 103 | } 104 | } 105 | 106 | func (h *Handlers) SetMode(w http.ResponseWriter, r *http.Request) { 107 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 108 | if err != nil { 109 | http.Error(w, err.Error(), http.StatusInternalServerError) 110 | return 111 | } 112 | 113 | modeStr := chi.URLParam(r, "mode") 114 | modeRaw, err := strconv.Atoi(modeStr) 115 | if err != nil { 116 | http.Error(w, err.Error(), http.StatusBadRequest) 117 | return 118 | } 119 | 120 | mode := components.TodoViewMode(modeRaw) 121 | if mode < components.TodoViewModeAll || mode > components.TodoViewModeCompleted { 122 | http.Error(w, "invalid mode", http.StatusBadRequest) 123 | return 124 | } 125 | 126 | h.todoService.SetMode(mvc, mode) 127 | if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil { 128 | http.Error(w, err.Error(), http.StatusInternalServerError) 129 | return 130 | } 131 | } 132 | 133 | func (h *Handlers) ToggleTodo(w http.ResponseWriter, r *http.Request) { 134 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 135 | sse := datastar.NewSSE(w, r) 136 | if err != nil { 137 | if err := sse.ConsoleError(err); err != nil { 138 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 139 | } 140 | return 141 | } 142 | 143 | i, err := h.parseIndex(w, r) 144 | if err != nil { 145 | if err := sse.ConsoleError(err); err != nil { 146 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 147 | } 148 | return 149 | } 150 | 151 | h.todoService.ToggleTodo(mvc, i) 152 | if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil { 153 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 154 | } 155 | } 156 | 157 | func (h *Handlers) StartEdit(w http.ResponseWriter, r *http.Request) { 158 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 159 | if err != nil { 160 | http.Error(w, err.Error(), http.StatusInternalServerError) 161 | return 162 | } 163 | 164 | i, err := h.parseIndex(w, r) 165 | if err != nil { 166 | return 167 | } 168 | 169 | h.todoService.StartEditing(mvc, i) 170 | if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil { 171 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 172 | } 173 | } 174 | 175 | func (h *Handlers) SaveEdit(w http.ResponseWriter, r *http.Request) { 176 | type Store struct { 177 | Input string `json:"input"` 178 | } 179 | store := &Store{} 180 | 181 | if err := datastar.ReadSignals(r, store); err != nil { 182 | http.Error(w, err.Error(), http.StatusBadRequest) 183 | return 184 | } 185 | 186 | if store.Input == "" { 187 | return 188 | } 189 | 190 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 191 | if err != nil { 192 | http.Error(w, err.Error(), http.StatusInternalServerError) 193 | return 194 | } 195 | 196 | i, err := h.parseIndex(w, r) 197 | if err != nil { 198 | return 199 | } 200 | 201 | h.todoService.EditTodo(mvc, i, store.Input) 202 | if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil { 203 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 204 | } 205 | } 206 | 207 | func (h *Handlers) DeleteTodo(w http.ResponseWriter, r *http.Request) { 208 | i, err := h.parseIndex(w, r) 209 | if err != nil { 210 | return 211 | } 212 | 213 | sessionID, mvc, err := h.todoService.GetSessionMVC(w, r) 214 | if err != nil { 215 | http.Error(w, err.Error(), http.StatusInternalServerError) 216 | return 217 | } 218 | 219 | h.todoService.DeleteTodo(mvc, i) 220 | if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil { 221 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 222 | } 223 | } 224 | 225 | func (h *Handlers) parseIndex(w http.ResponseWriter, r *http.Request) (int, error) { 226 | idx := chi.URLParam(r, "idx") 227 | i, err := strconv.Atoi(idx) 228 | if err != nil { 229 | http.Error(w, err.Error(), http.StatusBadRequest) 230 | return 0, err 231 | } 232 | return i, nil 233 | } 234 | -------------------------------------------------------------------------------- /features/common/components/navigation_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package components 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | type page int 12 | 13 | const ( 14 | PageIndex page = iota 15 | PageCounter 16 | PageMonitor 17 | PageReverse 18 | PageSortable 19 | ) 20 | 21 | func Navigation(page page) templ.Component { 22 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 23 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 24 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 25 | return templ_7745c5c3_CtxErr 26 | } 27 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 28 | if !templ_7745c5c3_IsBuffer { 29 | defer func() { 30 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 31 | if templ_7745c5c3_Err == nil { 32 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 33 | } 34 | }() 35 | } 36 | ctx = templ.InitializeContext(ctx) 37 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 38 | if templ_7745c5c3_Var1 == nil { 39 | templ_7745c5c3_Var1 = templ.NopComponent 40 | } 41 | ctx = templ.ClearChildren(ctx) 42 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 153 | if templ_7745c5c3_Err != nil { 154 | return templ_7745c5c3_Err 155 | } 156 | return nil 157 | }) 158 | } 159 | 160 | var _ = templruntime.GeneratedTemplate 161 | -------------------------------------------------------------------------------- /features/index/components/todo.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "fmt" 5 | "github.com/starfederation/datastar-go/datastar" 6 | common "northstar/features/common/components" 7 | ) 8 | 9 | type TodoViewMode int 10 | 11 | const ( 12 | TodoViewModeAll TodoViewMode = iota 13 | TodoViewModeActive 14 | TodoViewModeCompleted 15 | TodoViewModeLast 16 | ) 17 | 18 | var TodoViewModeStrings = []string{"All", "Active", "Completed"} 19 | 20 | type Todo struct { 21 | Text string `json:"text"` 22 | Completed bool `json:"completed"` 23 | } 24 | 25 | type TodoMVC struct { 26 | Todos []*Todo `json:"todos"` 27 | EditingIdx int `json:"editingIdx"` 28 | Mode TodoViewMode `json:"mode"` 29 | } 30 | 31 | templ TodosMVCView(mvc *TodoMVC) { 32 | {{ 33 | hasTodos := len(mvc.Todos) > 0 34 | left, completed := 0, 0 35 | for _, todo := range mvc.Todos { 36 | if !todo.Completed { 37 | left++ 38 | } else { 39 | completed++ 40 | } 41 | } 42 | input := "" 43 | if mvc.EditingIdx >= 0 { 44 | input = mvc.Todos[mvc.EditingIdx].Text 45 | } 46 | }} 47 |
48 |
52 |
53 |
54 |
55 | @common.Icon("material-symbols:info") 56 |

57 |

58 | This mini application is driven by a 59 | single get request! 60 |
61 | As you interact with the UI, the backend state is updated and new partial HTML fragments are sent down to the client via Server-Sent Events. You can make simple apps or full blown SPA replacements with this pattern. Open your dev tools and watch the network tab to see the magic happen (you will want to look for the "/todos" Network/EventStream tab). 62 |
63 |

64 |
65 |
66 |

todo

67 |
68 |

69 | The input is bound to a local store, but this is not a single page application. It is like having HTMX + Alpine.js but with just one API to learn and much easier to extend. 70 |

71 |
72 | if hasTodos { 73 |
74 | 83 |
84 | } 85 | if mvc.EditingIdx <0 { 86 | @TodoInput(-1) 87 | } 88 | @common.SseIndicator("toggleAllFetching") 89 |
90 |
91 | if hasTodos { 92 |
93 |
    94 | for i, todo := range mvc.Todos { 95 | @TodoRow(mvc.Mode, todo, i, i == mvc.EditingIdx) 96 | } 97 |
98 |
99 |
100 | 101 | 102 | { fmt.Sprint(left) } 103 | if (len(mvc.Todos) > 1) { 104 | items 105 | } else { 106 | item 107 | } 108 | left 109 | 110 |
111 | for i := TodoViewModeAll; i < TodoViewModeLast; i++ { 112 | if i == mvc.Mode { 113 |
{ TodoViewModeStrings[i] }
114 | } else { 115 | 121 | } 122 | } 123 |
124 |
125 | if completed > 0 { 126 |
127 | 133 |
134 | } 135 |
136 | 142 |
143 |
144 |
145 | 148 | } 149 |
150 |
151 |
152 | } 153 | 154 | templ TodoInput(i int) { 155 | = 0 { 167 | data-on:click__outside={ datastar.PutSSE("/api/todos/cancel") } 168 | } 169 | /> 170 | } 171 | 172 | templ TodoRow(mode TodoViewMode, todo *Todo, i int, isEditing bool) { 173 | {{ 174 | indicatorID := fmt.Sprintf("indicator%d", i) 175 | fetchingSignalName := fmt.Sprintf("fetching%d", i) 176 | }} 177 | if isEditing { 178 | @TodoInput(i) 179 | } else if ( 180 | mode == TodoViewModeAll) || 181 | (mode == TodoViewModeActive && !todo.Completed) || 182 | (mode == TodoViewModeCompleted && todo.Completed) { 183 |
  • 184 | 196 | 204 | @common.SseIndicator(fetchingSignalName) 205 | 215 |
  • 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /features/counter/pages/counter_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package pages 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/starfederation/datastar-go/datastar" 13 | "northstar/features/common/components" 14 | "northstar/features/common/layouts" 15 | ) 16 | 17 | type CounterSignals struct { 18 | Global uint32 `json:"global"` 19 | User uint32 `json:"user"` 20 | } 21 | 22 | func CounterButtons() templ.Component { 23 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 24 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 25 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 26 | return templ_7745c5c3_CtxErr 27 | } 28 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 29 | if !templ_7745c5c3_IsBuffer { 30 | defer func() { 31 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 32 | if templ_7745c5c3_Err == nil { 33 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 34 | } 35 | }() 36 | } 37 | ctx = templ.InitializeContext(ctx) 38 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 39 | if templ_7745c5c3_Var1 == nil { 40 | templ_7745c5c3_Var1 = templ.NopComponent 41 | } 42 | ctx = templ.ClearChildren(ctx) 43 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
    ") 70 | if templ_7745c5c3_Err != nil { 71 | return templ_7745c5c3_Err 72 | } 73 | return nil 74 | }) 75 | } 76 | 77 | func CounterCounts() templ.Component { 78 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 79 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 80 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 81 | return templ_7745c5c3_CtxErr 82 | } 83 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 84 | if !templ_7745c5c3_IsBuffer { 85 | defer func() { 86 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 87 | if templ_7745c5c3_Err == nil { 88 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 89 | } 90 | }() 91 | } 92 | ctx = templ.InitializeContext(ctx) 93 | templ_7745c5c3_Var4 := templ.GetChildren(ctx) 94 | if templ_7745c5c3_Var4 == nil { 95 | templ_7745c5c3_Var4 = templ.NopComponent 96 | } 97 | ctx = templ.ClearChildren(ctx) 98 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
    Global
    User
    ") 99 | if templ_7745c5c3_Err != nil { 100 | return templ_7745c5c3_Err 101 | } 102 | return nil 103 | }) 104 | } 105 | 106 | func Counter(signals CounterSignals) templ.Component { 107 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 108 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 109 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 110 | return templ_7745c5c3_CtxErr 111 | } 112 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 113 | if !templ_7745c5c3_IsBuffer { 114 | defer func() { 115 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 116 | if templ_7745c5c3_Err == nil { 117 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 118 | } 119 | }() 120 | } 121 | ctx = templ.InitializeContext(ctx) 122 | templ_7745c5c3_Var5 := templ.GetChildren(ctx) 123 | if templ_7745c5c3_Var5 == nil { 124 | templ_7745c5c3_Var5 = templ.NopComponent 125 | } 126 | ctx = templ.ClearChildren(ctx) 127 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
    ") 141 | if templ_7745c5c3_Err != nil { 142 | return templ_7745c5c3_Err 143 | } 144 | templ_7745c5c3_Err = CounterButtons().Render(ctx, templ_7745c5c3_Buffer) 145 | if templ_7745c5c3_Err != nil { 146 | return templ_7745c5c3_Err 147 | } 148 | templ_7745c5c3_Err = CounterCounts().Render(ctx, templ_7745c5c3_Buffer) 149 | if templ_7745c5c3_Err != nil { 150 | return templ_7745c5c3_Err 151 | } 152 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
    ") 153 | if templ_7745c5c3_Err != nil { 154 | return templ_7745c5c3_Err 155 | } 156 | return nil 157 | }) 158 | } 159 | 160 | func CounterPage() templ.Component { 161 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 162 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 163 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 164 | return templ_7745c5c3_CtxErr 165 | } 166 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 167 | if !templ_7745c5c3_IsBuffer { 168 | defer func() { 169 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 170 | if templ_7745c5c3_Err == nil { 171 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 172 | } 173 | }() 174 | } 175 | ctx = templ.InitializeContext(ctx) 176 | templ_7745c5c3_Var7 := templ.GetChildren(ctx) 177 | if templ_7745c5c3_Var7 == nil { 178 | templ_7745c5c3_Var7 = templ.NopComponent 179 | } 180 | ctx = templ.ClearChildren(ctx) 181 | templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 182 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 183 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 184 | if !templ_7745c5c3_IsBuffer { 185 | defer func() { 186 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 187 | if templ_7745c5c3_Err == nil { 188 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 189 | } 190 | }() 191 | } 192 | ctx = templ.InitializeContext(ctx) 193 | templ_7745c5c3_Err = components.Navigation(components.PageCounter).Render(ctx, templ_7745c5c3_Buffer) 194 | if templ_7745c5c3_Err != nil { 195 | return templ_7745c5c3_Err 196 | } 197 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
    ") 211 | if templ_7745c5c3_Err != nil { 212 | return templ_7745c5c3_Err 213 | } 214 | return nil 215 | }) 216 | templ_7745c5c3_Err = layouts.Base("Counter").Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer) 217 | if templ_7745c5c3_Err != nil { 218 | return templ_7745c5c3_Err 219 | } 220 | return nil 221 | }) 222 | } 223 | 224 | var _ = templruntime.GeneratedTemplate 225 | -------------------------------------------------------------------------------- /web/resources/static/datastar/datastar.js: -------------------------------------------------------------------------------- 1 | // Datastar v1.0.0-RC.7 2 | var at=/🖕JS_DS🚀/.source,je=at.slice(0,5),Ge=at.slice(4),q="datastar-fetch",Z="datastar-signal-patch";var C=Object.hasOwn??Object.prototype.hasOwnProperty.call;var U=e=>e!==null&&typeof e=="object"&&(Object.getPrototypeOf(e)===Object.prototype||Object.getPrototypeOf(e)===null),ct=e=>{for(let t in e)if(C(e,t))return!1;return!0},Y=(e,t)=>{for(let n in e){let r=e[n];U(r)||Array.isArray(r)?Y(r,t):e[n]=t(r)}},Me=e=>{let t={};for(let[n,r]of e){let s=n.split("."),o=s.pop(),i=s.reduce((a,c)=>a[c]??={},t);i[o]=r}return t};var xe=[],Be=[],Oe=0,Le=0,We=0,Ue,j,Ne=0,M=()=>{Oe++},x=()=>{--Oe||(ft(),J())},F=e=>{Ue=j,j=e},P=()=>{j=Ue,Ue=void 0},pe=e=>Ut.bind(0,{previousValue:e,t:e,e:1}),Je=Symbol("computed"),ke=e=>{let t=Jt.bind(0,{e:17,getter:e});return t[Je]=1,t},S=e=>{let t={d:e,e:2};j&&ze(t,j),F(t),M();try{t.d()}finally{x(),P()}return gt.bind(0,t)},ft=()=>{for(;Le"getter"in e?dt(e):mt(e,e.t),dt=e=>{F(e),ht(e);try{let t=e.t;return t!==(e.t=e.getter(t))}finally{P(),yt(e)}},mt=(e,t)=>(e.e=1,e.previousValue!==(e.previousValue=t)),Ke=e=>{let t=e.e;if(!(t&64)){e.e=t|64;let n=e.r;n?Ke(n.o):Be[We++]=e}},pt=(e,t)=>{if(t&16||t&32&&bt(e.s,e)){F(e),ht(e),M();try{e.d()}finally{x(),P(),yt(e)}return}t&32&&(e.e=t&-33);let n=e.s;for(;n;){let r=n.c,s=r.e;s&64&&pt(r,r.e=s&-65),n=n.i}},Ut=(e,...t)=>{if(t.length){if(e.t!==(e.t=t[0])){e.e=17;let r=e.r;return r&&(Kt(r),Oe||ft()),!0}return!1}let n=e.t;if(e.e&16&&mt(e,n)){let r=e.r;r&&Pe(r)}return j&&ze(e,j),n},Jt=e=>{let t=e.e;if(t&16||t&32&&bt(e.s,e)){if(dt(e)){let n=e.r;n&&Pe(n)}}else t&32&&(e.e=t&-33);return j&&ze(e,j),e.t},gt=e=>{let t=e.s;for(;t;)t=Fe(t,e);let n=e.r;n&&Fe(n),e.e=0},ze=(e,t)=>{let n=t.a;if(n&&n.c===e)return;let r=n?n.i:t.s;if(r&&r.c===e){r.m=Ne,t.a=r;return}let s=e.p;if(s&&s.m===Ne&&s.o===t)return;let o=t.a=e.p={m:Ne,c:e,o:t,l:n,i:r,u:s};r&&(r.l=o),n?n.i=o:t.s=o,s?s.n=o:e.r=o},Fe=(e,t=e.o)=>{let n=e.c,r=e.l,s=e.i,o=e.n,i=e.u;if(s?s.l=r:t.a=r,r?r.i=s:t.s=s,o?o.u=i:n.p=i,i)i.n=o;else if(!(n.r=o))if("getter"in n){let a=n.s;if(a){n.e=17;do a=Fe(a,n);while(a)}}else"previousValue"in n||gt(n);return s},Kt=e=>{let t=e.n,n;e:for(;;){let r=e.o,s=r.e;if(s&60?s&12?s&4?!(s&48)&&zt(e,r)?(r.e=s|40,s&=1):s=0:r.e=s&-9|32:s=0:r.e=s|32,s&2&&Ke(r),s&1){let o=r.r;if(o){let i=(e=o).n;i&&(n={t,f:n},t=i);continue}}if(e=t){t=e.n;continue}for(;n;)if(e=n.t,n=n.f,e){t=e.n;continue e}break}},ht=e=>{Ne++,e.a=void 0,e.e=e.e&-57|4},yt=e=>{let t=e.a,n=t?t.i:e.s;for(;n;)n=Fe(n,e);e.e&=-5},bt=(e,t)=>{let n,r=0,s=!1;e:for(;;){let o=e.c,i=o.e;if(t.e&16)s=!0;else if((i&17)===17){if(lt(o)){let a=o.r;a.n&&Pe(a),s=!0}}else if((i&33)===33){(e.n||e.u)&&(n={t:e,f:n}),e=o.s,t=o,++r;continue}if(!s){let a=e.i;if(a){e=a;continue}}for(;r--;){let a=t.r,c=a.n;if(c?(e=n.t,n=n.f):e=a,s){if(lt(t)){c&&Pe(a),t=e.o;continue}s=!1}else t.e&=-33;if(t=e.o,e.i){e=e.i;continue e}}return s}},Pe=e=>{do{let t=e.o,n=t.e;(n&48)===32&&(t.e=n|16,n&2&&Ke(t))}while(e=e.n)},zt=(e,t)=>{let n=t.a;for(;n;){if(n===e)return!0;n=n.l}return!1},oe=e=>{let t=X,n=e.split(".");for(let r of n){if(t==null||!C(t,r))return;t=t[r]}return t},Ce=(e,t="")=>{let n=Array.isArray(e);if(n||U(e)){let r=n?[]:{};for(let o in e)r[o]=pe(Ce(e[o],`${t+o}.`));let s=pe(0);return new Proxy(r,{get(o,i){if(!(i==="toJSON"&&!C(r,i)))return n&&i in Array.prototype?(s(),r[i]):typeof i=="symbol"?r[i]:((!C(r,i)||r[i]()==null)&&(r[i]=pe(""),J(t+i,""),s(s()+1)),r[i]())},set(o,i,a){let c=t+i;if(n&&i==="length"){let l=r[i]-a;if(r[i]=a,l>0){let u={};for(let d=a;d{if(e!==void 0&&t!==void 0&&xe.push([e,t]),!Oe&&xe.length){let n=Me(xe);xe.length=0,document.dispatchEvent(new CustomEvent(Z,{detail:n}))}},O=(e,{ifMissing:t}={})=>{M();for(let n in e)e[n]==null?t||delete X[n]:vt(e[n],n,X,"",t);x()},T=(e,t)=>O(Me(e),t),vt=(e,t,n,r,s)=>{if(U(e)){C(n,t)&&(U(n[t])||Array.isArray(n[t]))||(n[t]={});for(let o in e)e[o]==null?s||delete n[t][o]:vt(e[o],o,n[t],`${r+t}.`,s)}else s&&C(n,t)||(n[t]=e)},ut=e=>typeof e=="string"?RegExp(e.replace(/^\/|\/$/g,"")):e,_=({include:e=/.*/,exclude:t=/(?!)/}={},n=X)=>{let r=ut(e),s=ut(t),o=[],i=[[n,""]];for(;i.length;){let[a,c]=i.pop();for(let l in a){let u=c+l;U(a[l])?i.push([a[l],`${u}.`]):r.test(u)&&!s.test(u)&&o.push([u,oe(u)])}}return Me(o)},X=Ce({});var K=e=>e instanceof HTMLElement||e instanceof SVGElement||e instanceof MathMLElement;var ge=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/([a-z])([0-9]+)/gi,"$1-$2").replace(/([0-9]+)([a-z])/gi,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase();var Et=e=>ge(e).replace(/-/g,"_");var ae=e=>{try{return JSON.parse(e)}catch{return Function(`return (${e})`)()}},St={camel:e=>e.replace(/-[a-z]/g,t=>t[1].toUpperCase()),snake:e=>e.replace(/-/g,"_"),pascal:e=>e[0].toUpperCase()+St.camel(e.slice(1))},L=(e,t,n="camel")=>{for(let r of t.get("case")||[n])e=St[r]?.(e)||e;return e},G=e=>`data-${e}`;var Qt="https://data-star.dev/errors",he=(e,t,n={})=>{Object.assign(n,e);let r=new Error,s=Et(t),o=new URLSearchParams({metadata:JSON.stringify(n)}).toString(),i=JSON.stringify(n,null,2);return r.message=`${t} 3 | More info: ${Qt}/${s}?${o} 4 | Context: ${i}`,r},ye=new Map,Qe=new Map,At=new Map,Rt=new Proxy({},{get:(e,t)=>ye.get(t)?.apply,has:(e,t)=>ye.has(t),ownKeys:()=>Reflect.ownKeys(ye),set:()=>!1,deleteProperty:()=>!1}),be=new Map,He=[],Ze=new Set,Zt=new WeakSet,p=e=>{He.push(e),He.length===1&&setTimeout(()=>{for(let t of He)Ze.add(t.name),Qe.set(t.name,t);He.length=0,nn(),Ze.clear()})},k=e=>{ye.set(e.name,e)};document.addEventListener(q,e=>{let t=At.get(e.detail.type);t&&t.apply({error:he.bind(0,{plugin:{type:"watcher",name:t.name},element:{id:e.target.id,tag:e.target.tagName}})},e.detail.argsRaw)});var ve=e=>{At.set(e.name,e)},Tt=e=>{for(let t of e){let n=be.get(t);if(n&&be.delete(t))for(let r of n.values())for(let s of r.values())s()}},wt=G("ignore"),Yt=`[${wt}]`,Mt=e=>e.hasAttribute(`${wt}__self`)||!!e.closest(Yt),_e=(e,t)=>{for(let n of e)if(!Mt(n))for(let r in n.dataset)xt(n,r.replace(/[A-Z]/g,"-$&").toLowerCase(),n.dataset[r],t)},Xt=e=>{for(let{target:t,type:n,attributeName:r,addedNodes:s,removedNodes:o}of e)if(n==="childList"){for(let i of o)K(i)&&(Tt([i]),Tt(i.querySelectorAll("*")));for(let i of s)K(i)&&(_e([i]),_e(i.querySelectorAll("*")))}else if(n==="attributes"&&r.startsWith("data-")&&K(t)&&!Mt(t)){let i=r.slice(5),a=t.getAttribute(r);if(a===null){let c=be.get(t);if(c){let l=c.get(i);if(l){for(let u of l.values())u();c.delete(i)}}}else xt(t,i,a)}},en=new MutationObserver(Xt),tn=e=>{let[t,...n]=e.split("__"),[r,s]=t.split(/:(.+)/),o=new Map;for(let i of n){let[a,...c]=i.split(".");o.set(a,new Set(c))}return{pluginName:r,key:s,mods:o}};var nn=(e=document.documentElement,t=!0)=>{K(e)&&_e([e],!0),_e(e.querySelectorAll("*"),!0),t&&(en.observe(e,{subtree:!0,childList:!0,attributes:!0}),Zt.add(e))},xt=(e,t,n,r)=>{{let s=t,{pluginName:o,key:i,mods:a}=tn(s),c=Qe.get(o);if((!r||Ze.has(o))&&c){let l={el:e,rawKey:s,mods:a,error:he.bind(0,{plugin:{type:"attribute",name:c.name},element:{id:e.id,tag:e.tagName},expression:{rawKey:s,key:i,value:n}}),key:i,value:n,loadedPluginNames:{actions:new Set(ye.keys()),attributes:new Set(Qe.keys())},rx:void 0},u=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.key)||"allowed",d=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.value)||"allowed",h=i!=null&&i!=="",f=n!=null&&n!=="";if(h){if(u==="denied")throw l.error("KeyNotAllowed")}else if(u==="must")throw l.error("KeyRequired");if(f){if(d==="denied")throw l.error("ValueNotAllowed")}else if(d==="must")throw l.error("ValueRequired");if(u==="exclusive"||d==="exclusive"){if(h&&f)throw l.error("KeyAndValueProvided");if(!h&&!f)throw l.error("KeyOrValueRequired")}let m=new Map;if(f){let v;l.rx=(...A)=>(v||(v=rn(n,{returnsValue:c.returnsValue,argNames:c.argNames,cleanups:m})),v(e,...A))}let y=c.apply(l);y&&m.set("attribute",y);let b=be.get(e);if(b){let v=b.get(s);if(v)for(let A of v.values())A()}else b=new Map,be.set(e,b);b.set(s,m)}}},rn=(e,{returnsValue:t=!1,argNames:n=[],cleanups:r=new Map}={})=>{let s="";if(t){let c=/(\/(\\\/|[^/])*\/|"(\\"|[^"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|\(\s*((function)\s*\(\s*\)|(\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/gm,l=e.trim().match(c);if(l){let u=l.length-1,d=l[u].trim();d.startsWith("return")||(l[u]=`return (${d});`),s=l.join(`; 5 | `)}}else s=e.trim();let o=new Map,i=RegExp(`(?:${je})(.*?)(?:${Ge})`,"gm"),a=0;for(let c of s.matchAll(i)){let l=c[1],u=`__escaped${a++}`;o.set(u,l),s=s.replace(je+l+Ge,u)}s=s.replace(/\$\['([a-zA-Z_$\d][\w$]*)'\]/g,"$$$1").replace(/\$([a-zA-Z_\d]\w*(?:[.-]\w+)*)/g,(c,l)=>l.split(".").reduce((u,d)=>`${u}['${d}']`,"$")),s=s.replaceAll(/@([A-Za-z_$][\w$]*)\(/g,'__action("$1",evt,');for(let[c,l]of o)s=s.replace(c,l);try{let c=Function("el","$","__action","evt",...n,s);return(l,...u)=>{let d=(h,f,...m)=>{let y=he.bind(0,{plugin:{type:"action",name:h},element:{id:l.id,tag:l.tagName},expression:{fnContent:s,value:e}}),b=Rt[h];if(b)return b({el:l,evt:f,error:y,cleanups:r},...m);throw y("UndefinedAction")};try{return c(l,X,d,void 0,...u)}catch(h){throw console.error(h),he({element:{id:l.id,tag:l.tagName},expression:{fnContent:s,value:e},error:h.message},"ExecuteExpression")}}}catch(c){throw console.error(c),he({expression:{fnContent:s,value:e},error:c.message},"GenerateExpression")}};k({name:"peek",apply(e,t){F();try{return t()}finally{P()}}});k({name:"setAll",apply(e,t,n){F();let r=_(n);Y(r,()=>t),O(r),P()}});k({name:"toggleAll",apply(e,t){F();let n=_(t);Y(n,r=>!r),O(n),P()}});var Ee=(e,t,n=!0)=>k({name:e,apply:async({el:r,evt:s,error:o,cleanups:i},a,{selector:c,headers:l,contentType:u="json",filterSignals:{include:d=/.*/,exclude:h=/(^|\.)_/}={},openWhenHidden:f=n,payload:m,requestCancellation:y="auto",retry:b="auto",retryInterval:v=1e3,retryScaler:A=2,retryMaxWaitMs:I=3e4,retryMaxCount:ne=10}={})=>{let de=y instanceof AbortController?y:new AbortController;y==="auto"&&(i.get(`@${e}`)?.(),i.set(`@${e}`,async()=>{de.abort(),await Promise.resolve()}));let D=null;try{if(!a?.length)throw o("FetchNoUrlProvided",{action:k});let V={Accept:"text/event-stream, text/html, application/json","Datastar-Request":!0};u==="json"&&(V["Content-Type"]="application/json");let Ae=Object.assign({},V,l),re={method:t,headers:Ae,openWhenHidden:f,retry:b,retryInterval:v,retryScaler:A,retryMaxWaitMs:I,retryMaxCount:ne,signal:de.signal,onopen:async g=>{g.status>=400&&ee(sn,r,{status:g.status.toString()})},onmessage:g=>{if(!g.event.startsWith("datastar"))return;let B=g.event,E={};for(let R of g.data.split(` 6 | `)){let w=R.indexOf(" "),W=R.slice(0,w),De=R.slice(w+1);(E[W]||=[]).push(De)}let N=Object.fromEntries(Object.entries(E).map(([R,w])=>[R,w.join(` 7 | `)]));ee(B,r,N)},onerror:g=>{if(Lt(g))throw g("FetchExpectedTextEventStream",{url:a});g&&(console.error(g.message),ee(on,r,{message:g.message}))}},se=new URL(a,document.baseURI),ie=new URLSearchParams(se.search);if(u==="json"){F(),m=m!==void 0?m:_({include:d,exclude:h}),P();let g=JSON.stringify(m);t==="GET"?ie.set("datastar",g):re.body=g}else if(u==="form"){let g=c?document.querySelector(c):r.closest("form");if(!g)throw o("FetchFormNotFound",{action:k,selector:c});if(!g.noValidate&&!g.checkValidity()){g.reportValidity();return}let B=new FormData(g),E=r;if(r===g&&s instanceof SubmitEvent)E=s.submitter;else{let w=W=>W.preventDefault();g.addEventListener("submit",w),D=()=>{g.removeEventListener("submit",w)}}if(E instanceof HTMLButtonElement){let w=E.getAttribute("name");w&&B.append(w,E.value)}let N=g.getAttribute("enctype")==="multipart/form-data";N||(Ae["Content-Type"]="application/x-www-form-urlencoded");let R=new URLSearchParams(B);if(t==="GET")for(let[w,W]of R)ie.append(w,W);else N?re.body=B:re.body=R}else throw o("FetchInvalidContentType",{action:k,contentType:u});ee(Ye,r,{}),se.search=ie.toString();try{await dn(se.toString(),r,re)}catch(g){if(!Lt(g))throw o("FetchFailed",{method:t,url:a,error:g.message})}}finally{ee(Xe,r,{}),D?.(),i.delete(`@${e}`)}}});Ee("get","GET",!1);Ee("patch","PATCH");Ee("post","POST");Ee("put","PUT");Ee("delete","DELETE");var Ye="started",Xe="finished",sn="error",on="retrying",an="retries-failed",ee=(e,t,n)=>document.dispatchEvent(new CustomEvent(q,{detail:{type:e,el:t,argsRaw:n}})),Lt=e=>`${e}`.includes("text/event-stream"),cn=async(e,t)=>{let n=e.getReader(),r=await n.read();for(;!r.done;)t(r.value),r=await n.read()},ln=e=>{let t,n,r,s=!1;return o=>{t?t=fn(t,o):(t=o,n=0,r=-1);let i=t.length,a=0;for(;n{let r=Nt(),s=new TextDecoder;return(o,i)=>{if(!o.length)n?.(r),r=Nt();else if(i>0){let a=s.decode(o.subarray(0,i)),c=i+(o[i+1]===32?2:1),l=s.decode(o.subarray(c));switch(a){case"data":r.data=r.data?`${r.data} 8 | ${l}`:l;break;case"event":r.event=l;break;case"id":e(r.id=l);break;case"retry":{let u=+l;Number.isNaN(u)||t(r.retry=u);break}}}}},fn=(e,t)=>{let n=new Uint8Array(e.length+t.length);return n.set(e),n.set(t,e.length),n},Nt=()=>({data:"",event:"",id:"",retry:void 0}),dn=(e,t,{signal:n,headers:r,onopen:s,onmessage:o,onclose:i,onerror:a,openWhenHidden:c,fetch:l,retry:u="auto",retryInterval:d=1e3,retryScaler:h=2,retryMaxWaitMs:f=3e4,retryMaxCount:m=10,responseOverrides:y,...b})=>new Promise((v,A)=>{let I={...r},ne,de=()=>{ne.abort(),document.hidden||g()};c||document.addEventListener("visibilitychange",de);let D,V=()=>{document.removeEventListener("visibilitychange",de),clearTimeout(D),ne.abort()};n?.addEventListener("abort",()=>{V(),v()});let Ae=l||window.fetch,re=s||(()=>{}),se=0,ie=d,g=async()=>{ne=new AbortController;let B=ne.signal;try{let E=await Ae(e,{...b,headers:I,signal:B});await re(E);let N=async($,me,$e,Re,...Wt)=>{let ot={[$e]:await me.text()};for(let Ie of Wt){let qe=me.headers.get(`datastar-${ge(Ie)}`);if(Re){let we=Re[Ie];we&&(qe=typeof we=="string"?we:JSON.stringify(we))}qe&&(ot[Ie]=qe)}ee($,t,ot),V(),v()},R=E.status,w=R===204,W=R>=300&&R<400,De=R>=400&&R<600;if(R!==200){if(i?.(),u!=="never"&&!w&&!W&&(u==="always"||u==="error"&&De)){clearTimeout(D),D=setTimeout(g,d);return}V(),v();return}se=0,d=ie;let Ve=E.headers.get("Content-Type");if(Ve?.includes("text/html"))return await N("datastar-patch-elements",E,"elements",y,"selector","mode","namespace","useViewTransition");if(Ve?.includes("application/json"))return await N("datastar-patch-signals",E,"signals",y,"onlyIfMissing");if(Ve?.includes("text/javascript")){let $=document.createElement("script"),me=E.headers.get("datastar-script-attributes");if(me)for(let[$e,Re]of Object.entries(JSON.parse(me)))$.setAttribute($e,Re);$.textContent=await E.text(),document.head.appendChild($),V();return}if(await cn(E.body,ln(un($=>{$?I["last-event-id"]=$:delete I["last-event-id"]},$=>{ie=d=$},o))),i?.(),u==="always"&&!W){clearTimeout(D),D=setTimeout(g,d);return}V(),v()}catch(E){if(!B.aborted)try{let N=a?.(E)||d;clearTimeout(D),D=setTimeout(g,N),d=Math.min(d*h,f),++se>=m?(ee(an,t,{}),V(),A("Max retries reached.")):console.error(`Datastar failed to reach ${e.toString()} retrying in ${N}ms.`)}catch(N){V(),A(N)}}};g()});p({name:"attr",requirement:{value:"must"},returnsValue:!0,apply({el:e,key:t,rx:n}){let r=(a,c)=>{c===""||c===!0?e.setAttribute(a,""):c===!1||c==null?e.removeAttribute(a):typeof c=="string"?e.setAttribute(a,c):e.setAttribute(a,JSON.stringify(c))},s=t?()=>{o.disconnect();let a=n();r(t,a),o.observe(e,{attributeFilter:[t]})}:()=>{o.disconnect();let a=n(),c=Object.keys(a);for(let l of c)r(l,a[l]);o.observe(e,{attributeFilter:c})},o=new MutationObserver(s),i=S(s);return()=>{o.disconnect(),i()}}});var mn=/^data:(?[^;]+);base64,(?.*)$/,Ct=Symbol("empty"),Ft=G("bind");p({name:"bind",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r,error:s}){let o=t!=null?L(t,n):r,i=(f,m)=>m==="number"?+f.value:f.value,a=f=>{e.value=`${f}`};if(e instanceof HTMLInputElement)switch(e.type){case"range":case"number":i=(f,m)=>m==="string"?f.value:+f.value;break;case"checkbox":i=(f,m)=>f.value!=="on"?m==="boolean"?f.checked:f.checked?f.value:"":m==="string"?f.checked?f.value:"":f.checked,a=f=>{e.checked=typeof f=="string"?f===e.value:f};break;case"radio":e.getAttribute("name")?.length||e.setAttribute("name",o),i=(f,m)=>f.checked?m==="number"?+f.value:f.value:Ct,a=f=>{e.checked=f===(typeof f=="number"?+e.value:e.value)};break;case"file":{let f=()=>{let m=[...e.files||[]],y=[];Promise.all(m.map(b=>new Promise(v=>{let A=new FileReader;A.onload=()=>{if(typeof A.result!="string")throw s("InvalidFileResultType",{resultType:typeof A.result});let I=A.result.match(mn);if(!I?.groups)throw s("InvalidDataUri",{result:A.result});y.push({name:b.name,contents:I.groups.contents,mime:I.groups.mime})},A.onloadend=()=>v(),A.readAsDataURL(b)}))).then(()=>{T([[o,y]])})};return e.addEventListener("change",f),e.addEventListener("input",f),()=>{e.removeEventListener("change",f),e.removeEventListener("input",f)}}}else if(e instanceof HTMLSelectElement){if(e.multiple){let f=new Map;i=m=>[...m.selectedOptions].map(y=>{let b=f.get(y.value);return b==="string"||b==null?y.value:+y.value}),a=m=>{for(let y of e.options)m.includes(y.value)?(f.set(y.value,"string"),y.selected=!0):m.includes(+y.value)?(f.set(y.value,"number"),y.selected=!0):y.selected=!1}}}else e instanceof HTMLTextAreaElement||(i=f=>"value"in f?f.value:f.getAttribute("value"),a=f=>{"value"in e?e.value=f:e.setAttribute("value",f)});let c=oe(o),l=typeof c,u=o;if(Array.isArray(c)&&!(e instanceof HTMLSelectElement&&e.multiple)){let f=t||r,m=document.querySelectorAll(`[${Ft}\\:${CSS.escape(f)}],[${Ft}="${CSS.escape(f)}"]`),y=[],b=0;for(let v of m){if(y.push([`${u}.${b}`,i(v,"none")]),e===v)break;b++}T(y,{ifMissing:!0}),u=`${u}.${b}`}else T([[u,i(e,l)]],{ifMissing:!0});let d=()=>{let f=oe(u);if(f!=null){let m=i(e,typeof f);m!==Ct&&T([[u,m]])}};e.addEventListener("input",d),e.addEventListener("change",d);let h=S(()=>{a(oe(u))});return()=>{h(),e.removeEventListener("input",d),e.removeEventListener("change",d)}}});p({name:"class",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,mods:n,rx:r}){e&&=L(e,n,"kebab");let s,o=()=>{i.disconnect(),s=e?{[e]:r()}:r();for(let c in s){let l=c.split(/\s+/).filter(u=>u.length>0);if(s[c])for(let u of l)t.classList.contains(u)||t.classList.add(u);else for(let u of l)t.classList.contains(u)&&t.classList.remove(u)}i.observe(t,{attributeFilter:["class"]})},i=new MutationObserver(o),a=S(o);return()=>{i.disconnect(),a();for(let c in s){let l=c.split(/\s+/).filter(u=>u.length>0);for(let u of l)t.classList.remove(u)}}}});p({name:"computed",requirement:{value:"must"},returnsValue:!0,apply({key:e,mods:t,rx:n,error:r}){if(e)T([[L(e,t),ke(n)]]);else{let s=Object.assign({},n());Y(s,o=>{if(typeof o=="function")return ke(o);throw r("ComputedExpectedFunction")}),O(s)}}});p({name:"effect",requirement:{key:"denied",value:"must"},apply:({rx:e})=>S(e)});p({name:"indicator",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?L(t,n):r;T([[s,!1]]);let o=i=>{let{type:a,el:c}=i.detail;if(c===e)switch(a){case Ye:T([[s,!0]]);break;case Xe:T([[s,!1]]);break}};return document.addEventListener(q,o),()=>{T([[s,!1]]),document.removeEventListener(q,o)}}});var z=e=>{if(!e||e.size<=0)return 0;for(let t of e){if(t.endsWith("ms"))return+t.replace("ms","");if(t.endsWith("s"))return+t.replace("s","")*1e3;try{return Number.parseFloat(t)}catch{}}return 0},te=(e,t,n=!1)=>e?e.has(t.toLowerCase()):n;var et=(e,t)=>(...n)=>{setTimeout(()=>{e(...n)},t)},Pt=(e,t,n=!0,r=!1,s=!1)=>{let o=null,i=0;return(...a)=>{n&&!i?(e(...a),o=null):o=a,(!i||s)&&(i&&clearTimeout(i),i=setTimeout(()=>{r&&o!==null&&e(...o),o=null,i=0},t))}},ce=(e,t)=>{let n=t.get("delay");if(n){let o=z(n);e=et(e,o)}let r=t.get("debounce");if(r){let o=z(r),i=te(r,"leading",!1),a=!te(r,"notrailing",!1);e=Pt(e,o,i,a,!0)}let s=t.get("throttle");if(s){let o=z(s),i=!te(s,"noleading",!1),a=te(s,"trailing",!1);e=Pt(e,o,i,a)}return e};var tt=!!document.startViewTransition,Q=(e,t)=>{if(t.has("viewtransition")&&tt){let n=e;e=(...r)=>document.startViewTransition(()=>n(...r))}return e};p({name:"init",requirement:{key:"denied",value:"must"},apply({rx:e,mods:t}){let n=()=>{M(),e(),x()};n=Q(n,t);let r=0,s=t.get("delay");s&&(r=z(s),r>0&&(n=et(n,r))),n()}});p({name:"json-signals",requirement:{key:"denied"},apply({el:e,value:t,mods:n}){let r=n.has("terse")?0:2,s={};t&&(s=ae(t));let o=()=>{i.disconnect(),e.textContent=JSON.stringify(_(s),null,r),i.observe(e,{childList:!0,characterData:!0,subtree:!0})},i=new MutationObserver(o),a=S(o);return()=>{i.disconnect(),a()}}});p({name:"on",requirement:"must",argNames:["evt"],apply({el:e,key:t,mods:n,rx:r}){let s=e;n.has("window")&&(s=window);let o=c=>{c&&(n.has("prevent")&&c.preventDefault(),n.has("stop")&&c.stopPropagation()),M(),r(c),x()};o=Q(o,n),o=ce(o,n);let i={capture:n.has("capture"),passive:n.has("passive"),once:n.has("once")};if(n.has("outside")){s=document;let c=o;o=l=>{e.contains(l?.target)||c(l)}}let a=L(t,n,"kebab");if((a===q||a===Z)&&(s=document),e instanceof HTMLFormElement&&a==="submit"){let c=o;o=l=>{l?.preventDefault(),c(l)}}return s.addEventListener(a,o,i),()=>{s.removeEventListener(a,o)}}});var Ot=(e,t,n)=>Math.max(t,Math.min(n,e));var nt=new WeakSet;p({name:"on-intersect",requirement:{key:"denied",value:"must"},apply({el:e,mods:t,rx:n}){let r=()=>{M(),n(),x()};r=Q(r,t),r=ce(r,t);let s={threshold:0};t.has("full")?s.threshold=1:t.has("half")?s.threshold=.5:t.get("threshold")&&(s.threshold=Ot(Number(t.get("threshold")),0,100)/100);let o=t.has("exit"),i=new IntersectionObserver(a=>{for(let c of a)c.isIntersecting!==o&&(r(),i&&nt.has(e)&&i.disconnect())},s);return i.observe(e),t.has("once")&&nt.add(e),()=>{t.has("once")||nt.delete(e),i&&(i.disconnect(),i=null)}}});p({name:"on-interval",requirement:{key:"denied",value:"must"},apply({mods:e,rx:t}){let n=()=>{M(),t(),x()};n=Q(n,e);let r=1e3,s=e.get("duration");s&&(r=z(s),te(s,"leading",!1)&&n());let o=setInterval(n,r);return()=>{clearInterval(o)}}});p({name:"on-signal-patch",requirement:{value:"must"},argNames:["patch"],returnsValue:!0,apply({el:e,key:t,mods:n,rx:r,error:s}){if(t&&t!=="filter")throw s("KeyNotAllowed");let o=G(`${this.name}-filter`),i=e.getAttribute(o),a={};i&&(a=ae(i));let c=!1,l=ce(u=>{if(c)return;let d=_(a,u.detail);if(!ct(d)){c=!0,M();try{r(d)}finally{x(),c=!1}}},n);return document.addEventListener(Z,l),()=>{document.removeEventListener(Z,l)}}});p({name:"ref",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?L(t,n):r;T([[s,e]])}});var kt="none",Ht="display";p({name:"show",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),t()?e.style.display===kt&&e.style.removeProperty(Ht):e.style.setProperty(Ht,kt),r.observe(e,{attributeFilter:["style"]})},r=new MutationObserver(n),s=S(n);return()=>{r.disconnect(),s()}}});p({name:"signals",returnsValue:!0,apply({key:e,mods:t,rx:n}){let r=t.has("ifmissing");if(e)e=L(e,t),T([[e,n?.()]],{ifMissing:r});else{let s=Object.assign({},n?.());O(s,{ifMissing:r})}}});p({name:"style",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,rx:n}){let{style:r}=t,s=new Map,o=(l,u)=>{let d=s.get(l);!u&&u!==0?d!==void 0&&(d?r.setProperty(l,d):r.removeProperty(l)):(d===void 0&&s.set(l,r.getPropertyValue(l)),r.setProperty(l,String(u)))},i=()=>{if(a.disconnect(),e)o(e,n());else{let l=n();for(let[u,d]of s)u in l||(d?r.setProperty(u,d):r.removeProperty(u));for(let u in l)o(ge(u),l[u])}a.observe(t,{attributeFilter:["style"]})},a=new MutationObserver(i),c=S(i);return()=>{a.disconnect(),c();for(let[l,u]of s)u?r.setProperty(l,u):r.removeProperty(l)}}});p({name:"text",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),e.textContent=`${t()}`,r.observe(e,{childList:!0,characterData:!0,subtree:!0})},r=new MutationObserver(n),s=S(n);return()=>{r.disconnect(),s()}}});var _t=(e,t)=>e.includes(t),pn=["remove","outer","inner","replace","prepend","append","before","after"],gn=["html","svg","mathml"];ve({name:"datastar-patch-elements",apply(e,{selector:t="",mode:n="outer",namespace:r="html",useViewTransition:s="",elements:o=""}){if(!_t(pn,n))throw e.error("PatchElementsInvalidMode",{mode:n});if(!t&&n!=="outer"&&n!=="replace")throw e.error("PatchElementsExpectedSelector");if(!_t(gn,r))throw e.error("PatchElementsInvalidNamespace",{namespace:r});let i={selector:t,mode:n,namespace:r,useViewTransition:s.trim()==="true",elements:o};tt&&s?document.startViewTransition(()=>Dt(e,i)):Dt(e,i)}});var Dt=({error:e},{selector:t,mode:n,namespace:r,elements:s})=>{let o=s.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,""),i=/<\/html>/.test(o),a=/<\/head>/.test(o),c=/<\/body>/.test(o),l=r==="svg"?"svg":r==="mathml"?"math":"",u=l?`<${l}>${s}`:s,d=new DOMParser().parseFromString(i||a||c?s:``,"text/html"),h=document.createDocumentFragment();if(i)h.appendChild(d.documentElement);else if(a&&c)h.appendChild(d.head),h.appendChild(d.body);else if(a)h.appendChild(d.head);else if(c)h.appendChild(d.body);else if(l){let f=d.querySelector("template").content.querySelector(l);for(let m of f.childNodes)h.appendChild(m)}else h=d.querySelector("template").content;if(!t&&(n==="outer"||n==="replace"))for(let f of h.children){let m;if(f instanceof HTMLHtmlElement)m=document.documentElement;else if(f instanceof HTMLBodyElement)m=document.body;else if(f instanceof HTMLHeadElement)m=document.head;else if(m=document.getElementById(f.id),!m){console.warn(e("PatchElementsNoTargetsFound"),{element:{id:f.id}});continue}$t(n,f,[m])}else{let f=document.querySelectorAll(t);if(!f.length){console.warn(e("PatchElementsNoTargetsFound"),{selector:t});return}$t(n,h,f)}},st=new WeakSet;for(let e of document.querySelectorAll("script"))st.add(e);var jt=e=>{let t=e instanceof HTMLScriptElement?[e]:e.querySelectorAll("script");for(let n of t)if(!st.has(n)){let r=document.createElement("script");for(let{name:s,value:o}of n.attributes)r.setAttribute(s,o);r.text=n.text,n.replaceWith(r),st.add(r)}},Vt=(e,t,n)=>{for(let r of e){let s=t.cloneNode(!0);jt(s),r[n](s)}},$t=(e,t,n)=>{switch(e){case"remove":for(let r of n)r.remove();break;case"outer":case"inner":for(let r of n)yn(r,t.cloneNode(!0),e),jt(r);break;case"replace":Vt(n,t,"replaceWith");break;case"prepend":case"append":case"before":case"after":Vt(n,t,e)}},H=new Map,ue=new Set,le=new Map,Se=new Set,fe=document.createElement("div");fe.hidden=!0;var Te=G("ignore-morph"),hn=`[${Te}]`,yn=(e,t,n="outer")=>{if(K(e)&&K(t)&&e.hasAttribute(Te)&&t.hasAttribute(Te)||e.parentElement?.closest(hn))return;let r=document.createElement("div");r.append(t),document.body.insertAdjacentElement("afterend",fe);let s=e.querySelectorAll("[id]");for(let{id:a,tagName:c}of s)le.has(a)?Se.add(a):le.set(a,c);e instanceof Element&&e.id&&(le.has(e.id)?Se.add(e.id):le.set(e.id,e.tagName)),ue.clear();let o=r.querySelectorAll("[id]");for(let{id:a,tagName:c}of o)ue.has(a)?Se.add(a):le.get(a)===c&&ue.add(a);for(let a of Se)ue.delete(a);le.clear(),Se.clear(),H.clear();let i=n==="outer"?e.parentElement:e;qt(i,s),qt(r,o),Gt(i,r,n==="outer"?e:null,e.nextSibling),fe.remove()},Gt=(e,t,n=null,r=null)=>{e instanceof HTMLTemplateElement&&t instanceof HTMLTemplateElement&&(e=e.content,t=t.content),n??=e.firstChild;for(let s of t.childNodes){if(n&&n!==r){let o=bn(s,n,r);if(o){if(o!==n){let i=n;for(;i&&i!==o;){let a=i;i=i.nextSibling,it(a)}}rt(o,s),n=o.nextSibling;continue}}if(s instanceof Element&&ue.has(s.id)){let o=document.getElementById(s.id),i=o;for(;i=i.parentNode;){let a=H.get(i);a&&(a.delete(s.id),a.size||H.delete(i))}Bt(e,o,n),rt(o,s),n=o.nextSibling;continue}if(H.has(s)){let o=s.namespaceURI,i=s.tagName,a=o&&o!=="http://www.w3.org/1999/xhtml"?document.createElementNS(o,i):document.createElement(i);e.insertBefore(a,n),rt(a,s),n=a.nextSibling}else{let o=document.importNode(s,!0);e.insertBefore(o,n),n=o.nextSibling}}for(;n&&n!==r;){let s=n;n=n.nextSibling,it(s)}},bn=(e,t,n)=>{let r=null,s=e.nextSibling,o=0,i=0,a=H.get(e)?.size||0,c=t;for(;c&&c!==n;){if(It(c,e)){let l=!1,u=H.get(c),d=H.get(e);if(d&&u){for(let h of u)if(d.has(h)){l=!0;break}}if(l)return c;if(!r&&!H.has(c)){if(!a)return c;r=c}}if(i+=H.get(c)?.size||0,i>a)break;r===null&&s&&It(c,s)&&(o++,s=s.nextSibling,o>=2&&(r=void 0)),c=c.nextSibling}return r||null},It=(e,t)=>e.nodeType===t.nodeType&&e.tagName===t.tagName&&(!e.id||e.id===t.id),it=e=>{H.has(e)?Bt(fe,e,null):e.parentNode?.removeChild(e)},Bt=it.call.bind(fe.moveBefore??fe.insertBefore),vn=G("preserve-attr"),rt=(e,t)=>{let n=t.nodeType;if(n===1){let r=e,s=t,o=r.hasAttribute("data-scope-children");if(r.hasAttribute(Te)&&s.hasAttribute(Te))return e;r instanceof HTMLInputElement&&s instanceof HTMLInputElement&&s.type!=="file"?s.getAttribute("value")!==r.getAttribute("value")&&(r.value=s.getAttribute("value")??""):r instanceof HTMLTextAreaElement&&s instanceof HTMLTextAreaElement&&(s.value!==r.value&&(r.value=s.value),r.firstChild&&r.firstChild.nodeValue!==s.value&&(r.firstChild.nodeValue=s.value));let i=(t.getAttribute(vn)??"").split(" ");for(let{name:a,value:c}of s.attributes)r.getAttribute(a)!==c&&!i.includes(a)&&r.setAttribute(a,c);for(let a=r.attributes.length-1;a>=0;a--){let{name:c}=r.attributes[a];!s.hasAttribute(c)&&!i.includes(c)&&r.removeAttribute(c)}o&&!r.hasAttribute("data-scope-children")&&r.setAttribute("data-scope-children",""),r.isEqualNode(s)||Gt(r,s),o&&r.dispatchEvent(new CustomEvent("datastar:scope-children",{bubbles:!1}))}return(n===8||n===3)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),e},qt=(e,t)=>{for(let n of t)if(ue.has(n.id)){let r=n;for(;r&&r!==e;){let s=H.get(r);s||(s=new Set,H.set(r,s)),s.add(n.id),r=r.parentElement}}};ve({name:"datastar-patch-signals",apply({error:e},{signals:t,onlyIfMissing:n}){if(t){let r=n?.trim()==="true";O(ae(t),{ifMissing:r})}else throw e("PatchSignalsExpectedSignals")}});export{k as action,Rt as actions,p as attribute,M as beginBatch,ke as computed,S as effect,x as endBatch,_ as filtered,oe as getPath,O as mergePatch,T as mergePaths,X as root,pe as signal,F as startPeeking,P as stopPeeking,ve as watcher}; 9 | //# sourceMappingURL=datastar.js.map 10 | -------------------------------------------------------------------------------- /features/index/components/todo_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.960 4 | package components 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "fmt" 13 | "github.com/starfederation/datastar-go/datastar" 14 | common "northstar/features/common/components" 15 | ) 16 | 17 | type TodoViewMode int 18 | 19 | const ( 20 | TodoViewModeAll TodoViewMode = iota 21 | TodoViewModeActive 22 | TodoViewModeCompleted 23 | TodoViewModeLast 24 | ) 25 | 26 | var TodoViewModeStrings = []string{"All", "Active", "Completed"} 27 | 28 | type Todo struct { 29 | Text string `json:"text"` 30 | Completed bool `json:"completed"` 31 | } 32 | 33 | type TodoMVC struct { 34 | Todos []*Todo `json:"todos"` 35 | EditingIdx int `json:"editingIdx"` 36 | Mode TodoViewMode `json:"mode"` 37 | } 38 | 39 | func TodosMVCView(mvc *TodoMVC) templ.Component { 40 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 41 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 42 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 43 | return templ_7745c5c3_CtxErr 44 | } 45 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 46 | if !templ_7745c5c3_IsBuffer { 47 | defer func() { 48 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 49 | if templ_7745c5c3_Err == nil { 50 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 51 | } 52 | }() 53 | } 54 | ctx = templ.InitializeContext(ctx) 55 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 56 | if templ_7745c5c3_Var1 == nil { 57 | templ_7745c5c3_Var1 = templ.NopComponent 58 | } 59 | ctx = templ.ClearChildren(ctx) 60 | hasTodos := len(mvc.Todos) > 0 61 | left, completed := 0, 0 62 | for _, todo := range mvc.Todos { 63 | if !todo.Completed { 64 | left++ 65 | } else { 66 | completed++ 67 | } 68 | } 69 | input := "" 70 | if mvc.EditingIdx >= 0 { 71 | input = mvc.Todos[mvc.EditingIdx].Text 72 | } 73 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
    ") 87 | if templ_7745c5c3_Err != nil { 88 | return templ_7745c5c3_Err 89 | } 90 | templ_7745c5c3_Err = common.Icon("material-symbols:info").Render(ctx, templ_7745c5c3_Buffer) 91 | if templ_7745c5c3_Err != nil { 92 | return templ_7745c5c3_Err 93 | } 94 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

    This mini application is driven by a single get request!
    As you interact with the UI, the backend state is updated and new partial HTML fragments are sent down to the client via Server-Sent Events. You can make simple apps or full blown SPA replacements with this pattern. Open your dev tools and watch the network tab to see the magic happen (you will want to look for the \"/todos\" Network/EventStream tab).

    todo

    The input is bound to a local store, but this is not a single page application. It is like having HTMX + Alpine.js but with just one API to learn and much easier to extend.

    ") 95 | if templ_7745c5c3_Err != nil { 96 | return templ_7745c5c3_Err 97 | } 98 | if hasTodos { 99 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
    ") 121 | if templ_7745c5c3_Err != nil { 122 | return templ_7745c5c3_Err 123 | } 124 | } 125 | if mvc.EditingIdx < 0 { 126 | templ_7745c5c3_Err = TodoInput(-1).Render(ctx, templ_7745c5c3_Buffer) 127 | if templ_7745c5c3_Err != nil { 128 | return templ_7745c5c3_Err 129 | } 130 | } 131 | templ_7745c5c3_Err = common.SseIndicator("toggleAllFetching").Render(ctx, templ_7745c5c3_Buffer) 132 | if templ_7745c5c3_Err != nil { 133 | return templ_7745c5c3_Err 134 | } 135 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
    ") 136 | if templ_7745c5c3_Err != nil { 137 | return templ_7745c5c3_Err 138 | } 139 | if hasTodos { 140 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
      ") 141 | if templ_7745c5c3_Err != nil { 142 | return templ_7745c5c3_Err 143 | } 144 | for i, todo := range mvc.Todos { 145 | templ_7745c5c3_Err = TodoRow(mvc.Mode, todo, i, i == mvc.EditingIdx).Render(ctx, templ_7745c5c3_Buffer) 146 | if templ_7745c5c3_Err != nil { 147 | return templ_7745c5c3_Err 148 | } 149 | } 150 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
    ") 151 | if templ_7745c5c3_Err != nil { 152 | return templ_7745c5c3_Err 153 | } 154 | var templ_7745c5c3_Var4 string 155 | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(left)) 156 | if templ_7745c5c3_Err != nil { 157 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/components/todo.templ`, Line: 102, Col: 26} 158 | } 159 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 160 | if templ_7745c5c3_Err != nil { 161 | return templ_7745c5c3_Err 162 | } 163 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ") 164 | if templ_7745c5c3_Err != nil { 165 | return templ_7745c5c3_Err 166 | } 167 | if len(mvc.Todos) > 1 { 168 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "items") 169 | if templ_7745c5c3_Err != nil { 170 | return templ_7745c5c3_Err 171 | } 172 | } else { 173 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "item") 174 | if templ_7745c5c3_Err != nil { 175 | return templ_7745c5c3_Err 176 | } 177 | } 178 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " left
    ") 179 | if templ_7745c5c3_Err != nil { 180 | return templ_7745c5c3_Err 181 | } 182 | for i := TodoViewModeAll; i < TodoViewModeLast; i++ { 183 | if i == mvc.Mode { 184 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
    ") 185 | if templ_7745c5c3_Err != nil { 186 | return templ_7745c5c3_Err 187 | } 188 | var templ_7745c5c3_Var5 string 189 | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TodoViewModeStrings[i]) 190 | if templ_7745c5c3_Err != nil { 191 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/components/todo.templ`, Line: 113, Col: 79} 192 | } 193 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 194 | if templ_7745c5c3_Err != nil { 195 | return templ_7745c5c3_Err 196 | } 197 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
    ") 198 | if templ_7745c5c3_Err != nil { 199 | return templ_7745c5c3_Err 200 | } 201 | } else { 202 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") 229 | if templ_7745c5c3_Err != nil { 230 | return templ_7745c5c3_Err 231 | } 232 | } 233 | } 234 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
    ") 235 | if templ_7745c5c3_Err != nil { 236 | return templ_7745c5c3_Err 237 | } 238 | if completed > 0 { 239 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
    ") 274 | if templ_7745c5c3_Err != nil { 275 | return templ_7745c5c3_Err 276 | } 277 | } 278 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
    Click to edit, click away to cancel, press enter to save.
    ") 300 | if templ_7745c5c3_Err != nil { 301 | return templ_7745c5c3_Err 302 | } 303 | } 304 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
    ") 305 | if templ_7745c5c3_Err != nil { 306 | return templ_7745c5c3_Err 307 | } 308 | return nil 309 | }) 310 | } 311 | 312 | func TodoInput(i int) templ.Component { 313 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 314 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 315 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 316 | return templ_7745c5c3_CtxErr 317 | } 318 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 319 | if !templ_7745c5c3_IsBuffer { 320 | defer func() { 321 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 322 | if templ_7745c5c3_Err == nil { 323 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 324 | } 325 | }() 326 | } 327 | ctx = templ.InitializeContext(ctx) 328 | templ_7745c5c3_Var11 := templ.GetChildren(ctx) 329 | if templ_7745c5c3_Var11 == nil { 330 | templ_7745c5c3_Var11 = templ.NopComponent 331 | } 332 | ctx = templ.ClearChildren(ctx) 333 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "= 0 { 355 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " data-on:click__outside=\"") 356 | if templ_7745c5c3_Err != nil { 357 | return templ_7745c5c3_Err 358 | } 359 | var templ_7745c5c3_Var13 string 360 | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PutSSE("/api/todos/cancel")) 361 | if templ_7745c5c3_Err != nil { 362 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/components/todo.templ`, Line: 167, Col: 64} 363 | } 364 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 365 | if templ_7745c5c3_Err != nil { 366 | return templ_7745c5c3_Err 367 | } 368 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"") 369 | if templ_7745c5c3_Err != nil { 370 | return templ_7745c5c3_Err 371 | } 372 | } 373 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">") 374 | if templ_7745c5c3_Err != nil { 375 | return templ_7745c5c3_Err 376 | } 377 | return nil 378 | }) 379 | } 380 | 381 | func TodoRow(mode TodoViewMode, todo *Todo, i int, isEditing bool) templ.Component { 382 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 383 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 384 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 385 | return templ_7745c5c3_CtxErr 386 | } 387 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 388 | if !templ_7745c5c3_IsBuffer { 389 | defer func() { 390 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 391 | if templ_7745c5c3_Err == nil { 392 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 393 | } 394 | }() 395 | } 396 | ctx = templ.InitializeContext(ctx) 397 | templ_7745c5c3_Var14 := templ.GetChildren(ctx) 398 | if templ_7745c5c3_Var14 == nil { 399 | templ_7745c5c3_Var14 = templ.NopComponent 400 | } 401 | ctx = templ.ClearChildren(ctx) 402 | indicatorID := fmt.Sprintf("indicator%d", i) 403 | fetchingSignalName := fmt.Sprintf("fetching%d", i) 404 | if isEditing { 405 | templ_7745c5c3_Err = TodoInput(i).Render(ctx, templ_7745c5c3_Buffer) 406 | if templ_7745c5c3_Err != nil { 407 | return templ_7745c5c3_Err 408 | } 409 | } else if (mode == TodoViewModeAll) || 410 | (mode == TodoViewModeActive && !todo.Completed) || 411 | (mode == TodoViewModeCompleted && todo.Completed) { 412 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
  • ") 532 | if templ_7745c5c3_Err != nil { 533 | return templ_7745c5c3_Err 534 | } 535 | templ_7745c5c3_Err = common.SseIndicator(fetchingSignalName).Render(ctx, templ_7745c5c3_Buffer) 536 | if templ_7745c5c3_Err != nil { 537 | return templ_7745c5c3_Err 538 | } 539 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
  • ") 613 | if templ_7745c5c3_Err != nil { 614 | return templ_7745c5c3_Err 615 | } 616 | } 617 | return nil 618 | }) 619 | } 620 | 621 | var _ = templruntime.GeneratedTemplate 622 | --------------------------------------------------------------------------------