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) +}