diff --git a/CLAUDE.md b/CLAUDE.md index 9d7a1f8..ad0846a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,19 +22,20 @@ All examples follow the **progressive complexity** model introduced in livetempl | Attribute | Purpose | Example | |-----------|---------|---------| -| `lvt-scroll` | Auto-scroll behavior | Chat message container | +| `lvt-fx:scroll` | Auto-scroll behavior | Chat message container | | `lvt-upload` | Chunked file uploads | Avatar upload | -| `lvt-debounce` | Custom timing control | Search with custom delay | -| `lvt-keydown` | Keyboard shortcuts | Global key bindings | -| `lvt-animate` | Entry/exit animations | Toast notifications | +| `lvt-mod:debounce` | Custom timing control | Search with custom delay | +| `lvt-on:keydown` | Keyboard shortcuts | Global key bindings | +| `lvt-fx:animate` | Entry/exit animations | Toast notifications | +| `lvt-form:preserve` | Preserve form state across re-renders | Shared notepad | +| `lvt-form:no-intercept` | Skip WebSocket, use real HTTP POST | Login/logout forms | ### Action Resolution Order When a form is submitted, the framework resolves the action in this order: -1. `lvt-submit` attribute on the form -2. Clicked button's `name` attribute -3. Form's `name` attribute -4. Default: `"submit"` +1. Clicked button's `name` attribute +2. Form's `name` attribute +3. Default: `"submit"` ## Creating New Examples @@ -65,7 +66,7 @@ When a form is submitted, the framework resolves the action in this order: - `todos/` — Canonical Tier 1 example: CRUD, auth, pagination, modal + toast components - `live-preview/` — Tier 1 with `Change()` method for live updates -- `chat/` — Tier 1+2 (uses `lvt-scroll` for auto-scroll) +- `chat/` — Tier 1+2 (uses `lvt-fx:scroll` for auto-scroll) ## Framework Documentation diff --git a/README.md b/README.md index 8072da3..246f002 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ All examples follow the [progressive complexity](https://github.com/livetemplate | Example | Tier | Description | Tier 2 Attributes | |---------|------|-------------|--------------------| | `counter/` | 1 | Counter with logging + graceful shutdown | None | -| `chat/` | 1+2 | Real-time multi-user chat | `lvt-scroll` | +| `chat/` | 1+2 | Real-time multi-user chat | `lvt-fx:scroll` | | `todos/` | 1+2 | Full CRUD with SQLite, auth, modal + toast components | Component-internal | | `flash-messages/` | 1 | Flash notification patterns | None | | `avatar-upload/` | 1+2 | File upload with progress | `lvt-upload` | | `progressive-enhancement/` | 1 | Works with/without JS | None | | `ws-disabled/` | 1 | HTTP-only mode | None | | `live-preview/` | 1 | Change() live updates | None | -| `login/` | 1 | Authentication + sessions | None | -| `shared-notepad/` | 1 | BasicAuth + SharedState | None | +| `login/` | 1+2 | Authentication + sessions | `lvt-form:no-intercept` | +| `shared-notepad/` | 1+2 | BasicAuth + SharedState | `lvt-form:preserve` | ## Examples @@ -77,7 +77,7 @@ For local development, examples can serve the client library locally using `gith ## Dependencies -- **Core Library**: `github.com/livetemplate/livetemplate v0.8.7` +- **Core Library**: `github.com/livetemplate/livetemplate v0.8.15` - **LVT Testing** (for examples with E2E tests): `github.com/livetemplate/lvt` (latest) - **Client Library**: `@livetemplate/client@latest` (via CDN) diff --git a/avatar-upload/avatar-upload.tmpl b/avatar-upload/avatar-upload.tmpl index b7a623d..10b9537 100644 --- a/avatar-upload/avatar-upload.tmpl +++ b/avatar-upload/avatar-upload.tmpl @@ -26,7 +26,7 @@ {{end}} -
+ @@ -72,25 +72,6 @@ - {{if .lvt.DevMode}} {{else}} diff --git a/avatar-upload/avatar-upload_test.go b/avatar-upload/avatar-upload_test.go index 9854bbe..8bad8f4 100644 --- a/avatar-upload/avatar-upload_test.go +++ b/avatar-upload/avatar-upload_test.go @@ -91,83 +91,41 @@ func TestAvatarUploadE2E(t *testing.T) { }) t.Run("Upload Avatar and Verify", func(t *testing.T) { - // Drive the upload via the WebSocket client's send() method, which is the - // recommended approach per CLAUDE.md: "use window.liveTemplateClient.send() - // directly" for WebSocket verification. This bypasses file-input change - // event timing issues with morphdom DOM replacement. + // Tier 1 file upload: create a File object in JavaScript, set it on the + // input, and submit the form. The client detects the file input and sends + // via HTTP fetch with FormData. The server parses the multipart body. err := chromedp.Run(ctx, // Fresh page load chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), e2etest.WaitForWebSocketReady(5*time.Second), - // Send upload_start via the client's WebSocket, then upload chunks, - // send upload_complete, and finally submit the form action. + // Create a minimal 1x1 PNG file in JavaScript and set it on the input. + // We can't use chromedp.SetUploadFiles because Chrome runs in Docker + // and can't access host filesystem paths. chromedp.Evaluate(` (() => { - // 1x1 red PNG as raw bytes - const pngBytes = new Uint8Array([ - 0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A, - 0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52, - 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, - 0x08,0x02,0x00,0x00,0x00,0x90,0x77,0x53, - 0xDE,0x00,0x00,0x00,0x0C,0x49,0x44,0x41, - 0x54,0x08,0x99,0x63,0xF8,0x0F,0x00,0x00, - 0x01,0x01,0x00,0x05,0x18,0x0D,0xA8,0xDB, - 0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44, - 0xAE,0x42,0x60,0x82 - ]); - - const client = window.liveTemplateClient; - - // Listen for the upload_start response to get the entry ID - const ws = client.ws; - const origOnMessage = ws.onmessage; - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.entries && data.upload_name === 'avatar') { - // Got upload_start response — send chunk + complete - const entryId = data.entries[0].entry_id; - - // Convert to base64 for the chunk message - let binary = ''; - for (let i = 0; i < pngBytes.length; i++) { - binary += String.fromCharCode(pngBytes[i]); - } - const base64Data = btoa(binary); - - ws.send(JSON.stringify({ - action: 'upload_chunk', - entry_id: entryId, - chunk_base64: base64Data, - offset: 0, - total: pngBytes.length - })); - - // Small delay then send upload_complete - setTimeout(() => { - ws.send(JSON.stringify({ - action: 'upload_complete', - upload_name: 'avatar', - entry_ids: [entryId] - })); - }, 50); - } - // Call original handler for DOM updates - origOnMessage.call(ws, event); - }; - - // Send upload_start - client.send({ - action: 'upload_start', - upload_name: 'avatar', - files: [{name: 'test-avatar.png', type: 'image/png', size: pngBytes.length}] - }); + const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAMBBBQAB1x2RAAAASElEQVQI12P4z8BQDwCNAQz/cWMmRQAAAABJRU5ErkJggg=='; + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const file = new File([bytes], 'test-avatar.png', {type: 'image/png'}); + + const input = document.querySelector('#avatar'); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + return 'file set (' + input.files.length + ' files)'; })() `, nil), - // Wait for the "Upload complete!" message via live WebSocket update - e2etest.WaitFor(`document.querySelector('ins') !== null`, 15*time.Second), + // Click submit button + chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), + + // Wait for the avatar image to appear + e2etest.WaitFor(`document.querySelector('img[alt="Avatar"]') !== null`, 15*time.Second), ) if err != nil { var debugHTML string @@ -176,39 +134,7 @@ func TestAvatarUploadE2E(t *testing.T) { t.Fatalf("Upload flow failed: %v", err) } - // Verify the success message - var successText string - err = chromedp.Run(ctx, - chromedp.TextContent(`ins`, &successText, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to read success message: %v", err) - } - if !strings.Contains(successText, "Upload complete") { - t.Errorf("Expected 'Upload complete' in success message, got: %q", successText) - } - - // Verify the avatar image appeared (server moved the file and set AvatarURL) - err = chromedp.Run(ctx, - e2etest.WaitFor(`document.querySelector('img[alt="Avatar"]') !== null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Avatar image did not appear after upload: %v", err) - } - - // Verify the progress bar shows 100% - var progressVal string - err = chromedp.Run(ctx, - chromedp.AttributeValue(`progress`, "value", &progressVal, nil, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to read progress bar value: %v", err) - } - if progressVal != "100" { - t.Errorf("Expected progress value 100, got: %s", progressVal) - } - - t.Log("Upload complete, success message shown, avatar image rendered, progress at 100%") + t.Log("✅ Tier 1 file upload: avatar image rendered after form submission") }) t.Run("WebSocket Connection", func(t *testing.T) { diff --git a/avatar-upload/main.go b/avatar-upload/main.go index 1ee1cc4..637c9a9 100644 --- a/avatar-upload/main.go +++ b/avatar-upload/main.go @@ -44,16 +44,7 @@ func (c *ProfileController) UpdateProfile(state ProfileState, ctx *livetemplate. return state, nil } -// UploadAvatarComplete handles the "upload_avatar_complete" action. -// Auto-triggered when avatar upload completes. -func (c *ProfileController) UploadAvatarComplete(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) { - log.Printf("DEBUG: Processing auto-triggered upload") - return c.processAvatarUpload(state, ctx) -} - // processAvatarUpload handles avatar upload processing -// Called either automatically when upload completes (upload_avatar_complete action) -// or during explicit form submission (UpdateProfile action) func (c *ProfileController) processAvatarUpload(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) { // Get completed uploads from Context uploads := ctx.GetCompletedUploads("avatar") @@ -133,8 +124,6 @@ func main() { Accept: []string{"image/jpeg", "image/png", "image/gif"}, MaxFileSize: 5 * 1024 * 1024, // 5MB MaxEntries: 1, // Single file - AutoUpload: false, // Upload on form submit - ChunkSize: 256 * 1024, // 256KB chunks }), )) @@ -161,11 +150,8 @@ func main() { // Start server addr := ":" + port - log.Printf("🚀 Avatar upload example running at http://localhost%s", addr) - log.Printf("📸 Upload an avatar to see the upload feature in action!") - log.Printf("📁 Uploaded files will be saved to ./uploads/") - log.Printf("✨ Upload processing happens automatically when upload completes") - log.Printf(" (via upload_avatar_complete action) or during form submission") + log.Printf("Avatar upload example running at http://localhost%s", addr) + log.Printf("Uploaded files will be saved to ./uploads/") if err := http.ListenAndServe(addr, nil); err != nil { log.Fatal(err) diff --git a/chat/README.md b/chat/README.md index a6c82ad..22e2872 100644 --- a/chat/README.md +++ b/chat/README.md @@ -162,8 +162,8 @@ func (s *ChatState) updateOnlineCount() { **Key concepts:** -- `ctx.Action` comes from HTML `lvt-submit="action"` attribute -- `ctx.Bind(&data)` extracts form data +- Actions route via `` and `` (button/form `name` routing) +- `ctx.GetString("field")` extracts form data - Just modify state - broadcasting happens automatically! - No manual WebSocket code needed @@ -240,12 +240,12 @@ Replace `chat.tmpl` with the chat interface. Key template concepts: **Form Actions:** ```html - + -
+
diff --git a/chat/chat.tmpl b/chat/chat.tmpl index 5129e13..a4a23c6 100644 --- a/chat/chat.tmpl +++ b/chat/chat.tmpl @@ -83,7 +83,7 @@ {{else}} -
+
{{if eq (len .Messages) 0}}
No messages yet. Be the first to send one! diff --git a/counter/README.md b/counter/README.md index a85e80b..98c7c76 100644 --- a/counter/README.md +++ b/counter/README.md @@ -85,71 +85,71 @@ For more details, see [CONFIGURATION.md](../../docs/CONFIGURATION.md). The server is extremely simple with the new reactive API: ```go +// Controller: singleton, holds dependencies (none in this simple example) +type CounterController struct{} + +// State: pure data, cloned per session type CounterState struct { - Counter int `json:"counter"` - Status string `json:"status"` - // ... other fields + Title string `json:"title" lvt:"persist"` + Counter int `json:"counter" lvt:"persist"` + LastUpdated string `json:"last_updated" lvt:"persist"` } -// Implement the Store interface -func (s *CounterState) Change(action string, data map[string]interface{}) { - switch action { - case "increment": - s.Counter++ - case "decrement": - s.Counter-- - case "reset": - s.Counter = 0 - } - - // Update derived state - s.Status = getStatus(s.Counter) - s.LastUpdated = formatTime() +// Named action methods — routed via - - + + + + ``` That's it! No JavaScript code needed. The client library auto-initializes and handles: -- **Declarative event binding** via `lvt-*` attributes (`lvt-click`, `lvt-submit`, `lvt-change`, `lvt-input`, etc.) +- **Button name routing**: ` + + - - - - -
+ + - +
- - + +
+ + +
``` -All values are collected into a `data` map and passed to the store's `Change()` method: +Form field values are accessed in the controller via `ctx.GetString()`, `ctx.GetInt()`, or `ctx.BindAndValidate()`: ```go -func (s *Store) Change(action string, data map[string]interface{}) { - id := livetemplate.GetInt(data, "id") - title := livetemplate.GetString(data, "title") +func (c *Controller) Delete(state State, ctx *livetemplate.Context) (State, error) { + id := ctx.GetInt("id") // ... } ``` -#### Supported lvt-* attributes: -- `lvt-click` - Handle click events -- `lvt-submit` - Handle form submissions (prevents default, sends all form fields) -- `lvt-change` - Handle input change events (sends input value as "value") -- `lvt-input` - Handle input events for real-time updates (sends input value as "value") -- `lvt-keydown` - Handle keydown events -- `lvt-keyup` - Handle keyup events -- `lvt-data-*` - Include custom data in the data map (e.g., `lvt-data-id="123"`) -- `lvt-value-*` - Include explicit multiple values (e.g., `lvt-value-quantity="5"`) +#### Tier 2 attributes (use only when standard HTML can't express it): +- `lvt-on:click` - Route click events on non-button elements (e.g., table rows) +- `lvt-on:keydown` - Handle keyboard events +- `lvt-mod:debounce` - Custom timing control for event routing +- `lvt-fx:scroll` - Auto-scroll behavior +- `lvt-fx:animate` - Entry/exit animations +- `lvt-form:preserve` - Prevent form auto-reset +- `lvt-form:no-intercept` - Skip WebSocket, use real HTTP POST ### LiveTemplate Integration @@ -218,10 +214,10 @@ Browser WebSocket/HTTP Go Server │ LiveTemplate │ │ │ │ data) │ │ Client JS │ │ │ │ │ │ │ │ │ │ Handle() │ -│ lvt-* attrs │ │ Auto- │ │ - Clones state │ -│ - click │ │ detects │ │ - Generates │ -│ - submit │ │ transport│ │ updates │ -│ - change │ │ │ │ - Broadcasts │ +│ Button name │ │ Auto- │ │ - Clones state │ +│ routing │ │ detects │ │ - Generates │ +│ (Tier 1 HTML) │ │ transport│ │ updates │ +│ │ │ │ │ - Broadcasts │ └─────────────────┘ └──────────┘ └──────────────────┘ ``` @@ -274,23 +270,21 @@ The template follows the same pattern as `testdata/e2e/counter/input.tmpl`: - **Session Management**: HTTP connections use cookie-based sessions for state persistence - **Error Handling**: Automatic WebSocket reconnection and comprehensive error logging -## Multiple Stores +## Controller+State Pattern -For applications with multiple state objects, pass them to `Handle()`: +The counter uses the Controller+State pattern introduced in v0.7.0: ```go -counter := &CounterState{} -user := &UserState{} +// Controller: singleton, holds dependencies +controller := &CounterController{} -tmpl := livetemplate.New("app") -http.Handle("/", tmpl.Handle(counter, user)) -``` +// State: pure data, cloned per session +initialState := &CounterState{Title: "Live Counter", Counter: 0} -**Store names are auto-derived from struct types** (case-insensitive): -- `CounterState` → actions use `"counterstate.increment"` or `"CounterState.increment"` -- `UserState` → actions use `"userstate.logout"` or `"UserState.logout"` +tmpl := livetemplate.Must(livetemplate.New("counter")) +http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) +``` -```html - - -``` \ No newline at end of file +- **Controller** holds dependencies (DB, Logger, etc.) — never cloned +- **State** is pure data — cloned per session, serializable +- Action methods on the controller receive state and return modified state \ No newline at end of file diff --git a/docs/plans/improve-ui-ux.md b/docs/plans/improve-ui-ux.md index 0bdb3f5..d3c989a 100644 --- a/docs/plans/improve-ui-ux.md +++ b/docs/plans/improve-ui-ux.md @@ -115,7 +115,7 @@ Apply to ALL 10 templates: - Replace `.stats` div → `` element (Pico handles the styling) - Replace `.input-form` grid → `
` (compact input+button on one line) - Replace `.empty-state` → `` -- Keep message container `.messages` CSS (genuine Tier 2 need for scrollable area with `lvt-scroll`) +- Keep message container `.messages` CSS (genuine Tier 2 need for scrollable area with `lvt-fx:scroll`) - Keep `.message` styles but minimize — use Pico variables - Remove `body { padding: 1rem; }` — Pico's container handles this - Wrap in `
` for consistent card structure diff --git a/go.mod b/go.mod index 1807c60..a9abf39 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/go-playground/validator/v10 v10.30.1 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.8.14 + github.com/livetemplate/livetemplate v0.8.16 github.com/livetemplate/lvt v0.1.1 github.com/livetemplate/lvt/components v0.1.1 modernc.org/sqlite v1.43.0 diff --git a/go.sum b/go.sum index f99ccd8..b5a703a 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/livetemplate/livetemplate v0.8.14 h1:FLHnp7hRSxjsc9VEoIufJ5DjZEdRuuWxjMzpHyIdgmw= -github.com/livetemplate/livetemplate v0.8.14/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.8.16 h1:HHqFyMpPTE46tWIB1Jg18fICP+HdtOnQ0K58bIAXN4A= +github.com/livetemplate/livetemplate v0.8.16/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= github.com/livetemplate/lvt v0.1.1 h1:HKpt5gKP4bLbZ74S+fgjZR4wEEPd51dh972w29shTAM= github.com/livetemplate/lvt v0.1.1/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU= github.com/livetemplate/lvt/components v0.1.1 h1:C8/g3KW5X9kGicaLBRh0vN2Vzr6ddgjM9Po8M4peAtk= diff --git a/login/templates/auth.html b/login/templates/auth.html index 5c4f457..69fe13c 100644 --- a/login/templates/auth.html +++ b/login/templates/auth.html @@ -22,8 +22,8 @@

Dashboard

Logged in at: {{.LoginTime.Format "15:04:05"}} {{end}} - -
+ +
@@ -32,8 +32,8 @@

Dashboard

Login

{{.lvt.FlashTag "error"}} - -
+ +