Created
January 22, 2026 03:35
-
-
Save delaneyj/b409c91945eb233bc7413e8ff05e826d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package examples | |
| import ( | |
| "context" | |
| "fmt" | |
| "log" | |
| "net/http" | |
| "os" | |
| "strconv" | |
| "time" | |
| "github.com/delaneyj/toolbelt" | |
| "github.com/go-chi/chi/v5" | |
| "github.com/goccy/go-json" | |
| "github.com/gorilla/sessions" | |
| "github.com/samber/lo" | |
| "github.com/starfederation/datastar-dev/site/auth" | |
| . "github.com/starfederation/datastar-dev/site/shared" | |
| "github.com/starfederation/datastar-go/datastar" | |
| "zombiezen.com/go/sqlite" | |
| ) | |
| type todoViewMode int | |
| const ( | |
| todoViewModeAll todoViewMode = iota | |
| todoViewModePending | |
| todoViewModeCompleted | |
| todoViewModeLast | |
| ) | |
| var todoViewModeStrings = []string{"All", "Pending", "Completed"} | |
| type todoEntry struct { | |
| Text string `json:"text"` | |
| Completed bool `json:"completed"` | |
| } | |
| type todoMVC struct { | |
| Todos []*todoEntry `json:"todos"` | |
| EditingIdx int `json:"editingIdx"` | |
| Mode todoViewMode `json:"mode"` | |
| } | |
| // Package-level event bus for todo updates | |
| var todoEventBus = NewEventBusAsync[string]() | |
| func setupTodoMVC( | |
| examplesRouter chi.Router, | |
| sessionStore *sessions.CookieStore, | |
| ) { | |
| // Setup SQLite database | |
| filename := "todos.sqlite" | |
| os.RemoveAll(filename) // Remove old database file if it exists | |
| ctx := context.Background() | |
| db, err := toolbelt.NewDatabase(ctx, filename, []string{ | |
| ` | |
| CREATE TABLE IF NOT EXISTS todos ( | |
| session_id TEXT PRIMARY KEY, | |
| data TEXT NOT NULL, | |
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| updated_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at); | |
| CREATE INDEX IF NOT EXISTS idx_todos_updated_at ON todos(updated_at); | |
| `, | |
| }) | |
| if err != nil { | |
| panic(fmt.Errorf("failed to open SQLite database: %w", err)) | |
| } | |
| go func() { | |
| ticker := time.NewTicker(24 * time.Hour) | |
| defer ticker.Stop() | |
| cleanupOldEntries := func() { | |
| if err := db.WriteTX(ctx, func(tx *sqlite.Conn) error { | |
| stmt := tx.Prep("DELETE FROM todos WHERE created_at < datetime('now', '-1 day')") | |
| defer stmt.Reset() | |
| _, err := stmt.Step() | |
| return err | |
| }); err != nil { | |
| log.Printf("Error cleaning up old todo entries: %v", err) | |
| } | |
| } | |
| cleanupOldEntries() | |
| for range ticker.C { | |
| cleanupOldEntries() | |
| } | |
| }() | |
| saveMVC := func(ctx context.Context, sessionID string, mvc *todoMVC) error { | |
| b, err := json.Marshal(mvc) | |
| if err != nil { | |
| return fmt.Errorf("failed to marshal mvc: %w", err) | |
| } | |
| // Save to SQLite | |
| if err := db.WriteTX(ctx, func(tx *sqlite.Conn) error { | |
| stmt := tx.Prep(` | |
| INSERT INTO todos (session_id, data, updated_at) | |
| VALUES ($session_id, $data, CURRENT_TIMESTAMP) | |
| ON CONFLICT(session_id) DO UPDATE SET | |
| data = excluded.data, | |
| updated_at = CURRENT_TIMESTAMP | |
| `) | |
| defer stmt.Reset() | |
| stmt.SetText("$session_id", sessionID) | |
| stmt.SetText("$data", string(b)) | |
| _, err := stmt.Step() | |
| return err | |
| }); err != nil { | |
| return fmt.Errorf("failed to save to database: %w", err) | |
| } | |
| // Emit update event | |
| if err := todoEventBus.Emit(ctx, sessionID); err != nil { | |
| log.Printf("Error emitting todo update event: %v", err) | |
| } | |
| return nil | |
| } | |
| resetMVC := func(mvc *todoMVC) { | |
| mvc.Mode = todoViewModeAll | |
| mvc.Todos = []*todoEntry{ | |
| {Text: "Learn any backend language", Completed: true}, | |
| {Text: "Learn Datastar", Completed: false}, | |
| {Text: "???", Completed: false}, | |
| {Text: "Profit", Completed: false}, | |
| } | |
| mvc.EditingIdx = -1 | |
| } | |
| mvcSession := func(w http.ResponseWriter, r *http.Request) (string, *todoMVC, error) { | |
| session, err := sessionStore.Get(r, "connections") | |
| if err != nil { | |
| return "", nil, fmt.Errorf("failed to get session: %w", err) | |
| } | |
| id, ok := session.Values["id"].(string) | |
| if !ok { | |
| id = toolbelt.NextEncodedID() | |
| session.Values["id"] = id | |
| if err := session.Save(r, w); err != nil { | |
| return "", nil, fmt.Errorf("failed to save session: %w", err) | |
| } | |
| } | |
| mvc := &todoMVC{} | |
| ctx := r.Context() | |
| // Load from SQLite | |
| found := false | |
| if err := db.ReadTX(ctx, func(tx *sqlite.Conn) error { | |
| stmt := tx.Prep("SELECT data FROM todos WHERE session_id = $session_id") | |
| defer stmt.Reset() | |
| stmt.SetText("$session_id", id) | |
| hasRow, err := stmt.Step() | |
| if err != nil { | |
| return err | |
| } | |
| if hasRow { | |
| data := stmt.GetText("data") | |
| if err := json.Unmarshal([]byte(data), mvc); err != nil { | |
| return fmt.Errorf("failed to unmarshal mvc: %w", err) | |
| } | |
| found = true | |
| } | |
| return nil | |
| }); err != nil { | |
| return "", nil, fmt.Errorf("failed to read from database: %w", err) | |
| } | |
| if !found { | |
| resetMVC(mvc) | |
| if err := saveMVC(ctx, id, mvc); err != nil { | |
| return "", nil, fmt.Errorf("failed to save mvc: %w", err) | |
| } | |
| } | |
| return id, mvc, nil | |
| } | |
| examplesRouter.Route("/todomvc", func(todoMVCRouter chi.Router) { | |
| todoMVCRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { | |
| _, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| u := auth.UserFromContext(r.Context()) | |
| RenderPage(todo(u, mvc), w, r) | |
| }) | |
| todoMVCRouter.Get("/updates", func(w http.ResponseWriter, r *http.Request) { | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| // Subscribe to updates for this session | |
| ctx := r.Context() | |
| sse := datastar.NewSSE(w, r, datastar.WithCompression()) | |
| // Send initial data | |
| c := todoMVCView(mvc) | |
| if err := sse.PatchElementTempl(c); err != nil { | |
| sse.ConsoleError(err) | |
| return | |
| } | |
| // Subscribe to updates | |
| cancel := todoEventBus.Subscribe(context.Background(), func(updatedSessionID string) error { | |
| if updatedSessionID != sessionID { | |
| return nil // Not our session | |
| } | |
| // Load updated data from database | |
| if err := db.ReadTX(ctx, func(tx *sqlite.Conn) error { | |
| stmt := tx.Prep("SELECT data FROM todos WHERE session_id = $session_id") | |
| defer stmt.Reset() | |
| stmt.SetText("$session_id", sessionID) | |
| hasRow, err := stmt.Step() | |
| if err != nil { | |
| return err | |
| } | |
| if hasRow { | |
| data := stmt.GetText("data") | |
| if err := json.Unmarshal([]byte(data), mvc); err != nil { | |
| return fmt.Errorf("failed to unmarshal mvc: %w", err) | |
| } | |
| } | |
| return nil | |
| }); err != nil { | |
| sse.ConsoleError(fmt.Errorf("failed to read updated data: %w", err)) | |
| return err | |
| } | |
| // Send update | |
| c := todoMVCView(mvc) | |
| if err := sse.PatchElementTempl(c); err != nil { | |
| sse.ConsoleError(err) | |
| return err | |
| } | |
| return nil | |
| }) | |
| defer cancel() | |
| // Keep connection open | |
| <-ctx.Done() | |
| }) | |
| todoMVCRouter.Put("/reset", func(w http.ResponseWriter, r *http.Request) { | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| resetMVC(mvc) | |
| if err := saveMVC(r.Context(), sessionID, mvc); err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| w.WriteHeader(204) | |
| }) | |
| todoMVCRouter.Put("/cancel", func(w http.ResponseWriter, r *http.Request) { | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| sse := datastar.NewSSE(w, r) | |
| sse.ConsoleError(err) | |
| return | |
| } | |
| mvc.EditingIdx = -1 | |
| if err := saveMVC(r.Context(), sessionID, mvc); err != nil { | |
| sse := datastar.NewSSE(w, r) | |
| sse.ConsoleError(err) | |
| return | |
| } | |
| w.WriteHeader(204) | |
| }) | |
| todoMVCRouter.Put("/mode/{mode}", func(w http.ResponseWriter, r *http.Request) { | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| modeStr := chi.URLParam(r, "mode") | |
| modeRaw, err := strconv.Atoi(modeStr) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| mode := todoViewMode(modeRaw) | |
| if mode < todoViewModeAll || mode > todoViewModeCompleted { | |
| http.Error(w, "invalid mode", http.StatusBadRequest) | |
| return | |
| } | |
| mvc.Mode = mode | |
| if err := saveMVC(r.Context(), sessionID, mvc); err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| w.WriteHeader(204) | |
| }) | |
| todoMVCRouter.Route("/{idx}", func(idxRouter chi.Router) { | |
| routeIndex := func(w http.ResponseWriter, r *http.Request) (int, error) { | |
| idx := chi.URLParam(r, "idx") | |
| i, err := strconv.Atoi(idx) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadRequest) | |
| return 0, err | |
| } | |
| return i, nil | |
| } | |
| idxRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| i, err := routeIndex(w, r) | |
| if err != nil { | |
| return | |
| } | |
| mvc.EditingIdx = i | |
| saveMVC(r.Context(), sessionID, mvc) | |
| w.WriteHeader(204) | |
| }) | |
| idxRouter.Patch("/", func(w http.ResponseWriter, r *http.Request) { | |
| type Signals struct { | |
| Input string `json:"input"` | |
| } | |
| signals := &Signals{} | |
| if err := datastar.ReadSignals(r, signals); err != nil { | |
| http.Error(w, err.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| if signals.Input == "" { | |
| return | |
| } | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| i, err := routeIndex(w, r) | |
| if err != nil { | |
| return | |
| } | |
| if i >= 0 { | |
| mvc.Todos[i].Text = signals.Input | |
| } else { | |
| mvc.Todos = append(mvc.Todos, &todoEntry{ | |
| Text: signals.Input, | |
| Completed: false, | |
| }) | |
| } | |
| mvc.EditingIdx = -1 | |
| saveMVC(r.Context(), sessionID, mvc) | |
| w.WriteHeader(204) | |
| }) | |
| idxRouter.Delete("/", func(w http.ResponseWriter, r *http.Request) { | |
| i, err := routeIndex(w, r) | |
| if err != nil { | |
| return | |
| } | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusInternalServerError) | |
| return | |
| } | |
| if i >= 0 { | |
| mvc.Todos = append(mvc.Todos[:i], mvc.Todos[i+1:]...) | |
| } else { | |
| mvc.Todos = lo.Filter(mvc.Todos, func(todo *todoEntry, i int) bool { | |
| return !todo.Completed | |
| }) | |
| } | |
| saveMVC(r.Context(), sessionID, mvc) | |
| w.WriteHeader(204) | |
| }) | |
| idxRouter.Post("/toggle", func(w http.ResponseWriter, r *http.Request) { | |
| sessionID, mvc, err := mvcSession(w, r) | |
| if err != nil { | |
| sse := datastar.NewSSE(w, r) | |
| sse.ConsoleError(err) | |
| return | |
| } | |
| i, err := routeIndex(w, r) | |
| if err != nil { | |
| sse := datastar.NewSSE(w, r) | |
| sse.ConsoleError(err) | |
| return | |
| } | |
| if i < 0 { | |
| setCompletedTo := false | |
| for _, todo := range mvc.Todos { | |
| if !todo.Completed { | |
| setCompletedTo = true | |
| break | |
| } | |
| } | |
| for _, todo := range mvc.Todos { | |
| todo.Completed = setCompletedTo | |
| } | |
| } else { | |
| todo := mvc.Todos[i] | |
| todo.Completed = !todo.Completed | |
| } | |
| saveMVC(r.Context(), sessionID, mvc) | |
| w.WriteHeader(204) | |
| }) | |
| }) | |
| }) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package examples | |
| import ( | |
| "fmt" | |
| "github.com/samber/lo" | |
| "github.com/starfederation/datastar-dev/site/auth" | |
| . "github.com/starfederation/datastar-dev/site/shared" | |
| "github.com/starfederation/datastar-go/datastar" | |
| ) | |
| templ todo(u *auth.User, mvc *todoMVC) { | |
| @page(u, "todomvc") { | |
| @Demo() { | |
| @todoMVCView(mvc) | |
| } | |
| @Heading("Explanation") | |
| <p> | |
| This is a full implementation of TodoMVC using Datastar. It demonstrates complex state management, including adding, editing, deleting, and filtering todos, all handled through server-sent events. | |
| </p> | |
| @Heading("HTML") | |
| @HtmlCodeHighlight(` | |
| <section | |
| id="todomvc" | |
| data-init="@get('/examples/todomvc/updates')" | |
| > | |
| <header id="todo-header"> | |
| <input | |
| type="checkbox" | |
| data-on:click__prevent="@post('/examples/todomvc/-1/toggle')" | |
| data-init="el.checked = false" | |
| /> | |
| <input | |
| id="new-todo" | |
| type="text" | |
| placeholder="What needs to be done?" | |
| data-signals:input | |
| data-bind:input | |
| data-on:keydown=" | |
| evt.key === 'Enter' && $input.trim() && @patch('/examples/todomvc/-1') && ($input = ''); | |
| " | |
| /> | |
| </header> | |
| <ul id="todo-list"> | |
| <!-- Todo items are dynamically rendered here --> | |
| </ul> | |
| <div id="todo-actions"> | |
| <span> | |
| <strong>0</strong> items pending | |
| </span> | |
| <button class="small info" data-on:click="@put('/examples/todomvc/mode/0')"> | |
| All | |
| </button> | |
| <button class="small" data-on:click="@put('/examples/todomvc/mode/1')"> | |
| Pending | |
| </button> | |
| <button class="small" data-on:click="@put('/examples/todomvc/mode/2')"> | |
| Completed | |
| </button> | |
| <button class="error small" aria-disabled="true"> | |
| Delete | |
| </button> | |
| <button class="warning small" data-on:click="@put('/examples/todomvc/reset')"> | |
| Reset | |
| </button> | |
| </div> | |
| </section> | |
| `) | |
| } | |
| } | |
| templ todoMVCView(mvc *todoMVC) { | |
| {{ | |
| todoLen := len(mvc.Todos) | |
| anyTodos := todoLen > 0 | |
| isCompleted := func(todo *todoEntry) bool { return todo.Completed } | |
| anyCompleted := lo.SomeBy(mvc.Todos, isCompleted) | |
| allCompleted := lo.EveryBy(mvc.Todos, isCompleted) | |
| anyEditing := mvc.EditingIdx != -1 | |
| remaining := todoLen - lo.CountBy(mvc.Todos, isCompleted) | |
| }} | |
| <section | |
| id="todomvc" | |
| data-init="@get('/examples/todomvc/updates')" | |
| > | |
| <header id="todo-header"> | |
| if anyTodos { | |
| <input | |
| type="checkbox" | |
| data-on:click__prevent="@post('/examples/todomvc/-1/toggle')" | |
| data-init={ fmt.Sprintf("el.checked = %t", allCompleted) } | |
| /> | |
| } | |
| <input | |
| id="new-todo" | |
| type="text" | |
| placeholder="What needs to be done?" | |
| if !anyEditing { | |
| data-signals:input | |
| data-bind:input | |
| data-on:keydown=" | |
| evt.key === 'Enter' && $input.trim() && @patch('/examples/todomvc/-1') && ($input = ''); | |
| " | |
| } | |
| /> | |
| </header> | |
| if anyTodos { | |
| <ul id="todo-list"> | |
| for i, todo := range mvc.Todos { | |
| if i == mvc.EditingIdx { | |
| @todoMVCItem(i) { | |
| <input | |
| id="edit-todo" | |
| type="text" | |
| data-signals:input={ fmt.Sprintf("'%s'", mvc.Todos[i].Text) } | |
| data-bind:input | |
| data-init="el.focus()" | |
| data-on:blur="@put('/examples/todomvc/cancel')" | |
| data-on:keydown={ fmt.Sprintf(` | |
| if (evt.key === 'Escape') { | |
| el.blur(); | |
| } else if (evt.key === 'Enter' && $input.trim()) { | |
| @patch('/examples/todomvc/%d'); | |
| } | |
| `, i ) } | |
| /> | |
| } | |
| } else if (mvc.Mode == todoViewModeAll) || | |
| (mvc.Mode == todoViewModePending && !todo.Completed) || | |
| (mvc.Mode == todoViewModeCompleted && todo.Completed) { | |
| @todoMVCItem(i) { | |
| {{ | |
| id := fmt.Sprintf("todo-checkbox-%d", i) | |
| }} | |
| <input | |
| id={ id } | |
| type="checkbox" | |
| data-init={ fmt.Sprintf("el.checked = %t", todo.Completed) } | |
| data-on:click__prevent={ datastar.PostSSE( | |
| "/examples/todomvc/%d/toggle", | |
| i, | |
| ) } | |
| /> | |
| <label for={ id }>{ todo.Text }</label> | |
| <button | |
| class="error small" | |
| data-on:click={ datastar.DeleteSSE("/examples/todomvc/%d", i) } | |
| > | |
| @Icon("pixelarticons:close") | |
| </button> | |
| } | |
| } | |
| } | |
| </ul> | |
| } | |
| <div id="todo-actions"> | |
| if anyTodos { | |
| <span> | |
| <strong>{ remaining }</strong> | |
| if remaining == 1 { | |
| item | |
| } else { | |
| items | |
| } | |
| pending | |
| </span> | |
| for i := todoViewModeAll; i < todoViewModeLast; i++ { | |
| <button | |
| if i == mvc.Mode { | |
| class="small info" | |
| } else { | |
| class="small" | |
| } | |
| data-on:click={ datastar.PutSSE("/examples/todomvc/mode/%d", i) } | |
| > | |
| { todoViewModeStrings[i] } | |
| </button> | |
| } | |
| <button | |
| class="error small" | |
| if anyCompleted { | |
| data-on:click="@delete('/examples/todomvc/-1')" | |
| } else { | |
| aria-disabled="true" | |
| } | |
| > | |
| @Icon("pixelarticons:trash") | |
| Delete | |
| </button> | |
| } | |
| <button | |
| class="warning small" | |
| data-on:click="@put('/examples/todomvc/reset')" | |
| > | |
| @Icon("pixelarticons:reload") | |
| Reset | |
| </button> | |
| </div> | |
| </section> | |
| } | |
| templ todoMVCItem(i int) { | |
| <li | |
| role="button" | |
| tabindex="0" | |
| data-on:dblclick={ fmt.Sprintf("evt.target === el && @get('/examples/todomvc/%d')", i) } | |
| > | |
| { children... } | |
| </li> | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment