├── 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 |
17 | Reverse
18 |
19 |
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 |
15 |
22 |
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 |
20 | Increment Global
21 |
22 |
26 | Increment User
27 |
28 |
29 | }
30 |
31 | templ CounterCounts() {
32 |
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 (%):
")
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 |
81 | @common.Icon("material-symbols:checklist")
82 |
83 |
84 | }
85 | if mvc.EditingIdx <0 {
86 | @TodoInput(-1)
87 | }
88 | @common.SseIndicator("toggleAllFetching")
89 |
90 |
91 | if hasTodos {
92 |
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 |
119 | { TodoViewModeStrings[i] }
120 |
121 | }
122 | }
123 |
124 |
125 | if completed > 0 {
126 |
127 |
131 | @common.Icon("material-symbols:delete")
132 |
133 |
134 | }
135 |
136 |
140 | @common.Icon("material-symbols:delete-sweep")
141 |
142 |
143 |
144 |
145 |
146 | Click to edit, click away to cancel, press enter to save.
147 |
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 |
190 | if todo.Completed {
191 | @common.Icon("material-symbols:check-box-outline")
192 | } else {
193 | @common.Icon("material-symbols:check-box-outline-blank")
194 | }
195 |
196 |
202 | { todo.Text }
203 |
204 | @common.SseIndicator(fetchingSignalName)
205 |
213 | @common.Icon("material-symbols:close")
214 |
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, "Increment Global Increment User
")
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, "")
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}${l}>`:s,d=new DOMParser().parseFromString(i||a||c?s:`${u} `,"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, "
")
113 | if templ_7745c5c3_Err != nil {
114 | return templ_7745c5c3_Err
115 | }
116 | templ_7745c5c3_Err = common.Icon("material-symbols:checklist").Render(ctx, templ_7745c5c3_Buffer)
117 | if templ_7745c5c3_Err != nil {
118 | return templ_7745c5c3_Err
119 | }
120 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
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, "
")
216 | if templ_7745c5c3_Err != nil {
217 | return templ_7745c5c3_Err
218 | }
219 | var templ_7745c5c3_Var7 string
220 | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TodoViewModeStrings[i])
221 | if templ_7745c5c3_Err != nil {
222 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/components/todo.templ`, Line: 119, Col: 34}
223 | }
224 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
225 | if templ_7745c5c3_Err != nil {
226 | return templ_7745c5c3_Err
227 | }
228 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ")
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, "
")
266 | if templ_7745c5c3_Err != nil {
267 | return templ_7745c5c3_Err
268 | }
269 | templ_7745c5c3_Err = common.Icon("material-symbols:delete").Render(ctx, templ_7745c5c3_Buffer)
270 | if templ_7745c5c3_Err != nil {
271 | return templ_7745c5c3_Err
272 | }
273 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
")
274 | if templ_7745c5c3_Err != nil {
275 | return templ_7745c5c3_Err
276 | }
277 | }
278 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
")
292 | if templ_7745c5c3_Err != nil {
293 | return templ_7745c5c3_Err
294 | }
295 | templ_7745c5c3_Err = common.Icon("material-symbols:delete-sweep").Render(ctx, templ_7745c5c3_Buffer)
296 | if templ_7745c5c3_Err != nil {
297 | return templ_7745c5c3_Err
298 | }
299 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
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, "")
465 | if templ_7745c5c3_Err != nil {
466 | return templ_7745c5c3_Err
467 | }
468 | if todo.Completed {
469 | templ_7745c5c3_Err = common.Icon("material-symbols:check-box-outline").Render(ctx, templ_7745c5c3_Buffer)
470 | if templ_7745c5c3_Err != nil {
471 | return templ_7745c5c3_Err
472 | }
473 | } else {
474 | templ_7745c5c3_Err = common.Icon("material-symbols:check-box-outline-blank").Render(ctx, templ_7745c5c3_Buffer)
475 | if templ_7745c5c3_Err != nil {
476 | return templ_7745c5c3_Err
477 | }
478 | }
479 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ")
519 | if templ_7745c5c3_Err != nil {
520 | return templ_7745c5c3_Err
521 | }
522 | var templ_7745c5c3_Var22 string
523 | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(todo.Text)
524 | if templ_7745c5c3_Err != nil {
525 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/components/todo.templ`, Line: 202, Col: 15}
526 | }
527 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
528 | if templ_7745c5c3_Err != nil {
529 | return templ_7745c5c3_Err
530 | }
531 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ")
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, "")
605 | if templ_7745c5c3_Err != nil {
606 | return templ_7745c5c3_Err
607 | }
608 | templ_7745c5c3_Err = common.Icon("material-symbols:close").Render(ctx, templ_7745c5c3_Buffer)
609 | if templ_7745c5c3_Err != nil {
610 | return templ_7745c5c3_Err
611 | }
612 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " ")
613 | if templ_7745c5c3_Err != nil {
614 | return templ_7745c5c3_Err
615 | }
616 | }
617 | return nil
618 | })
619 | }
620 |
621 | var _ = templruntime.GeneratedTemplate
622 |
--------------------------------------------------------------------------------