Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated action resolution order removes lvt-submit, but this repo still documents and demonstrates lvt-submit elsewhere (e.g., chat/README.md, counter/README.md, progressive-enhancement/README.md, ws-disabled/README.md). Either keep lvt-submit in this list or update the other docs so contributor guidance is consistent across the repo.

Suggested change
3. Default: `"submit"`
3. Default: `"lvt-submit"`

Copilot uses AI. Check for mistakes.

## Creating New Examples

Expand Down Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
23 changes: 2 additions & 21 deletions avatar-upload/avatar-upload.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
{{end}}
</div>

<form method="POST" name="updateProfile">
<form method="POST" name="updateProfile" enctype="multipart/form-data">
<label for="name">
Name
<input type="text" id="name" name="name" value="{{.Name}}" required>
Expand All @@ -41,7 +41,7 @@
Avatar
<input type="file"
id="avatar"
lvt-upload="avatar"
name="avatar"
accept="image/jpeg,image/png,image/gif">
</label>

Expand Down Expand Up @@ -72,25 +72,6 @@
</article>
</main>

<script>
// Listen for upload events
document.addEventListener('DOMContentLoaded', () => {
const wrapper = document.querySelector('[data-lvt-id]');
if (wrapper) {
wrapper.addEventListener('lvt:upload:progress', (e) => {
console.log('Upload progress:', e.detail.entry.file.name, e.detail.entry.progress + '%');
});

wrapper.addEventListener('lvt:upload:complete', (e) => {
console.log('Upload complete:', e.detail.uploadName);
});

wrapper.addEventListener('lvt:upload:error', (e) => {
console.error('Upload error:', e.detail.error);
});
}
});
</script>
{{if .lvt.DevMode}}
<script src="/livetemplate-client.js"></script>
{{else}}
Expand Down
124 changes: 25 additions & 99 deletions avatar-upload/avatar-upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ins> "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
Expand All @@ -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) {
Expand Down
18 changes: 2 additions & 16 deletions avatar-upload/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}),
))

Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<form name="join">` and `<form name="send">` (button/form `name` routing)
- `ctx.GetString("field")` extracts form data
- Just modify state - broadcasting happens automatically!
- No manual WebSocket code needed

Expand Down Expand Up @@ -240,12 +240,12 @@ Replace `chat.tmpl` with the chat interface. Key template concepts:
**Form Actions:**

```html
<form lvt-submit="join">
<form method="POST" name="join">
<input type="text" name="username" required autofocus>
<button type="submit">Join Chat</button>
</form>

<form lvt-submit="send">
<form method="POST" name="send">
<input type="text" name="message" autocomplete="off">
<button type="submit">Send</button>
</form>
Expand Down
2 changes: 1 addition & 1 deletion chat/chat.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
<button type="submit">Join Chat</button>
</form>
{{else}}
<div class="messages" id="messages" lvt-scroll="bottom-sticky">
<div class="messages" id="messages" lvt-fx:scroll="bottom-sticky">
{{if eq (len .Messages) 0}}
<div class="empty-state">
No messages yet. Be the first to send one!
Expand Down
Loading
Loading