Created
January 22, 2026 03:24
-
-
Save delaneyj/b3f754fbd0d15762f14c5d218199e91c 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
| 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