refactor: remove unused assets and components, update styles and structure

- Deleted base.css and logo.svg as they were no longer needed.
- Updated main.css with new color palette and improved styles for various components.
- Removed HelloWorld.vue and TheWelcome.vue components to streamline the project.
- Deleted WelcomeItem.vue and associated icon components to clean up unused code.
- Enhanced responsiveness and accessibility across styles and components.
This commit is contained in:
2026-01-25 18:03:15 +08:00
parent 74a94a0ae9
commit bc69ad4e33
16 changed files with 1567 additions and 659 deletions

29
go.mod
View File

@@ -2,33 +2,52 @@ module wolves.top/todo
go 1.25.5 go 1.25.5
require github.com/gin-gonic/gin v1.10.0 require (
github.com/gin-gonic/gin v1.10.0
github.com/jackc/pgconn v1.14.3
github.com/jackc/pgx/v5 v5.8.0
github.com/redis/go-redis/v9 v9.17.2
github.com/segmentio/kafka-go v0.4.50
golang.org/x/crypto v0.36.0
)
require ( require (
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.38.0 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.15.0 // indirect golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

74
go.sum
View File

@@ -1,14 +1,23 @@
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=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
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/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -28,12 +37,37 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 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/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -45,43 +79,63 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

631
main.go
View File

@@ -1,12 +1,26 @@
package main package main
import ( import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"log"
"net/http" "net/http"
"os"
"strconv" "strconv"
"sync" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"github.com/segmentio/kafka-go"
"golang.org/x/crypto/bcrypt"
) )
type Task struct { type Task struct {
@@ -21,120 +35,224 @@ type Task struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type taskStore struct {
mu sync.Mutex
nextID int64
items map[int64]Task
}
type User struct { type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"-"`
} }
type authStore struct { type tokenManager struct {
mu sync.Mutex secret []byte
nextID int64 ttl time.Duration
users map[string]User
sessions map[string]int64
} }
func newTaskStore() *taskStore { type postgresStore struct {
return &taskStore{ pool *pgxpool.Pool
nextID: 1, }
items: make(map[int64]Task),
type tokenCache struct {
client *redis.Client
prefix string
}
type taskEmitter struct {
writer *kafka.Writer
topic string
}
func newTokenManager(secret string, ttl time.Duration) *tokenManager {
return &tokenManager{
secret: []byte(secret),
ttl: ttl,
} }
} }
func newAuthStore() *authStore { func (t *tokenManager) Generate(user User) (string, error) {
return &authStore{ payload := struct {
nextID: 1, UserID int64 `json:"uid"`
users: make(map[string]User), Exp int64 `json:"exp"`
sessions: make(map[string]int64), }{
UserID: user.ID,
Exp: time.Now().Add(t.ttl).Unix(),
} }
} raw, err := json.Marshal(payload)
if err != nil {
func (s *authStore) register(email, password string) (User, bool) { return "", err
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[email]; exists {
return User{}, false
} }
user := User{ encoded := base64.RawURLEncoding.EncodeToString(raw)
ID: s.nextID, sig := t.sign(encoded)
Email: email, return encoded + "." + sig, nil
Password: password, }
func (t *tokenManager) Validate(token string) (int64, bool) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return 0, false
} }
s.nextID++ payload, sig := parts[0], parts[1]
s.users[email] = user if !t.verify(payload, sig) {
return user, true return 0, false
}
func (s *authStore) login(email, password string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[email]
if !ok || user.Password != password {
return "", false
} }
token := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(user.ID, 10) raw, err := base64.RawURLEncoding.DecodeString(payload)
s.sessions[token] = user.ID if err != nil {
return token, true return 0, false
}
func (s *authStore) validate(token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.sessions[token]
return ok
}
func (s *taskStore) list() []Task {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Task, 0, len(s.items))
for _, t := range s.items {
result = append(result, t)
} }
return result var data struct {
UserID int64 `json:"uid"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return 0, false
}
if data.UserID == 0 || time.Now().Unix() > data.Exp {
return 0, false
}
return data.UserID, true
} }
func (s *taskStore) get(id int64) (Task, bool) { func (t *tokenManager) sign(payload string) string {
s.mu.Lock() mac := hmac.New(sha256.New, t.secret)
defer s.mu.Unlock() mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
t, ok := s.items[id]
return t, ok
} }
func (s *taskStore) create(input Task) Task { func (t *tokenManager) verify(payload, signature string) bool {
s.mu.Lock() expected := t.sign(payload)
defer s.mu.Unlock() return hmac.Equal([]byte(signature), []byte(expected))
}
input.ID = s.nextID func newPostgresStore(ctx context.Context, url string) (*postgresStore, error) {
s.nextID++ pool, err := pgxpool.New(ctx, url)
now := time.Now().UTC() if err != nil {
input.CreatedAt = now return nil, err
input.UpdatedAt = now }
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
store := &postgresStore{pool: pool}
if err := store.initSchema(ctx); err != nil {
pool.Close()
return nil, err
}
return store, nil
}
func (s *postgresStore) initSchema(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL,
due_at TEXT,
priority INT NOT NULL DEFAULT 0,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`,
}
for _, stmt := range statements {
if _, err := s.pool.Exec(ctx, stmt); err != nil {
return err
}
}
return nil
}
func (s *postgresStore) Register(ctx context.Context, email, password string) (User, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return User{}, err
}
var id int64
err = s.pool.QueryRow(ctx, `INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`, email, string(hashed)).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return User{}, errAlreadyExists
}
return User{}, err
}
return User{ID: id, Email: email}, nil
}
func (s *postgresStore) Login(ctx context.Context, email, password string) (User, error) {
var user User
var hash string
err := s.pool.QueryRow(ctx, `SELECT id, email, password_hash FROM users WHERE email = $1`, email).Scan(&user.ID, &user.Email, &hash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, errInvalidCredentials
}
return User{}, err
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return User{}, errInvalidCredentials
}
return user, nil
}
func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error) {
rows, err := s.pool.Query(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY id DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Task{}
for rows.Next() {
var task Task
var tags []string
if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt); err != nil {
return nil, err
}
task.Tags = tags
result = append(result, task)
}
return result, rows.Err()
}
func (s *postgresStore) Get(ctx context.Context, userID, id int64) (Task, error) {
var task Task
var tags []string
err := s.pool.QueryRow(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 AND id = $2`, userID, id).
Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return Task{}, err
}
task.Tags = tags
return task, nil
}
func (s *postgresStore) Create(ctx context.Context, userID int64, input Task) (Task, error) {
if input.Status == "" { if input.Status == "" {
input.Status = "todo" input.Status = "todo"
} }
s.items[input.ID] = input var tags []string
return input if input.Tags != nil {
tags = input.Tags
}
err := s.pool.QueryRow(ctx, `
INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
RETURNING id, created_at, updated_at`,
userID, input.Title, input.Description, input.Status, input.DueAt, input.Priority, tags,
).Scan(&input.ID, &input.CreatedAt, &input.UpdatedAt)
if err != nil {
return Task{}, err
}
return input, nil
} }
func (s *taskStore) update(id int64, input Task) (Task, bool) { func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task) (Task, error) {
s.mu.Lock() existing, err := s.Get(ctx, userID, id)
defer s.mu.Unlock() if err != nil {
return Task{}, err
existing, ok := s.items[id]
if !ok {
return Task{}, false
} }
if input.Title != "" { if input.Title != "" {
existing.Title = input.Title existing.Title = input.Title
@@ -154,25 +272,139 @@ func (s *taskStore) update(id int64, input Task) (Task, bool) {
if input.Tags != nil { if input.Tags != nil {
existing.Tags = input.Tags existing.Tags = input.Tags
} }
existing.UpdatedAt = time.Now().UTC() var updatedAt time.Time
s.items[id] = existing err = s.pool.QueryRow(ctx, `
return existing, true UPDATE tasks
SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, updated_at = now()
WHERE id = $7 AND user_id = $8
RETURNING updated_at`,
existing.Title, existing.Description, existing.Status, existing.DueAt, existing.Priority, existing.Tags, id, userID,
).Scan(&updatedAt)
if err != nil {
return Task{}, err
}
existing.UpdatedAt = updatedAt
return existing, nil
} }
func (s *taskStore) delete(id int64) bool { func (s *postgresStore) Delete(ctx context.Context, userID, id int64) error {
s.mu.Lock() result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID)
defer s.mu.Unlock() if err != nil {
return err
if _, ok := s.items[id]; !ok {
return false
} }
delete(s.items, id) if result.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *postgresStore) Close() {
if s.pool != nil {
s.pool.Close()
}
}
func newTokenCache(addr, password string, db int) (*tokenCache, error) {
if strings.TrimSpace(addr) == "" {
return nil, nil
}
client := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &tokenCache{client: client, prefix: "auth:token:"}, nil
}
func (c *tokenCache) Save(ctx context.Context, token string, ttl time.Duration) error {
return c.client.Set(ctx, c.prefix+token, "1", ttl).Err()
}
func (c *tokenCache) Delete(ctx context.Context, token string) error {
return c.client.Del(ctx, c.prefix+token).Err()
}
func (c *tokenCache) Exists(ctx context.Context, token string) (bool, error) {
count, err := c.client.Exists(ctx, c.prefix+token).Result()
if err != nil {
return false, err
}
return count == 1, nil
}
func newTaskEmitter(brokers []string, topic string) *taskEmitter {
if len(brokers) == 0 {
return nil
}
writer := &kafka.Writer{
Addr: kafka.TCP(brokers...),
Topic: topic,
Balancer: &kafka.LeastBytes{},
}
return &taskEmitter{writer: writer, topic: topic}
}
func (e *taskEmitter) Emit(ctx context.Context, eventType string, task Task, userID int64) {
if e == nil || e.writer == nil {
return
}
payload := map[string]any{
"type": eventType,
"task_id": task.ID,
"user_id": userID,
"status": task.Status,
"priority": task.Priority,
"at": time.Now().UTC().Format(time.RFC3339),
}
data, err := json.Marshal(payload)
if err != nil {
return
}
writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := e.writer.WriteMessages(writeCtx, kafka.Message{
Key: []byte(strconv.FormatInt(task.ID, 10)),
Value: data,
}); err != nil {
log.Printf("kafka write failed: %v", err)
}
}
func (e *taskEmitter) Close() {
if e == nil || e.writer == nil {
return
}
if err := e.writer.Close(); err != nil {
log.Printf("kafka close failed: %v", err)
}
}
var (
errAlreadyExists = errors.New("already exists")
errInvalidCredentials = errors.New("invalid credentials")
)
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return true return true
}
return false
} }
func main() { func main() {
store := newTaskStore() store, tokens, cache, emitter := buildDependencies()
authStore := newAuthStore() defer store.Close()
if cache != nil {
defer cache.client.Close()
}
defer emitter.Close()
gin.SetMode(gin.DebugMode) gin.SetMode(gin.DebugMode)
router := gin.Default() router := gin.Default()
router.RedirectTrailingSlash = false router.RedirectTrailingSlash = false
@@ -193,16 +425,6 @@ func main() {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })
router.GET("/", func(c *gin.Context) {
c.File("test/web/index.html")
})
router.GET("/styles.css", func(c *gin.Context) {
c.File("test/web/styles.css")
})
router.GET("/app.js", func(c *gin.Context) {
c.File("test/web/app.js")
})
auth := router.Group("/api/v1/auth") auth := router.Group("/api/v1/auth")
{ {
auth.POST("/register", func(c *gin.Context) { auth.POST("/register", func(c *gin.Context) {
@@ -214,15 +436,20 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
input.Email = strings.TrimSpace(input.Email)
if input.Email == "" || input.Password == "" { if input.Email == "" || input.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
return return
} }
user, ok := authStore.register(input.Email, input.Password) user, err := store.Register(c.Request.Context(), input.Email, input.Password)
if !ok { if err != nil {
if errors.Is(err, errAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email}) c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email})
}) })
auth.POST("/login", func(c *gin.Context) { auth.POST("/login", func(c *gin.Context) {
@@ -234,41 +461,90 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
input.Email = strings.TrimSpace(input.Email)
if input.Email == "" || input.Password == "" { if input.Email == "" || input.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
return return
} }
token, ok := authStore.login(input.Email, input.Password) user, err := store.Login(c.Request.Context(), input.Email, input.Password)
if !ok { if err != nil {
if errors.Is(err, errInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
return
}
token, err := tokens.Generate(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token generation failed"})
return
}
if cache != nil {
if err := cache.Save(c.Request.Context(), token, tokens.ttl); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
return
}
}
c.JSON(http.StatusOK, gin.H{"token": token}) c.JSON(http.StatusOK, gin.H{"token": token})
}) })
auth.POST("/logout", func(c *gin.Context) {
token := extractBearerToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
if _, ok := tokens.Validate(token); !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
if cache != nil {
if err := cache.Delete(c.Request.Context(), token); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
return
}
}
c.Status(http.StatusNoContent)
})
} }
api := router.Group("/api/v1") api := router.Group("/api/v1")
api.Use(func(c *gin.Context) { api.Use(func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") token := extractBearerToken(c)
if authHeader == "" { if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return return
} }
token := authHeader userID, ok := tokens.Validate(token)
if len(authHeader) > 7 && authHeader[:7] == "Bearer " { if !ok {
token = authHeader[7:]
}
if token == "" || !authStore.validate(token) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return return
} }
if cache != nil {
exists, err := cache.Exists(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
return
}
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
}
c.Set("user_id", userID)
c.Next() c.Next()
}) })
tasks := api.Group("/tasks") tasks := api.Group("/tasks")
{ {
tasks.GET("", func(c *gin.Context) { tasks.GET("", func(c *gin.Context) {
c.JSON(http.StatusOK, store.list()) userID := c.GetInt64("user_id")
items, err := store.List(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tasks"})
return
}
c.JSON(http.StatusOK, items)
}) })
tasks.POST("", func(c *gin.Context) { tasks.POST("", func(c *gin.Context) {
var input Task var input Task
@@ -276,7 +552,13 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
created := store.create(input) userID := c.GetInt64("user_id")
created, err := store.Create(c.Request.Context(), userID, input)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
return
}
emitter.Emit(c.Request.Context(), "task.created", created, userID)
c.JSON(http.StatusCreated, created) c.JSON(http.StatusCreated, created)
}) })
tasks.GET(":id", func(c *gin.Context) { tasks.GET(":id", func(c *gin.Context) {
@@ -285,11 +567,16 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return return
} }
task, ok := store.get(id) userID := c.GetInt64("user_id")
if !ok { task, err := store.Get(c.Request.Context(), userID, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task"})
return
}
c.JSON(http.StatusOK, task) c.JSON(http.StatusOK, task)
}) })
tasks.PUT(":id", func(c *gin.Context) { tasks.PUT(":id", func(c *gin.Context) {
@@ -303,11 +590,17 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
updated, ok := store.update(id, input) userID := c.GetInt64("user_id")
if !ok { updated, err := store.Update(c.Request.Context(), userID, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
return
}
emitter.Emit(c.Request.Context(), "task.updated", updated, userID)
c.JSON(http.StatusOK, updated) c.JSON(http.StatusOK, updated)
}) })
tasks.DELETE(":id", func(c *gin.Context) { tasks.DELETE(":id", func(c *gin.Context) {
@@ -316,10 +609,16 @@ func main() {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return return
} }
if !store.delete(id) { userID := c.GetInt64("user_id")
if err := store.Delete(c.Request.Context(), userID, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"})
return
}
emitter.Emit(c.Request.Context(), "task.deleted", Task{ID: id}, userID)
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
}) })
} }
@@ -329,6 +628,78 @@ func main() {
} }
} }
func buildDependencies() (*postgresStore, *tokenManager, *tokenCache, *taskEmitter) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dbURL == "" {
dbURL = "postgres://todo:todo@localhost:5432/todo?sslmode=disable"
}
store, err := newPostgresStore(ctx, dbURL)
if err != nil {
log.Fatalf("postgres connection failed: %v", err)
}
secret := strings.TrimSpace(os.Getenv("AUTH_SECRET"))
if secret == "" {
secret = "dev-secret-change-me"
}
tokens := newTokenManager(secret, 24*time.Hour)
redisAddr := strings.TrimSpace(os.Getenv("REDIS_ADDR"))
redisPassword := os.Getenv("REDIS_PASSWORD")
redisDB := parseEnvInt("REDIS_DB", 0)
cache, err := newTokenCache(redisAddr, redisPassword, redisDB)
if err != nil {
log.Fatalf("redis connection failed: %v", err)
}
brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
topic := strings.TrimSpace(os.Getenv("KAFKA_TOPIC"))
if topic == "" {
topic = "todo.tasks"
}
emitter := newTaskEmitter(brokers, topic)
return store, tokens, cache, emitter
}
func parseID(value string) (int64, error) { func parseID(value string) (int64, error) {
return strconv.ParseInt(value, 10, 64) return strconv.ParseInt(value, 10, 64)
} }
func extractBearerToken(c *gin.Context) string {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimSpace(authHeader[7:])
}
return authHeader
}
func parseEnvInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func splitCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
result = append(result, item)
}
}
return result
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo Control Room</title> <title>Todo Room</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { API_BASE } from './config' import { API_BASE } from './config'
type Task = { type Task = {
@@ -12,9 +12,181 @@ type Task = {
status?: string status?: string
} }
type LocaleKey = 'en' | 'zh'
type MessageKey =
| 'eyebrow'
| 'headline'
| 'subhead'
| 'new_task'
| 'account'
| 'login'
| 'register'
| 'logout'
| 'total'
| 'open'
| 'complete'
| 'tasks'
| 'refresh'
| 'clear_completed'
| 'empty'
| 'title'
| 'due'
| 'priority'
| 'tags'
| 'description'
| 'create_task'
| 'cancel'
| 'email'
| 'password'
| 'account_hint'
| 'credentials_required'
| 'toast_login_success'
| 'toast_register_success'
| 'toast_logout_success'
| 'mark_complete'
| 'undo'
| 'priority_high'
| 'priority_medium'
| 'priority_low'
| 'delete'
| 'edit'
| 'edit_task'
| 'save_task'
| 'state_idle'
| 'state_connected'
| 'state_auth_required'
| 'state_login_failed'
| 'state_register_failed'
| 'state_registered'
| 'state_token_missing'
| 'state_logged_out'
| 'state_create_failed'
| 'state_update_failed'
| 'state_restored'
| 'language'
| 'lang_en'
| 'lang_zh'
const messages: Record<LocaleKey, Record<MessageKey, string>> = {
en: {
eyebrow: 'Productivity OS',
headline: 'SuperTodo Mission Control',
subhead: 'Plan, prioritize, and close tasks with a crisp, no-noise workflow.',
new_task: 'New Task',
account: 'Account',
login: 'Login',
register: 'Register',
logout: 'Logout',
total: 'Total',
open: 'Open',
complete: 'Complete',
tasks: 'Tasks',
refresh: 'Refresh',
clear_completed: 'Clear Completed',
empty: 'No tasks yet.',
title: 'Title',
due: 'Due',
priority: 'Priority',
tags: 'Tags (comma)',
description: 'Description',
create_task: 'Create Task',
cancel: 'Cancel',
email: 'Email',
password: 'Password',
account_hint: 'Create an account or login to sync tasks.',
credentials_required: 'Email and password required',
toast_login_success: 'Login successful',
toast_register_success: 'Register successful',
toast_logout_success: 'Logged out',
mark_complete: 'Complete',
undo: 'Undo',
priority_high: 'High',
priority_medium: 'Medium',
priority_low: 'Low',
delete: 'Delete',
edit: 'Edit',
edit_task: 'Edit Task',
save_task: 'Save Changes',
state_idle: 'Not connected',
state_connected: 'Connected',
state_auth_required: 'Auth required',
state_login_failed: 'Login failed',
state_register_failed: 'Register failed',
state_registered: 'Registered, now login',
state_token_missing: 'Token missing',
state_logged_out: 'Logged out',
state_create_failed: 'Failed to create task',
state_update_failed: 'Failed to update task',
state_restored: 'Session restored',
language: 'Language',
lang_en: 'English',
lang_zh: 'Chinese',
},
zh: {
eyebrow: '效率中枢',
headline: 'SuperTodo 控制台',
subhead: '清晰规划、明确优先级、快速收尾,让任务流保持锋利。',
new_task: '新建任务',
account: '账户',
login: '登录',
register: '注册',
logout: '退出',
total: '总计',
open: '待办',
complete: '完成率',
tasks: '任务列表',
refresh: '刷新',
clear_completed: '清理已完成',
empty: '还没有任务。',
title: '标题',
due: '截止',
priority: '优先级',
tags: '标签 (逗号分隔)',
description: '描述',
create_task: '创建任务',
cancel: '取消',
email: '邮箱',
password: '密码',
account_hint: '登录或注册后同步任务。',
credentials_required: '请输入邮箱和密码',
toast_login_success: '登录成功',
toast_register_success: '注册成功',
toast_logout_success: '已退出登录',
mark_complete: '完成',
undo: '撤销',
priority_high: '高',
priority_medium: '中',
priority_low: '低',
delete: '删除',
edit: '编辑',
edit_task: '编辑任务',
save_task: '保存修改',
state_idle: '未连接',
state_connected: '已连接',
state_auth_required: '需要登录',
state_login_failed: '登录失败',
state_register_failed: '注册失败',
state_registered: '注册成功,请登录',
state_token_missing: '缺少令牌',
state_logged_out: '已退出',
state_create_failed: '创建任务失败',
state_update_failed: '更新任务失败',
state_restored: '会话已恢复',
language: '语言',
lang_en: '英文',
lang_zh: '中文',
},
}
const locale = ref<LocaleKey>('zh')
const token = ref('') const token = ref('')
const sessionState = ref('Not connected') const sessionState = ref<MessageKey>('state_idle')
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const authForm = reactive({
email: '',
password: '',
})
const form = reactive({ const form = reactive({
title: '', title: '',
due_at: '', due_at: '',
@@ -22,47 +194,135 @@ const form = reactive({
tags: '', tags: '',
description: '', description: '',
}) })
const isAccountOpen = ref(false)
const isTaskOpen = ref(false)
const autoSyncId = ref<number | null>(null)
const editingTaskId = ref<number | string | null>(null)
const toasts = ref<Array<{ id: number; message: string; tone: 'success' | 'error' | 'info' }>>([])
function setSessionState(text: string) { const isEditing = computed(() => editingTaskId.value !== null)
sessionState.value = text
const totalCount = computed(() => tasks.value.length)
const doneCount = computed(() => tasks.value.filter((task) => task.status === 'done').length)
const todoCount = computed(() => Math.max(totalCount.value - doneCount.value, 0))
const completionRate = computed(() =>
totalCount.value === 0 ? 0 : Math.round((doneCount.value / totalCount.value) * 100),
)
const sortedTasks = computed(() => {
return [...tasks.value].sort((a, b) => {
const pa = a.priority ?? 0
const pb = b.priority ?? 0
if (pa !== pb) return pa - pb
return String(a.id ?? '').localeCompare(String(b.id ?? ''))
})
})
const t = (key: MessageKey) => messages[locale.value][key] ?? key
function setSessionState(key: MessageKey) {
sessionState.value = key
}
function pushToast(message: string, tone: 'success' | 'error' | 'info' = 'info') {
const id = Date.now() + Math.floor(Math.random() * 1000)
toasts.value.push({ id, message, tone })
window.setTimeout(() => {
toasts.value = toasts.value.filter((toast) => toast.id !== id)
}, 3200)
} }
function getHeaders() { function getHeaders() {
return { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: token.value ? `Bearer ${token.value}` : '',
} }
if (token.value) {
headers.Authorization = `Bearer ${token.value}`
}
return headers
}
function saveToken(value: string) {
token.value = value
if (value) {
localStorage.setItem('todo_token', value)
} else {
localStorage.removeItem('todo_token')
}
}
async function register() {
if (!authForm.email || !authForm.password) {
pushToast(t('credentials_required'), 'error')
return
}
const response = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({
email: authForm.email,
password: authForm.password,
}),
})
if (!response.ok) {
pushToast(t('state_register_failed'), 'error')
return
}
pushToast(t('toast_register_success'), 'success')
} }
async function login() { async function login() {
if (!authForm.email || !authForm.password) {
pushToast(t('credentials_required'), 'error')
return
}
const response = await fetch(`${API_BASE}/auth/login`, { const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST', method: 'POST',
headers: getHeaders(),
body: JSON.stringify({
email: authForm.email,
password: authForm.password,
}),
}) })
if (!response.ok) { if (!response.ok) {
setSessionState('Login failed') pushToast(t('state_login_failed'), 'error')
return return
} }
const data = await response.json() const data = await response.json()
token.value = data.token || 'demo-token' saveToken(data.token || '')
setSessionState('Connected') if (!token.value) {
pushToast(t('state_token_missing'), 'error')
return
}
setSessionState('state_connected')
isAccountOpen.value = false
pushToast(t('toast_login_success'), 'success')
await loadTasks() await loadTasks()
} }
function buildMeta(task: Task) { async function logout() {
const parts: string[] = [] if (token.value) {
if (task.status) parts.push(task.status.toUpperCase()) await fetch(`${API_BASE}/auth/logout`, {
if (task.due_at) parts.push(`Due: ${task.due_at}`) method: 'POST',
if (task.priority) parts.push(`P${task.priority}`) headers: getHeaders(),
if (task.tags && task.tags.length > 0) parts.push(task.tags.join(', ')) })
return parts.join(' | ') }
saveToken('')
tasks.value = []
setSessionState('state_logged_out')
pushToast(t('toast_logout_success'), 'success')
} }
async function loadTasks() { async function loadTasks() {
if (!token.value) {
setSessionState('state_idle')
tasks.value = []
return
}
const response = await fetch(`${API_BASE}/tasks`, { const response = await fetch(`${API_BASE}/tasks`, {
headers: getHeaders(), headers: getHeaders(),
}) })
if (!response.ok) { if (!response.ok) {
setSessionState('Auth required') setSessionState('state_idle')
tasks.value = [] tasks.value = []
return return
} }
@@ -70,6 +330,36 @@ async function loadTasks() {
tasks.value = Array.isArray(data) ? data : [] tasks.value = Array.isArray(data) ? data : []
} }
function resetTaskForm() {
form.title = ''
form.due_at = ''
form.priority = '1'
form.tags = ''
form.description = ''
}
function openNewTask() {
editingTaskId.value = null
resetTaskForm()
isTaskOpen.value = true
}
function openEditTask(task: Task) {
editingTaskId.value = task.id ?? null
form.title = task.title || ''
form.description = task.description || ''
form.due_at = toLocalInput(task.due_at || '')
form.priority = task.priority ? String(task.priority) : '1'
form.tags = Array.isArray(task.tags) ? task.tags.join(', ') : ''
isTaskOpen.value = true
}
function closeTaskModal() {
isTaskOpen.value = false
editingTaskId.value = null
resetTaskForm()
}
async function createTask() { async function createTask() {
const payload = { const payload = {
title: form.title, title: form.title,
@@ -82,22 +372,23 @@ async function createTask() {
.filter(Boolean), .filter(Boolean),
} }
const response = await fetch(`${API_BASE}/tasks`, { const isUpdate = isEditing.value && editingTaskId.value !== null
method: 'POST', const endpoint = isUpdate ? `${API_BASE}/tasks/${editingTaskId.value}` : `${API_BASE}/tasks`
const method = isUpdate ? 'PUT' : 'POST'
const response = await fetch(endpoint, {
method,
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
if (!response.ok) { if (!response.ok) {
setSessionState('Failed to create task') pushToast(t(isUpdate ? 'state_update_failed' : 'state_create_failed'), 'error')
return return
} }
form.title = '' resetTaskForm()
form.due_at = '' editingTaskId.value = null
form.priority = '1' isTaskOpen.value = false
form.tags = ''
form.description = ''
await loadTasks() await loadTasks()
} }
@@ -141,89 +432,240 @@ function toISO(value: string) {
if (Number.isNaN(date.getTime())) return '' if (Number.isNaN(date.getTime())) return ''
return date.toISOString() return date.toISOString()
} }
function toLocalInput(value: string) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const offset = date.getTimezoneOffset() * 60000
return new Date(date.getTime() - offset).toISOString().slice(0, 16)
}
function formatDue(value: string) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}/${month}/${day} ${hour}:${minute}`
}
function toggleLocale() {
locale.value = locale.value === 'zh' ? 'en' : 'zh'
localStorage.setItem('todo_locale', locale.value)
}
onMounted(() => {
const saved = localStorage.getItem('todo_token')
const savedLocale = localStorage.getItem('todo_locale') as LocaleKey | null
if (savedLocale === 'en' || savedLocale === 'zh') {
locale.value = savedLocale
}
if (saved) {
token.value = saved
setSessionState('state_restored')
loadTasks()
}
autoSyncId.value = window.setInterval(() => {
if (token.value) {
loadTasks()
}
}, 2000)
})
onBeforeUnmount(() => {
if (autoSyncId.value !== null) {
window.clearInterval(autoSyncId.value)
autoSyncId.value = null
}
})
</script> </script>
<template> <template>
<div class="bg-orbit"></div> <div class="bg-orbit"></div>
<main class="shell"> <main class="shell">
<header class="hero"> <header class="hero">
<div> <div class="hero-copy">
<p class="eyebrow">Distributed todo playground</p> <p class="eyebrow">{{ t('eyebrow') }}</p>
<h1>Todo Control Room</h1> <h1>{{ t('headline') }}</h1>
<p class="subhead">A bold, focused UI to drive your task workflow from a single cockpit.</p> <p class="subhead">{{ t('subhead') }}</p>
</div>
<div class="hero-panel">
<div class="status-board">
<div class="session-card">
<div>
<p class="session-state">{{ t(sessionState) }}</p>
</div>
<div class="session-controls">
<button
v-if="token"
class="btn ghost danger compact"
type="button"
@click="logout"
>
{{ t('logout') }}
</button>
<button v-else class="btn ghost compact" type="button" @click="isAccountOpen = true">
{{ t('login') }}
</button>
<button class="btn ghost compact" type="button" @click.stop="toggleLocale">
{{ locale === 'zh' ? t('lang_en') : t('lang_zh') }}
</button>
</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<p>{{ t('total') }}</p>
<h3>{{ totalCount }}</h3>
</div>
<div class="stat-card">
<p>{{ t('open') }}</p>
<h3>{{ todoCount }}</h3>
</div>
<div class="stat-card">
<p>{{ t('complete') }}</p>
<h3>{{ completionRate }}%</h3>
</div>
</div>
</div> </div>
<div class="status-card">
<h2>Session</h2>
<p>{{ sessionState }}</p>
<button class="btn primary" type="button" @click="login">Login (demo)</button>
</div> </div>
</header> </header>
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>{{ t('tasks') }}</h2>
<div class="actions">
<button class="btn ghost" type="button" @click="openNewTask">{{ t('new_task') }}</button>
<button class="btn ghost" type="button" @click="loadTasks">{{ t('refresh') }}</button>
<button class="btn ghost" type="button" @click="clearCompleted">{{ t('clear_completed') }}</button>
</div>
</div>
<div class="task-list">
<div v-if="tasks.length === 0" class="empty">{{ t('empty') }}</div>
<div v-else class="task-header">
<span class="task-spacer" aria-hidden="true"></span>
<span class="task-title">{{ t('title') }}</span>
<span class="task-due">{{ t('due') }}</span>
<span class="task-desc">{{ t('description') }}</span>
<span class="task-actions-placeholder" aria-hidden="true"></span>
</div>
<article
v-for="task in sortedTasks"
:key="task.id ?? task.title"
class="task-card"
:class="{ completed: task.status === 'done' }"
>
<div class="task-main">
<div class="task-checker">
<input
class="task-check"
type="checkbox"
:checked="task.status === 'done'"
@change="toggleStatus(task)"
:aria-label="task.status === 'done' ? t('undo') : t('mark_complete')"
/>
</div>
<div class="task-content">
<div class="task-info">
<span class="task-title">{{ task.title || t('title') }}</span>
<span class="task-due">{{ formatDue(task.due_at || '') || '-' }}</span>
<span class="task-desc">{{ task.description || t('description') }}</span>
</div>
</div>
<div class="task-actions">
<button class="btn ghost toggle" type="button" @click="toggleStatus(task)">
{{ task.status === 'done' ? t('undo') : t('mark_complete') }}
</button>
<button class="btn ghost" type="button" @click="openEditTask(task)">{{ t('edit') }}</button>
<button class="btn ghost danger" type="button" @click="deleteTask(task)">{{ t('delete') }}</button>
</div>
</div>
</article>
</div>
</section>
</main>
<div v-if="isAccountOpen" class="modal-backdrop" @click.self="isAccountOpen = false">
<div class="modal">
<div class="modal-header">
<div> <div>
<h2>New Task</h2> <h3>{{ t('account') }}</h3>
<p>Create tasks quickly with priority and due date.</p> <p class="modal-sub">{{ t('account_hint') }}</p>
</div> </div>
<button class="btn ghost" type="button" @click="isAccountOpen = false">{{ t('cancel') }}</button>
</div> </div>
<form class="form-grid" @submit.prevent="createTask"> <div class="modal-body">
<label> <label>
<span>Title</span> <span>{{ t('email') }}</span>
<input v-model="authForm.email" type="email" placeholder="you@company.com" />
</label>
<label>
<span>{{ t('password') }}</span>
<input v-model="authForm.password" type="password" placeholder="••••••" />
</label>
</div>
<div class="modal-actions">
<button class="btn primary" type="button" @click="login">{{ t('login') }}</button>
<button class="btn ghost" type="button" @click="register">{{ t('register') }}</button>
<button class="btn ghost danger" type="button" @click="logout">{{ t('logout') }}</button>
</div>
</div>
</div>
<div v-if="isTaskOpen" class="modal-backdrop" @click.self="closeTaskModal">
<div class="modal">
<div class="modal-header">
<div>
<h3>{{ isEditing ? t('edit_task') : t('new_task') }}</h3>
<p class="modal-sub">{{ isEditing ? t('save_task') : t('create_task') }}</p>
</div>
<button class="btn ghost" type="button" @click="closeTaskModal">{{ t('cancel') }}</button>
</div>
<form class="modal-body" @submit.prevent="createTask">
<label>
<span>{{ t('title') }}</span>
<input v-model="form.title" type="text" name="title" placeholder="Plan service split" required /> <input v-model="form.title" type="text" name="title" placeholder="Plan service split" required />
</label> </label>
<label> <label>
<span>Due</span> <span>{{ t('due') }}</span>
<input v-model="form.due_at" type="datetime-local" name="due_at" /> <input v-model="form.due_at" type="datetime-local" name="due_at" />
</label> </label>
<label> <label>
<span>Priority</span> <span>{{ t('priority') }}</span>
<select v-model="form.priority" name="priority"> <select v-model="form.priority" name="priority">
<option value="1">High</option> <option value="1">{{ t('priority_high') }}</option>
<option value="2">Medium</option> <option value="2">{{ t('priority_medium') }}</option>
<option value="3">Low</option> <option value="3">{{ t('priority_low') }}</option>
</select> </select>
</label> </label>
<label> <label>
<span>Tags (comma)</span> <span>{{ t('tags') }}</span>
<input v-model="form.tags" type="text" name="tags" placeholder="backend, microservices" /> <input v-model="form.tags" type="text" name="tags" placeholder="backend, microservices" />
</label> </label>
<label class="full"> <label class="full">
<span>Description</span> <span>{{ t('description') }}</span>
<textarea <textarea
v-model="form.description" v-model="form.description"
name="description" name="description"
placeholder="Outline scope, list risks..." placeholder="Outline scope, list risks..."
></textarea> ></textarea>
</label> </label>
<button class="btn primary" type="submit">Create Task</button> <div class="modal-actions">
<button class="btn primary" type="submit">
{{ isEditing ? t('save_task') : t('create_task') }}
</button>
<button class="btn ghost" type="button" @click="closeTaskModal">{{ t('cancel') }}</button>
</div>
</form> </form>
</section> </div>
</div>
<section class="panel"> <div class="toast-stack" aria-live="polite" aria-atomic="true">
<div class="panel-head"> <div v-for="toast in toasts" :key="toast.id" class="toast" :class="toast.tone">
<h2>Tasks</h2> {{ toast.message }}
<div class="actions">
<button class="btn ghost" type="button" @click="loadTasks">Refresh</button>
<button class="btn ghost" type="button" @click="clearCompleted">Clear Completed</button>
</div> </div>
</div> </div>
<div class="task-list">
<div v-if="tasks.length === 0" class="empty">No tasks yet.</div>
<article v-for="task in tasks" :key="task.id ?? task.title" class="task-card">
<div class="task-main">
<div>
<h3>{{ task.title || 'Untitled task' }}</h3>
<p class="meta">{{ buildMeta(task) }}</p>
<p class="desc">{{ task.description || '' }}</p>
</div>
<div class="badge">{{ task.status || 'todo' }}</div>
</div>
<div class="task-actions">
<button class="btn ghost toggle" type="button" @click="toggleStatus(task)">Toggle Status</button>
<button class="btn ghost delete" type="button" @click="deleteTask(task)">Delete</button>
</div>
</article>
</div>
</section>
</main>
</template> </template>

View File

@@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -1,16 +1,19 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Syne:wght@600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f6f3ef; --bg: #f0fdfa;
--bg-alt: #efe7de; --bg-alt: #ccfbf1;
--ink: #1b1a17; --ink: #134e4a;
--muted: #6c5f57; --muted: #0f766e;
--accent: #d97706; --primary: #0d9488;
--accent-dark: #a35503; --primary-strong: #0f766e;
--card: #fff7ed; --primary-soft: #ccfbf1;
--stroke: rgba(27, 26, 23, 0.1); --accent: #f97316;
--shadow: 0 24px 60px rgba(27, 26, 23, 0.15); --accent-strong: #ea580c;
--accent-soft: #ffedd5;
--card: #ffffff;
--stroke: #99f6e4;
} }
* { * {
@@ -19,8 +22,8 @@
body { body {
margin: 0; margin: 0;
font-family: 'Space Grotesk', sans-serif; font-family: 'Fira Sans', sans-serif;
background: radial-gradient(circle at top, #fff2d9 0%, var(--bg) 40%, var(--bg-alt) 100%); background: var(--bg);
color: var(--ink); color: var(--ink);
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
@@ -29,74 +32,188 @@ body {
h1, h1,
h2, h2,
h3 { h3 {
font-family: 'Syne', sans-serif; font-family: 'Fira Sans', sans-serif;
margin: 0; margin: 0;
} }
.bg-orbit { .bg-orbit {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: radial-gradient(circle at 20% 20%, rgba(217, 119, 6, 0.2), transparent 50%), background: var(--bg);
radial-gradient(circle at 80% 10%, rgba(30, 64, 175, 0.15), transparent 45%),
radial-gradient(circle at 70% 80%, rgba(190, 24, 93, 0.12), transparent 60%);
z-index: -1; z-index: -1;
overflow: hidden;
}
.bg-orbit::before,
.bg-orbit::after {
content: '';
position: absolute;
border-radius: 42%;
opacity: 0.65;
}
.bg-orbit::before {
width: 520px;
height: 520px;
background: #a7f3d0;
top: -180px;
left: -120px;
}
.bg-orbit::after {
width: 440px;
height: 440px;
background: var(--accent-soft);
bottom: -160px;
right: -120px;
} }
.shell { .shell {
max-width: 1080px; max-width: 1120px;
margin: 0 auto; margin: 0 auto;
padding: 48px 24px 80px; padding: 56px 24px 88px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 28px;
} }
.hero { .hero {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 280px; grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 24px; gap: 28px;
align-items: stretch; align-items: stretch;
} }
.eyebrow { .eyebrow {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.2em; letter-spacing: 0.24em;
font-size: 0.72rem; font-size: 0.7rem;
color: var(--muted); color: var(--muted);
margin: 0 0 12px; margin: 0 0 12px;
} }
.subhead { .hero-copy h1 {
font-size: 1rem; font-size: clamp(2.3rem, 3vw, 3.2rem);
color: var(--muted); line-height: 1.05;
margin-top: 12px;
max-width: 42ch;
} }
.status-card { .subhead {
background: var(--card); font-size: 1.05rem;
border: 1px solid var(--stroke); color: var(--muted);
border-radius: 18px; margin-top: 12px;
padding: 20px; max-width: 46ch;
box-shadow: var(--shadow); }
.hero-actions {
display: flex;
gap: 12px;
margin-top: 20px;
flex-wrap: wrap;
}
.hero-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
}
.status-board {
background: var(--card);
border: 1px solid var(--stroke);
border-radius: 22px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
.session-card {
background: var(--card);
border: 1px solid var(--stroke);
border-radius: 20px;
padding: 18px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.status-board .session-card {
border: none;
padding: 0;
}
.label {
margin: 0 0 6px;
color: var(--muted);
font-size: 0.85rem;
}
.session-state {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.session-controls {
display: flex;
align-items: center;
gap: 8px;
}
.btn.compact {
padding: 6px 12px;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.session-pill {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--stroke);
background: var(--bg-alt);
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.session-pill.live {
background: var(--primary-soft);
color: var(--primary-strong);
border-color: var(--primary);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
.status-card p { .stat-card {
margin: 0; background: var(--card);
border: 1px solid var(--stroke);
border-radius: 18px;
padding: 14px 16px;
}
.stat-card p {
margin: 0 0 8px;
color: var(--muted); color: var(--muted);
font-size: 0.85rem;
}
.stat-card h3 {
font-size: 1.4rem;
} }
.panel { .panel {
background: rgba(255, 255, 255, 0.6); background: var(--card);
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
border-radius: 28px; border-radius: 24px;
padding: 28px; padding: 26px;
backdrop-filter: blur(6px); animation: floatIn 0.2s ease both;
box-shadow: var(--shadow);
animation: floatIn 0.6s ease both;
} }
.panel-head { .panel-head {
@@ -139,7 +256,8 @@ h3 {
padding: 12px 14px; padding: 12px 14px;
font-size: 0.95rem; font-size: 0.95rem;
font-family: inherit; font-family: inherit;
background: #fff; background: #f0fdfa;
color: var(--ink);
} }
.form-grid textarea { .form-grid textarea {
@@ -147,6 +265,13 @@ h3 {
resize: vertical; resize: vertical;
} }
.form-grid input:focus,
.form-grid select:focus,
.form-grid textarea:focus {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.form-grid .full { .form-grid .full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -158,28 +283,58 @@ h3 {
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
font-family: inherit; font-family: inherit;
transition: transform 0.15s ease, box-shadow 0.15s ease; transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
} }
.btn.primary { .btn.primary {
background: var(--accent); background: var(--primary);
color: #fff; color: #fff;
box-shadow: 0 12px 24px rgba(217, 119, 6, 0.3);
} }
.btn.ghost { .btn.ghost {
background: transparent; background: var(--primary-soft);
border: 1px solid var(--stroke); border: 1px solid transparent;
color: var(--ink); color: var(--primary-strong);
} }
.btn:hover { .btn.danger {
transform: translateY(-2px); background: var(--accent-soft);
color: var(--accent-strong);
}
.btn.primary:hover {
background: var(--primary-strong);
}
.btn.ghost:hover {
border-color: var(--primary);
}
.btn.danger:hover {
border-color: var(--accent);
} }
.task-list { .task-list {
display: grid; display: grid;
gap: 16px; gap: 16px;
--task-grid: 40px minmax(160px, 1.3fr) 220px minmax(240px, 2fr) 240px;
}
.task-header {
display: grid;
grid-template-columns: var(--task-grid);
align-items: center;
gap: 20px;
padding: 0 18px;
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: left;
}
.task-header span {
text-align: left;
} }
.task-card { .task-card {
@@ -190,42 +345,101 @@ h3 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
animation: riseIn 0.4s ease both; animation: riseIn 0.2s ease both;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.task-card:hover {
border-color: var(--primary);
box-shadow: 0 12px 30px rgba(13, 148, 136, 0.1);
} }
.task-main { .task-main {
display: grid;
grid-template-columns: var(--task-grid);
gap: 20px;
align-items: center;
}
.task-checker {
display: flex; display: flex;
justify-content: space-between; align-items: center;
gap: 16px; gap: 10px;
align-items: flex-start; min-width: 40px;
} }
.task-card h3 { .task-check {
margin: 0 0 6px; width: 18px;
height: 18px;
border-radius: 6px;
border: 1px solid var(--primary);
accent-color: var(--primary);
cursor: pointer;
} }
.task-card .meta { .task-content {
font-size: 0.85rem; display: contents;
}
.task-info {
display: contents;
font-size: 0.9rem;
color: var(--ink);
}
.task-title {
font-weight: 600;
}
.task-due {
color: var(--muted); color: var(--muted);
font-family: 'Fira Code', monospace;
white-space: nowrap;
} }
.task-card .desc { .task-desc {
margin: 6px 0 0; color: var(--muted);
} font-size: 0.85rem;
line-height: 1.4;
.badge { white-space: nowrap;
padding: 6px 12px; overflow: hidden;
border-radius: 999px; text-overflow: ellipsis;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
background: rgba(217, 119, 6, 0.15);
color: var(--accent-dark);
} }
.task-actions { .task-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center;
justify-content: flex-end;
}
.task-card.completed {
position: relative;
overflow: hidden;
border-color: #5eead4;
box-shadow: 0 16px 36px rgba(13, 148, 136, 0.12);
background: #ecfdf5;
}
.task-card.completed::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
rgba(167, 243, 208, 0.75) 0%,
rgba(167, 243, 208, 0.35) 55%,
rgba(236, 253, 245, 0) 100%
);
transform: translateX(-120%);
animation: sweepGreen 0.8s ease-out forwards;
pointer-events: none;
z-index: 0;
}
.task-card.completed .task-main {
position: relative;
z-index: 1;
} }
.empty { .empty {
@@ -236,10 +450,122 @@ h3 {
color: var(--muted); color: var(--muted);
} }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 118, 110, 0.2);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 20;
}
.modal {
background: var(--card);
border-radius: 20px;
border: 1px solid var(--stroke);
padding: 20px;
width: min(560px, 92vw);
display: flex;
flex-direction: column;
gap: 16px;
animation: riseIn 0.2s ease both;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.modal-sub {
margin: 6px 0 0;
color: var(--muted);
}
.modal-body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.modal-body label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.85rem;
color: var(--muted);
}
.modal-body input,
.modal-body select,
.modal-body textarea {
border-radius: 12px;
border: 1px solid var(--stroke);
padding: 12px 14px;
font-size: 0.95rem;
font-family: inherit;
background: #f0fdfa;
color: var(--ink);
}
.modal-body textarea {
min-height: 90px;
resize: vertical;
}
.modal-body .full {
grid-column: 1 / -1;
}
.modal-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.toast-stack {
position: fixed;
top: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 40;
}
.toast {
min-width: 220px;
max-width: 320px;
background: var(--card);
border: 1px solid var(--stroke);
border-radius: 14px;
padding: 12px 16px;
font-size: 0.9rem;
color: var(--ink);
box-shadow: 0 14px 30px rgba(13, 148, 136, 0.18);
animation: toastIn 0.2s ease both;
}
.toast.success {
border-color: #5eead4;
}
.toast.error {
border-color: #fca5a5;
color: #b91c1c;
}
.toast.info {
border-color: var(--stroke);
}
@keyframes floatIn { @keyframes floatIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(24px); transform: translateY(8px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -250,7 +576,7 @@ h3 {
@keyframes riseIn { @keyframes riseIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(12px); transform: translateY(6px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -258,12 +584,65 @@ h3 {
} }
} }
@keyframes sweepGreen {
from {
transform: translateX(-120%);
}
to {
transform: translateX(0);
}
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.hero { .hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.stat-grid {
grid-template-columns: 1fr;
}
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.modal-body {
grid-template-columns: 1fr;
}
.task-main {
grid-template-columns: 1fr;
gap: 12px;
}
.task-checker {
min-width: 0;
}
.task-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.task-header {
display: none;
}
} }

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -1,95 +0,0 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>