diff --git a/README.md b/README.md index 2f77311..c7215dd 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,6 @@ All examples follow the [progressive complexity](https://github.com/livetemplate | `live-preview/` | 1 | Change() live updates | None | | `login/` | 1 | Authentication + sessions | None | | `shared-notepad/` | 1 | BasicAuth + SharedState | None | -| `ephemeral-counter/` | 1 | Ephemeral state with in-memory DB | None | -| `ephemeral-todos/` | 1 | Ephemeral state with SQLite DB | None | ## Examples diff --git a/avatar-upload/main.go b/avatar-upload/main.go index 19a8847..1ee1cc4 100644 --- a/avatar-upload/main.go +++ b/avatar-upload/main.go @@ -20,10 +20,10 @@ type ProfileController struct{} // ProfileState is pure data, cloned per session. type ProfileState struct { - Name string - Email string - AvatarPath string - AvatarURL string + Name string `lvt:"persist"` + Email string `lvt:"persist"` + AvatarPath string `lvt:"persist"` + AvatarURL string `lvt:"persist"` } // UpdateProfile handles the "UpdateProfile" action for profile update form submission diff --git a/chat/main.go b/chat/main.go index 25c69b3..151c2e9 100644 --- a/chat/main.go +++ b/chat/main.go @@ -25,7 +25,7 @@ type ChatController struct { // CurrentUser is never shared across tabs. type ChatState struct { Messages []Message `json:"messages"` - CurrentUser string `json:"current_user"` + CurrentUser string `json:"current_user" lvt:"persist"` OnlineCount int `json:"online_count"` TotalMessages int `json:"total_messages"` } diff --git a/counter/main.go b/counter/main.go index 6dc1589..292179f 100644 --- a/counter/main.go +++ b/counter/main.go @@ -19,9 +19,9 @@ type CounterController struct{} // CounterState is pure data, cloned per session. type CounterState struct { - Title string `json:"title"` - Counter int `json:"counter"` - LastUpdated string `json:"last_updated"` + Title string `json:"title" lvt:"persist"` + Counter int `json:"counter" lvt:"persist"` + LastUpdated string `json:"last_updated" lvt:"persist"` } func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) { diff --git a/ephemeral-counter/counter.tmpl b/ephemeral-counter/counter.tmpl deleted file mode 100644 index 7796507..0000000 --- a/ephemeral-counter/counter.tmpl +++ /dev/null @@ -1,31 +0,0 @@ - - - - Ephemeral Counter - - - - - -
-
-
-

Ephemeral Counter

-

Database is the source of truth. State is rebuilt from DB on every page load.

-
-

Count: {{.Count}}

-
- - - -
-
-
- - {{if .lvt.DevMode}} - - {{else}} - - {{end}} - - diff --git a/ephemeral-counter/counter_test.go b/ephemeral-counter/counter_test.go deleted file mode 100644 index b582133..0000000 --- a/ephemeral-counter/counter_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - "testing" - "time" - - "github.com/chromedp/chromedp" - e2etest "github.com/livetemplate/lvt/testing" -) - -func TestMain(m *testing.M) { - e2etest.CleanupChromeContainers() - code := m.Run() - e2etest.CleanupChromeContainers() - os.Exit(code) -} - -func TestEphemeralCounterE2E(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - serverPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for server: %v", err) - } - - debugPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for Chrome: %v", err) - } - - serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) - defer func() { - if serverCmd != nil && serverCmd.Process != nil { - serverCmd.Process.Kill() - } - }() - - chromeCmd := e2etest.StartDockerChrome(t, debugPort) - defer e2etest.StopDockerChrome(t, debugPort) - _ = chromeCmd - - chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) - allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) - defer cancel() - - ctx, cancel = context.WithTimeout(ctx, 60*time.Second) - defer cancel() - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "Count: 0") { - t.Error("Expected initial Count: 0") - } - }) - - t.Run("Increment", func(t *testing.T) { - err := chromedp.Run(ctx, - e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second), - chromedp.Evaluate(`document.querySelector('button[name="increment"]').click()`, nil), - e2etest.WaitFor(`document.body.innerText.includes('Count: 1')`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to increment: %v", err) - } - }) - - t.Run("Ephemeral_State_Survives_Refresh_Via_DB", func(t *testing.T) { - // Ephemeral mode: session state is NOT persisted, but the in-memory "DB" - // retains the count. On refresh, Mount() reloads from DB, so count = 1. - err := chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to reload: %v", err) - } - - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get HTML: %v", err) - } - - if !strings.Contains(html, "Count: 1") { - t.Errorf("Expected Count: 1 after refresh (loaded from DB), got: %s", html) - } - }) -} diff --git a/ephemeral-counter/main.go b/ephemeral-counter/main.go deleted file mode 100644 index 65c022c..0000000 --- a/ephemeral-counter/main.go +++ /dev/null @@ -1,115 +0,0 @@ -package main - -import ( - "context" - "log/slog" - "net/http" - "os" - "os/signal" - "sync" - "syscall" - "time" - - "github.com/livetemplate/livetemplate" - e2etest "github.com/livetemplate/lvt/testing" -) - -// CounterDB is a thread-safe in-memory counter that acts as the "database". -// With WithEphemeralState(), LiveTemplate state is rebuilt from this store -// on every request — no session persistence needed. -type CounterDB struct { - mu sync.Mutex - value int -} - -func (db *CounterDB) Get() int { - db.mu.Lock() - defer db.mu.Unlock() - return db.value -} - -func (db *CounterDB) Add(delta int) int { - db.mu.Lock() - defer db.mu.Unlock() - db.value += delta - return db.value -} - -func (db *CounterDB) Reset() { - db.mu.Lock() - defer db.mu.Unlock() - db.value = 0 -} - -type CounterController struct { - DB *CounterDB -} - -type CounterState struct { - Count int `json:"count"` -} - -func (c *CounterController) Mount(state CounterState, ctx *livetemplate.Context) (CounterState, error) { - state.Count = c.DB.Get() - return state, nil -} - -func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) { - state.Count = c.DB.Add(1) - return state, nil -} - -func (c *CounterController) Decrement(state CounterState, ctx *livetemplate.Context) (CounterState, error) { - state.Count = c.DB.Add(-1) - return state, nil -} - -func (c *CounterController) Reset(state CounterState, ctx *livetemplate.Context) (CounterState, error) { - c.DB.Reset() - state.Count = 0 - return state, nil -} - -func main() { - controller := &CounterController{DB: &CounterDB{}} - - tmpl := livetemplate.Must(livetemplate.New("counter")) - handler := tmpl.Handle(controller, livetemplate.AsState(&CounterState{}), - livetemplate.WithEphemeralState(), - ) - - mux := http.NewServeMux() - mux.Handle("/", handler) - mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) - - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - - server := &http.Server{ - Addr: ":" + port, - Handler: mux, - ReadTimeout: 15 * time.Second, - WriteTimeout: 15 * time.Second, - } - - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - - go func() { - slog.Info("Server starting", "url", "http://localhost:"+port) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("Server failed", "error", err) - os.Exit(1) - } - }() - - <-quit - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := server.Shutdown(ctx); err != nil { - slog.Error("Shutdown error", "error", err) - } -} diff --git a/ephemeral-todos/main.go b/ephemeral-todos/main.go deleted file mode 100644 index 0d7e559..0000000 --- a/ephemeral-todos/main.go +++ /dev/null @@ -1,167 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "log/slog" - "net/http" - "os" - "os/signal" - "strconv" - "syscall" - "time" - - "github.com/livetemplate/livetemplate" - e2etest "github.com/livetemplate/lvt/testing" - _ "modernc.org/sqlite" -) - -type Todo struct { - ID int `json:"id"` - Title string `json:"title"` - Done bool `json:"done"` -} - -// TodoController holds the database dependency. -type TodoController struct { - DB *sql.DB -} - -// TodoState is rebuilt from the database on every request. -type TodoState struct { - Items []Todo `json:"items"` -} - -func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - rows, err := c.DB.Query("SELECT id, title, done FROM todos ORDER BY id") - if err != nil { - return state, err - } - defer rows.Close() - - state.Items = nil - for rows.Next() { - var t Todo - if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil { - return state, err - } - state.Items = append(state.Items, t) - } - return state, nil -} - -func (c *TodoController) Submit(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - title := ctx.GetString("title") - if title == "" { - return state, livetemplate.FieldError{Field: "title", Message: "Title is required"} - } - - result, err := c.DB.Exec("INSERT INTO todos (title, done) VALUES (?, 0)", title) - if err != nil { - return state, err - } - - id, _ := result.LastInsertId() - state.Items = append(state.Items, Todo{ID: int(id), Title: title, Done: false}) - return state, nil -} - -func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - id, _ := strconv.Atoi(ctx.GetString("value")) - _, err := c.DB.Exec("UPDATE todos SET done = NOT done WHERE id = ?", id) - if err != nil { - return state, err - } - - for i := range state.Items { - if state.Items[i].ID == id { - state.Items[i].Done = !state.Items[i].Done - break - } - } - return state, nil -} - -func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - id, _ := strconv.Atoi(ctx.GetString("value")) - _, err := c.DB.Exec("DELETE FROM todos WHERE id = ?", id) - if err != nil { - return state, err - } - - for i := range state.Items { - if state.Items[i].ID == id { - state.Items = append(state.Items[:i], state.Items[i+1:]...) - break - } - } - return state, nil -} - -func initDB() *sql.DB { - db, err := sql.Open("sqlite", "file:todos.db?cache=shared&mode=rwc") - if err != nil { - slog.Error("Failed to open database", "error", err) - os.Exit(1) - } - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - done BOOLEAN NOT NULL DEFAULT 0 - )`) - if err != nil { - slog.Error("Failed to create table", "error", err) - os.Exit(1) - } - return db -} - -func main() { - db := initDB() - defer db.Close() - - controller := &TodoController{DB: db} - - opts := []livetemplate.Option{} - if os.Getenv("LVT_WEBSOCKET_DISABLED") == "true" { - opts = append(opts, livetemplate.WithWebSocketDisabled()) - } - - tmpl := livetemplate.Must(livetemplate.New("todos", opts...)) - handler := tmpl.Handle(controller, livetemplate.AsState(&TodoState{}), - livetemplate.WithEphemeralState(), - ) - - mux := http.NewServeMux() - mux.Handle("/", handler) - mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) - - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - - server := &http.Server{ - Addr: ":" + port, - Handler: mux, - ReadTimeout: 15 * time.Second, - WriteTimeout: 15 * time.Second, - } - - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - - go func() { - slog.Info("Server starting", "url", "http://localhost:"+port) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("Server failed", "error", err) - os.Exit(1) - } - }() - - <-quit - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - server.Shutdown(ctx) -} diff --git a/ephemeral-todos/todos.tmpl b/ephemeral-todos/todos.tmpl deleted file mode 100644 index 49329a9..0000000 --- a/ephemeral-todos/todos.tmpl +++ /dev/null @@ -1,51 +0,0 @@ - - - - Ephemeral Todos - - - - - -
-
-
-

Ephemeral Todos

-

Database is the source of truth. State is rebuilt from DB on every page load.

-
- -
-
- - -
- {{if .lvt.HasError "title"}} - {{.lvt.Error "title"}} - {{end}} -
- - {{if .Items}} - - {{else}} -

No todos yet. Add one above!

- {{end}} -
-
- - {{if .lvt.DevMode}} - - {{else}} - - {{end}} - - diff --git a/ephemeral-todos/todos_test.go b/ephemeral-todos/todos_test.go deleted file mode 100644 index e36efe3..0000000 --- a/ephemeral-todos/todos_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - "testing" - "time" - - "github.com/chromedp/chromedp" - e2etest "github.com/livetemplate/lvt/testing" -) - -func TestMain(m *testing.M) { - e2etest.CleanupChromeContainers() - code := m.Run() - e2etest.CleanupChromeContainers() - os.Exit(code) -} - -func TestEphemeralTodosE2E(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - serverPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for server: %v", err) - } - - debugPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for Chrome: %v", err) - } - - serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) - defer func() { - if serverCmd != nil && serverCmd.Process != nil { - serverCmd.Process.Kill() - } - }() - - chromeCmd := e2etest.StartDockerChrome(t, debugPort) - defer e2etest.StopDockerChrome(t, debugPort) - _ = chromeCmd - - chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) - allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) - defer cancel() - - ctx, cancel = context.WithTimeout(ctx, 60*time.Second) - defer cancel() - - t.Run("Initial_Load_Empty", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "No todos yet") { - t.Error("Expected empty state message") - } - }) - - t.Run("Add_Todo", func(t *testing.T) { - err := chromedp.Run(ctx, - e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second), - chromedp.SetValue(`input[name="title"]`, "Buy groceries", chromedp.ByQuery), - chromedp.Evaluate(`document.querySelector('button[type="submit"]').click()`, nil), - e2etest.WaitFor(`document.body.innerText.includes('Buy groceries')`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to add todo: %v", err) - } - }) - - t.Run("Todo_Survives_Refresh_Via_DB", func(t *testing.T) { - // Ephemeral mode: session state is NOT persisted, but SQLite retains the - // todo. On refresh, Mount() reloads from DB. - err := chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to reload: %v", err) - } - - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get HTML: %v", err) - } - - if !strings.Contains(html, "Buy groceries") { - t.Errorf("Expected 'Buy groceries' after refresh (loaded from DB), got: %s", html) - } - }) -} diff --git a/flash-messages/main.go b/flash-messages/main.go index f399231..d0e4a8c 100644 --- a/flash-messages/main.go +++ b/flash-messages/main.go @@ -21,8 +21,8 @@ type FlashController struct{} // FlashState holds the demo data. type FlashState struct { Title string `json:"title"` - Items []string `json:"items"` - ItemCount int `json:"item_count"` + Items []string `json:"items" lvt:"persist"` + ItemCount int `json:"item_count" lvt:"persist"` } // AddItem handles the "add_item" action - demonstrates success flash. diff --git a/go.mod b/go.mod index 210b8f4..d8f1f1b 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.8.11 + github.com/livetemplate/livetemplate v0.8.12 github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e modernc.org/sqlite v1.43.0 diff --git a/go.sum b/go.sum index 320bcde..47cc436 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,10 @@ github.com/livetemplate/livetemplate v0.8.10 h1:Lg62gb297Iq3/EShPnEUyqDFN8aeyTYc github.com/livetemplate/livetemplate v0.8.10/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= github.com/livetemplate/livetemplate v0.8.11 h1:s7yFfHp53tv5W7WNViQUK7EBq6ZmBth+mGA34+/zL9s= github.com/livetemplate/livetemplate v0.8.11/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.8.12-0.20260401033655-c6263289f218 h1:qOcImuTin52xJefrRBm/vJfOaYrRYG+GnU1gB6/47aw= +github.com/livetemplate/livetemplate v0.8.12-0.20260401033655-c6263289f218/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.8.12 h1:OeGWVFgLzFOUBGnTFSGSlJKAgIZVyuEjlaWYoBT8iqI= +github.com/livetemplate/livetemplate v0.8.12/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e h1:nAV7BaOatFcbSaP6m9CgLrRsGPeWtbjPDZ8dOI6Zb+c= github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU= github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e h1:vuR0pQtEQHZOD2/HvTJfHPKEdoD77XUs1mq1kdjgVig= diff --git a/login/main.go b/login/main.go index 8c1decf..b288941 100644 --- a/login/main.go +++ b/login/main.go @@ -23,11 +23,11 @@ type AuthController struct { // AuthState is pure data, cloned per session. // Contains only serializable fields for the auth UI. type AuthState struct { - Username string - IsLoggedIn bool + Username string `lvt:"persist"` + IsLoggedIn bool `lvt:"persist"` Error string - ServerMessage string // Message sent from server via WebSocket - LoginTime time.Time // When user logged in + ServerMessage string + LoginTime time.Time `lvt:"persist"` } // Login handles the "login" action diff --git a/profile-progressive/main.go b/profile-progressive/main.go index ea0097a..a9f1af9 100644 --- a/profile-progressive/main.go +++ b/profile-progressive/main.go @@ -20,10 +20,10 @@ import ( var validate = validator.New() type ProfileState struct { - DisplayName string - Email string - Bio string - Saved bool + DisplayName string `lvt:"persist"` + Email string `lvt:"persist"` + Bio string `lvt:"persist"` + Saved bool `lvt:"persist"` } type ProfileController struct { diff --git a/progressive-enhancement/main.go b/progressive-enhancement/main.go index cedab08..6285c39 100644 --- a/progressive-enhancement/main.go +++ b/progressive-enhancement/main.go @@ -23,9 +23,9 @@ type TodoController struct { // It contains all the state needed to render the template. type TodoState struct { Title string `json:"title"` - Items []Todo `json:"items"` + Items []Todo `json:"items" lvt:"persist"` // Form input values (preserved on validation errors) - InputTitle string `json:"input_title"` + InputTitle string `json:"input_title" lvt:"persist"` } // Todo represents a single todo item. diff --git a/shared-notepad/main.go b/shared-notepad/main.go index 26ab79b..b20d047 100644 --- a/shared-notepad/main.go +++ b/shared-notepad/main.go @@ -18,9 +18,9 @@ type NotepadController struct { type NotepadState struct { Username string `json:"username"` - Content string `json:"content"` - SavedAt string `json:"saved_at"` - CharCount int `json:"char_count"` + Content string `json:"content" lvt:"persist"` + SavedAt string `json:"saved_at" lvt:"persist"` + CharCount int `json:"char_count" lvt:"persist"` } func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) { diff --git a/test-all.sh b/test-all.sh index 33a3a9d..ae85c0d 100755 --- a/test-all.sh +++ b/test-all.sh @@ -36,8 +36,6 @@ WORKING_EXAMPLES=( "todos-components" "shared-notepad" "flash-messages" - "ephemeral-counter" - "ephemeral-todos" ) # Disabled examples diff --git a/todos-components/main.go b/todos-components/main.go index 22ded49..9f7f393 100644 --- a/todos-components/main.go +++ b/todos-components/main.go @@ -23,18 +23,40 @@ type Todo struct { // TodoState holds the application state. type TodoState struct { - Title string - Todos []Todo - NewTodoTitle string + Title string `lvt:"persist"` + Todos []Todo `lvt:"persist"` + NewTodoTitle string `lvt:"persist"` Toasts *toast.Container DeleteConfirm *modal.ConfirmModal - DeleteID int // ID of todo pending deletion - NextID int // Must be exported for JSON serialization + DeleteID int // ID of todo pending deletion + NextID int `lvt:"persist"` } // TodoController handles todo actions. type TodoController struct{} +// Mount re-initializes non-serializable component objects on every request. +func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + if state.Toasts == nil { + toasts := toast.New("notifications", + toast.WithPosition(toast.TopRight), + toast.WithMaxVisible(3), + ) + toasts.SetStyled(false) + state.Toasts = toasts + } + if state.DeleteConfirm == nil { + state.DeleteConfirm = modal.NewConfirm("delete_confirm", + modal.WithConfirmTitle("Delete Todo"), + modal.WithConfirmMessage("Are you sure you want to delete this todo?"), + modal.WithConfirmDestructive(true), + modal.WithConfirmText("Delete"), + modal.WithCancelText("Cancel"), + ) + } + return state, nil +} + // AddTodo handles the "add_todo" action. func (c *TodoController) AddTodo(state TodoState, ctx *livetemplate.Context) (TodoState, error) { title := ctx.GetString("title") diff --git a/todos-progressive/main.go b/todos-progressive/main.go index edb15e1..567e43c 100644 --- a/todos-progressive/main.go +++ b/todos-progressive/main.go @@ -35,8 +35,8 @@ type Todo struct { } type TodoState struct { - Items []Todo - ActiveFilter string + Items []Todo `lvt:"persist"` + ActiveFilter string `lvt:"persist"` } func (s TodoState) ActiveCount() int { diff --git a/todos/state.go b/todos/state.go index 1c37c8f..5a0c7cb 100644 --- a/todos/state.go +++ b/todos/state.go @@ -60,13 +60,13 @@ type PaginationInput struct { // The state is serializable and can be safely passed to templates. type TodoState struct { // Display metadata - Title string `json:"title"` - Username string `json:"username"` + Title string `json:"title" lvt:"persist"` + Username string `json:"username" lvt:"persist"` LastUpdated string `json:"last_updated"` // Filter and sort settings - SearchQuery string `json:"search_query"` - SortBy string `json:"sort_by"` + SearchQuery string `json:"search_query" lvt:"persist"` + SortBy string `json:"sort_by" lvt:"persist"` // Todo data FilteredTodos []TodoItem `json:"filtered_todos"` // After search filter applied @@ -78,8 +78,8 @@ type TodoState struct { RemainingCount int `json:"remaining_count"` // Pagination state - CurrentPage int `json:"current_page"` - PageSize int `json:"page_size"` + CurrentPage int `json:"current_page" lvt:"persist"` + PageSize int `json:"page_size" lvt:"persist"` TotalPages int `json:"total_pages"` ShowPagination bool `json:"show_pagination"` PrevDisabled bool `json:"prev_disabled"` diff --git a/ws-disabled/main.go b/ws-disabled/main.go index 9439238..5d7418c 100644 --- a/ws-disabled/main.go +++ b/ws-disabled/main.go @@ -18,8 +18,8 @@ type BookmarkController struct{} // BookmarkState is pure data, cloned per session. type BookmarkState struct { Title string `json:"title"` - Bookmarks []Bookmark `json:"bookmarks"` - Count int `json:"count"` + Bookmarks []Bookmark `json:"bookmarks" lvt:"persist"` + Count int `json:"count" lvt:"persist"` } // Bookmark represents a single bookmark.