diff --git a/avatar-upload/avatar-upload.tmpl b/avatar-upload/avatar-upload.tmpl
index d58008b..2dc757e 100644
--- a/avatar-upload/avatar-upload.tmpl
+++ b/avatar-upload/avatar-upload.tmpl
@@ -168,6 +168,16 @@
margin-top: 5px;
}
+ .uploading {
+ color: #667eea;
+ font-size: 12px;
+ margin-top: 5px;
+ }
+
+ .upload-status {
+ min-height: 20px;
+ }
+
button {
width: 100%;
padding: 14px;
@@ -261,11 +271,15 @@
+
{{if .Error}}
❌ {{.Error}}
{{else if .Done}}
✅ Upload complete!
+ {{else}}
+
⏳ Uploading...
{{end}}
+
{{end}}
diff --git a/avatar-upload/go.mod b/avatar-upload/go.mod
index 3d094e2..75999df 100644
--- a/avatar-upload/go.mod
+++ b/avatar-upload/go.mod
@@ -2,24 +2,56 @@ module github.com/livetemplate/examples/avatar-upload
go 1.25.3
-require github.com/livetemplate/livetemplate v0.3.0
+require (
+ github.com/chromedp/chromedp v0.14.2
+ github.com/livetemplate/livetemplate v0.3.0
+ github.com/livetemplate/lvt v0.0.0-20251103203330-967d24366dee
+)
+
+replace github.com/livetemplate/livetemplate => ../../livetemplate
+
+replace github.com/livetemplate/lvt => ../../lvt
require (
+ github.com/aws/aws-sdk-go-v2 v1.39.6 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
+ github.com/aws/smithy-go v1.23.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
+ github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
- github.com/gabriel-vasile/mimetype v1.4.10 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.11 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/redis/go-redis/v9 v9.16.0 // indirect
- github.com/tdewolff/minify/v2 v2.24.3 // indirect
- github.com/tdewolff/parse/v2 v2.8.3 // indirect
- golang.org/x/crypto v0.42.0 // indirect
- golang.org/x/net v0.44.0 // indirect
- golang.org/x/sys v0.36.0 // indirect
- golang.org/x/text v0.29.0 // indirect
+ github.com/tdewolff/minify/v2 v2.24.6 // indirect
+ github.com/tdewolff/parse/v2 v2.8.5 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/net v0.46.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
)
diff --git a/avatar-upload/go.sum b/avatar-upload/go.sum
index 1ea0169..43a7e5c 100644
--- a/avatar-upload/go.sum
+++ b/avatar-upload/go.sum
@@ -4,6 +4,42 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
+github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
+github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
+github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
+github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
+github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
+github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
+github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
+github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
+github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -12,6 +48,12 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -38,8 +80,10 @@ github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0o
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
-github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
+github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -54,6 +98,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -62,10 +112,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+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.3.0 h1:k3cpsiOmWcjS+S/e5rvydbi6nY86ipnWd801B5hgL5M=
-github.com/livetemplate/livetemplate v0.3.0/go.mod h1:PYS1hH2a6o7d9LID96780l/HHSGJIJS70kBnVO1mIUA=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
@@ -92,6 +142,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -106,10 +158,10 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
-github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
-github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
-github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
+github.com/tdewolff/minify/v2 v2.24.6 h1:GdScQWO9fJcMsR93SFWFvD3q3b4W4Uhf81VBYAiK8qk=
+github.com/tdewolff/minify/v2 v2.24.6/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
+github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
+github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts=
@@ -132,14 +184,15 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
-golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
-golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
-golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
-golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/avatar-upload/main.go b/avatar-upload/main.go
index 57c0f5d..f0a5132 100644
--- a/avatar-upload/main.go
+++ b/avatar-upload/main.go
@@ -1,7 +1,6 @@
package main
import (
- "context"
"embed"
"fmt"
"log"
@@ -23,23 +22,50 @@ type ProfileStore struct {
AvatarURL string
}
-// AllowUploads configures avatar upload
-func (s *ProfileStore) AllowUploads() map[string]livetemplate.UploadConfig {
- return map[string]livetemplate.UploadConfig{
- "avatar": {
- 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
- },
+// Change implements the Store interface
+func (s *ProfileStore) Change(ctx *livetemplate.ActionContext) error {
+ log.Printf("DEBUG: Change called with action: %s", ctx.Action)
+ switch ctx.Action {
+ case "UpdateProfile":
+ return s.UpdateProfile(ctx)
+ case "upload:avatar:complete":
+ // Auto-triggered when avatar upload completes
+ log.Printf("DEBUG: Processing auto-triggered upload")
+ return s.ProcessAvatarUpload(ctx)
+ default:
+ return fmt.Errorf("unknown action: %s", ctx.Action)
+ }
+}
+
+// UpdateProfile handles profile update form submission
+func (s *ProfileStore) UpdateProfile(ctx *livetemplate.ActionContext) error {
+ name, _ := ctx.Data.GetStringOk("name")
+ email, _ := ctx.Data.GetStringOk("email")
+
+ s.Name = name
+ s.Email = email
+
+ // Also process avatar if it was uploaded with the form
+ if ctx.HasUploads("avatar") {
+ if err := s.ProcessAvatarUpload(ctx); err != nil {
+ return err
+ }
}
+
+ log.Printf("Profile updated: name=%s, email=%s", s.Name, s.Email)
+ return nil
}
-// ConsumeUpload processes uploaded avatar
-func (s *ProfileStore) ConsumeUpload(ctx context.Context, name string, entries []*livetemplate.UploadEntry) error {
- if name != "avatar" {
- return nil
+// ProcessAvatarUpload handles avatar upload processing
+// Called either automatically when upload completes (upload:avatar:complete action)
+// or during explicit form submission (UpdateProfile action)
+func (s *ProfileStore) ProcessAvatarUpload(ctx *livetemplate.ActionContext) error {
+ // Get completed uploads from ActionContext
+ uploads := ctx.GetCompletedUploads("avatar")
+ log.Printf("DEBUG: ProcessAvatarUpload called, found %d completed uploads", len(uploads))
+ if len(uploads) == 0 {
+ log.Printf("DEBUG: No completed uploads found")
+ return nil // No uploads to process
}
// Create uploads directory if it doesn't exist
@@ -48,13 +74,22 @@ func (s *ProfileStore) ConsumeUpload(ctx context.Context, name string, entries [
return fmt.Errorf("failed to create uploads directory: %w", err)
}
- for _, entry := range entries {
+ for _, entry := range uploads {
+ log.Printf("DEBUG: Processing entry %s, TempPath: %s, exists: %v", entry.ID, entry.TempPath, fileExists(entry.TempPath))
+
+ // Check if temp file exists (may have been processed already by auto-trigger)
+ if !fileExists(entry.TempPath) {
+ log.Printf("DEBUG: Temp file already processed for entry %s, skipping", entry.ID)
+ continue
+ }
+
// Generate permanent filename
ext := filepath.Ext(entry.ClientName)
permanentPath := filepath.Join(uploadsDir, fmt.Sprintf("avatar-%s%s", entry.ID, ext))
// Move from temp to permanent location
if err := os.Rename(entry.TempPath, permanentPath); err != nil {
+ log.Printf("DEBUG: Rename failed: %v, trying copy", err)
// If rename fails (different filesystem), try copy
if err := copyFile(entry.TempPath, permanentPath); err != nil {
return fmt.Errorf("failed to save avatar: %w", err)
@@ -81,26 +116,10 @@ func copyFile(src, dst string) error {
return os.WriteFile(dst, data, 0644)
}
-// Change implements the Store interface
-func (s *ProfileStore) Change(ctx *livetemplate.ActionContext) error {
- switch ctx.Action {
- case "UpdateProfile":
- return s.UpdateProfile(ctx.Ctx, ctx.Data)
- default:
- return fmt.Errorf("unknown action: %s", ctx.Action)
- }
-}
-
-// UpdateProfile handles profile update form submission
-func (s *ProfileStore) UpdateProfile(ctx context.Context, data *livetemplate.ActionData) error {
- name, _ := data.GetStringOk("name")
- email, _ := data.GetStringOk("email")
-
- s.Name = name
- s.Email = email
-
- log.Printf("Profile updated: name=%s, email=%s", s.Name, s.Email)
- return nil
+// fileExists checks if a file exists
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
}
func main() {
@@ -110,10 +129,18 @@ func main() {
port = "8080"
}
- // Create LiveTemplate instance
+ // Create LiveTemplate instance with upload configuration
lt := livetemplate.Must(livetemplate.New("avatar-upload",
livetemplate.WithParseFiles("avatar-upload.tmpl"),
livetemplate.WithDevMode(true),
+ // Configure upload using WithUpload option
+ livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
+ 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
+ }),
))
// Create initial store
@@ -122,7 +149,7 @@ func main() {
Email: "john@example.com",
}
- // Create handler with upload support
+ // Create handler - upload configuration is now via WithUpload option
handler := lt.Handle(store)
// Serve static files (for uploaded avatars)
@@ -139,6 +166,8 @@ func main() {
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")
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal(err)
diff --git a/avatar-upload/upload_ws_e2e_test.go b/avatar-upload/upload_ws_e2e_test.go
new file mode 100644
index 0000000..fba8c05
--- /dev/null
+++ b/avatar-upload/upload_ws_e2e_test.go
@@ -0,0 +1,292 @@
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/livetemplate/livetemplate"
+)
+
+// TestUploadViaWebSocket tests the complete upload flow via WebSocket
+// This test reproduces the actual browser upload behavior
+func TestUploadViaWebSocket(t *testing.T) {
+ // Create test server
+ store := &ProfileStore{
+ Name: "John Doe",
+ Email: "john@example.com",
+ }
+
+ handler := createTestHandler(t, store)
+ server := httptest.NewServer(handler)
+ defer server.Close()
+
+ // Connect WebSocket
+ wsURL := "ws" + server.URL[4:] + "/ws"
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("Failed to connect WebSocket: %v", err)
+ }
+ defer conn.Close()
+
+ // Read messages in background
+ messages := make(chan []byte, 10)
+ errors := make(chan error, 1)
+ go func() {
+ for {
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ errors <- err
+ return
+ }
+ t.Logf("📩 Received message: %s", string(msg))
+ messages <- msg
+ }
+ }()
+
+ // Wait for initial tree
+ select {
+ case msg := <-messages:
+ var tree map[string]interface{}
+ if err := json.Unmarshal(msg, &tree); err != nil {
+ t.Fatalf("Failed to parse initial tree: %v", err)
+ }
+ t.Log("✅ Received initial tree")
+ case <-time.After(2 * time.Second):
+ t.Fatal("Timeout waiting for initial tree")
+ }
+
+ // Create a small test image (1x1 red PNG)
+ pngData := []byte{
+ 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,
+ }
+
+ // Step 1: Send upload_start
+ uploadStartMsg := map[string]interface{}{
+ "action": "upload_start",
+ "upload_name": "avatar",
+ "files": []map[string]interface{}{
+ {
+ "name": "test-avatar.png",
+ "type": "image/png",
+ "size": len(pngData),
+ },
+ },
+ }
+
+ if err := conn.WriteJSON(uploadStartMsg); err != nil {
+ t.Fatalf("Failed to send upload_start: %v", err)
+ }
+ t.Log("📤 Sent upload_start")
+
+ // Wait for upload_start response
+ var entryID string
+ select {
+ case msg := <-messages:
+ var response map[string]interface{}
+ if err := json.Unmarshal(msg, &response); err != nil {
+ t.Fatalf("Failed to parse upload_start response: %v", err)
+ }
+
+ entries, ok := response["entries"].([]interface{})
+ if !ok || len(entries) == 0 {
+ t.Fatalf("No entries in upload_start response: %+v", response)
+ }
+
+ entry := entries[0].(map[string]interface{})
+ entryID = entry["entry_id"].(string)
+ t.Logf("✅ Received upload_start response, entry_id: %s", entryID)
+ case <-time.After(2 * time.Second):
+ t.Fatal("Timeout waiting for upload_start response")
+ }
+
+ // Step 2: Send upload chunks
+ chunkSize := 256 * 1024
+ offset := 0
+ for offset < len(pngData) {
+ end := offset + chunkSize
+ if end > len(pngData) {
+ end = len(pngData)
+ }
+
+ chunk := pngData[offset:end]
+ chunkBase64 := base64.StdEncoding.EncodeToString(chunk)
+
+ chunkMsg := map[string]interface{}{
+ "action": "upload_chunk",
+ "entry_id": entryID,
+ "chunk_base64": chunkBase64,
+ "offset": offset,
+ "total": len(pngData),
+ }
+
+ if err := conn.WriteJSON(chunkMsg); err != nil {
+ t.Fatalf("Failed to send upload_chunk: %v", err)
+ }
+ t.Logf("📤 Sent chunk %d-%d", offset, end)
+
+ offset = end
+ }
+
+ // Small delay to ensure chunks are processed
+ time.Sleep(100 * time.Millisecond)
+
+ // Step 3: Send upload_complete
+ uploadCompleteMsg := map[string]interface{}{
+ "action": "upload_complete",
+ "upload_name": "avatar",
+ "entry_ids": []string{entryID},
+ }
+
+ if err := conn.WriteJSON(uploadCompleteMsg); err != nil {
+ t.Fatalf("Failed to send upload_complete: %v", err)
+ }
+ t.Log("📤 Sent upload_complete")
+
+ // Wait for tree update showing upload completion
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ receivedUpdate := false
+ for !receivedUpdate {
+ select {
+ case msg := <-messages:
+ var update map[string]interface{}
+ if err := json.Unmarshal(msg, &update); err != nil {
+ t.Logf("Skipping non-JSON message: %v", err)
+ continue
+ }
+
+ // Check if this is a tree update (has "tree" field)
+ if tree, ok := update["tree"]; ok {
+ t.Logf("✅ Received tree update after upload_complete")
+ receivedUpdate = true
+
+ // Verify the update is valid (not null/undefined)
+ if tree == nil {
+ t.Error("❌ Tree update has null tree - this causes client error!")
+ }
+
+ // Verify the tree is not empty
+ treeMap, ok := tree.(map[string]interface{})
+ if !ok || len(treeMap) == 0 {
+ t.Error("❌ Tree update is empty - no data changed!")
+ } else {
+ t.Logf("✅ Tree has %d root keys", len(treeMap))
+
+ // Check if upload entries are in the tree and have Done=true
+ // Position 3 should be the upload-preview div content
+ if uploadPreview, ok := treeMap["3"]; ok {
+ t.Logf("📦 Upload preview section found in tree: %T", uploadPreview)
+ // uploadPreview is an array containing the range operation
+ if outerArray, ok := uploadPreview.([]interface{}); ok && len(outerArray) > 0 {
+ // The first element is the actual range operation like ["a", [data], [statics], {idKey}]
+ if rangeOp, ok := outerArray[0].([]interface{}); ok {
+ t.Logf("Range operation has %d elements", len(rangeOp))
+ if len(rangeOp) > 1 {
+ t.Logf("Range operation [0] (op type): %v", rangeOp[0])
+ t.Logf("Range operation [1] (items data) type: %T", rangeOp[1])
+ if itemsData, ok := rangeOp[1].([]interface{}); ok {
+ t.Logf("Items data has %d items", len(itemsData))
+ if len(itemsData) > 0 {
+ t.Logf("First item type: %T", itemsData[0])
+ // First item should be the upload entry data
+ if entryData, ok := itemsData[0].(map[string]interface{}); ok {
+ t.Logf("📊 Upload entry data keys: %v", getKeys(entryData))
+ t.Logf("📊 Upload entry data in tree: %+v", entryData)
+
+ // Check if position 3 (the status message div wrapper) exists
+ if msgDiv, exists := entryData["3"]; exists {
+ t.Logf("✅ Position 3 (status div wrapper) exists: %T", msgDiv)
+ // msgDiv should be a map with position "0" containing the actual message
+ if msgMap, ok := msgDiv.(map[string]interface{}); ok {
+ if actualMsg, exists := msgMap["0"]; exists {
+ t.Logf("📝 Status message content: %v", actualMsg)
+ // Check if it contains success message
+ if msgStr, ok := actualMsg.(string); ok {
+ if msgStr == "✅ Upload complete!" || msgStr == "Upload complete!" {
+ t.Logf("✅ SUCCESS MESSAGE FOUND: Upload complete!")
+ } else {
+ t.Errorf("❌ Expected success message but got: %s", msgStr)
+ }
+ } else if msgTree, ok := actualMsg.(map[string]interface{}); ok {
+ // Message might be a nested tree
+ t.Logf("Message is a tree: %+v", msgTree)
+ }
+ } else {
+ t.Error("❌ Position 0 (actual message) missing in status div!")
+ }
+ }
+ } else {
+ t.Error("❌ Position 3 (status message) missing - success message won't show!")
+ t.Logf("Entry only has these positions: %v", getKeys(entryData))
+ }
+ } else {
+ t.Logf("First item is not a map: %+v", itemsData[0])
+ }
+ }
+ } else {
+ t.Logf("rangeOp[1] is not []interface{}: %+v", rangeOp[1])
+ }
+ }
+ } else {
+ t.Logf("outerArray[0] is not a range operation array: %+v", outerArray[0])
+ }
+ } else {
+ t.Logf("Upload preview is not a []interface{} or is empty: %+v", uploadPreview)
+ }
+ } else {
+ t.Error("❌ Upload preview section (position 3) not in tree update!")
+ }
+ }
+ }
+ case err := <-errors:
+ t.Fatalf("WebSocket error: %v", err)
+ case <-ctx.Done():
+ t.Fatal("❌ Timeout waiting for tree update after upload_complete")
+ }
+ }
+
+ t.Log("✅ Upload test completed successfully")
+}
+
+func getKeys(m map[string]interface{}) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+func createTestHandler(t *testing.T, store *ProfileStore) http.Handler {
+ // Same setup as main.go
+ lt, err := livetemplate.New("avatar-upload",
+ livetemplate.WithParseFiles("avatar-upload.tmpl"),
+ livetemplate.WithDevMode(true),
+ livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
+ Accept: []string{"image/jpeg", "image/png", "image/gif"},
+ MaxFileSize: 5 * 1024 * 1024,
+ MaxEntries: 1,
+ AutoUpload: false,
+ ChunkSize: 256 * 1024,
+ }),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create LiveTemplate: %v", err)
+ }
+
+ return lt.Handle(store)
+}