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:
29
go.mod
29
go.mod
@@ -2,33 +2,52 @@ module wolves.top/todo
|
||||
|
||||
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 (
|
||||
github.com/bytedance/sonic v1.11.6 // 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/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/gin-contrib/sse v0.1.0 // 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.20.0 // 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/klauspost/compress v1.15.9 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.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/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
74
go.sum
74
go.sum
@@ -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/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
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/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/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
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/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/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.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
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/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/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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/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/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/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.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.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.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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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.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/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/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.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
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 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
631
main.go
631
main.go
@@ -1,12 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
@@ -21,120 +35,224 @@ type Task struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type taskStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
items map[int64]Task
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
type authStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
users map[string]User
|
||||
sessions map[string]int64
|
||||
type tokenManager struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newTaskStore() *taskStore {
|
||||
return &taskStore{
|
||||
nextID: 1,
|
||||
items: make(map[int64]Task),
|
||||
type postgresStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
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 {
|
||||
return &authStore{
|
||||
nextID: 1,
|
||||
users: make(map[string]User),
|
||||
sessions: make(map[string]int64),
|
||||
func (t *tokenManager) Generate(user User) (string, error) {
|
||||
payload := struct {
|
||||
UserID int64 `json:"uid"`
|
||||
Exp int64 `json:"exp"`
|
||||
}{
|
||||
UserID: user.ID,
|
||||
Exp: time.Now().Add(t.ttl).Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *authStore) register(email, password string) (User, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.users[email]; exists {
|
||||
return User{}, false
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user := User{
|
||||
ID: s.nextID,
|
||||
Email: email,
|
||||
Password: password,
|
||||
encoded := base64.RawURLEncoding.EncodeToString(raw)
|
||||
sig := t.sign(encoded)
|
||||
return encoded + "." + sig, nil
|
||||
}
|
||||
|
||||
func (t *tokenManager) Validate(token string) (int64, bool) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
s.nextID++
|
||||
s.users[email] = user
|
||||
return user, true
|
||||
}
|
||||
|
||||
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
|
||||
payload, sig := parts[0], parts[1]
|
||||
if !t.verify(payload, sig) {
|
||||
return 0, false
|
||||
}
|
||||
token := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(user.ID, 10)
|
||||
s.sessions[token] = user.ID
|
||||
return token, true
|
||||
}
|
||||
|
||||
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)
|
||||
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
t, ok := s.items[id]
|
||||
return t, ok
|
||||
func (t *tokenManager) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, t.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *taskStore) create(input Task) Task {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
func (t *tokenManager) verify(payload, signature string) bool {
|
||||
expected := t.sign(payload)
|
||||
return hmac.Equal([]byte(signature), []byte(expected))
|
||||
}
|
||||
|
||||
input.ID = s.nextID
|
||||
s.nextID++
|
||||
now := time.Now().UTC()
|
||||
input.CreatedAt = now
|
||||
input.UpdatedAt = now
|
||||
func newPostgresStore(ctx context.Context, url string) (*postgresStore, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 == "" {
|
||||
input.Status = "todo"
|
||||
}
|
||||
s.items[input.ID] = input
|
||||
return input
|
||||
var tags []string
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
existing, ok := s.items[id]
|
||||
if !ok {
|
||||
return Task{}, false
|
||||
func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task) (Task, error) {
|
||||
existing, err := s.Get(ctx, userID, id)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if input.Title != "" {
|
||||
existing.Title = input.Title
|
||||
@@ -154,25 +272,139 @@ func (s *taskStore) update(id int64, input Task) (Task, bool) {
|
||||
if input.Tags != nil {
|
||||
existing.Tags = input.Tags
|
||||
}
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
s.items[id] = existing
|
||||
return existing, true
|
||||
var updatedAt time.Time
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
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 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.items[id]; !ok {
|
||||
return false
|
||||
func (s *postgresStore) Delete(ctx context.Context, userID, id int64) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 false
|
||||
}
|
||||
|
||||
func main() {
|
||||
store := newTaskStore()
|
||||
authStore := newAuthStore()
|
||||
store, tokens, cache, emitter := buildDependencies()
|
||||
defer store.Close()
|
||||
if cache != nil {
|
||||
defer cache.client.Close()
|
||||
}
|
||||
defer emitter.Close()
|
||||
|
||||
gin.SetMode(gin.DebugMode)
|
||||
router := gin.Default()
|
||||
router.RedirectTrailingSlash = false
|
||||
@@ -193,16 +425,6 @@ func main() {
|
||||
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.POST("/register", func(c *gin.Context) {
|
||||
@@ -214,15 +436,20 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Email = strings.TrimSpace(input.Email)
|
||||
if input.Email == "" || input.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||
return
|
||||
}
|
||||
user, ok := authStore.register(input.Email, input.Password)
|
||||
if !ok {
|
||||
user, err := store.Register(c.Request.Context(), input.Email, input.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email})
|
||||
})
|
||||
auth.POST("/login", func(c *gin.Context) {
|
||||
@@ -234,41 +461,90 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Email = strings.TrimSpace(input.Email)
|
||||
if input.Email == "" || input.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||
return
|
||||
}
|
||||
token, ok := authStore.login(input.Email, input.Password)
|
||||
if !ok {
|
||||
user, err := store.Login(c.Request.Context(), input.Email, input.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidCredentials) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
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})
|
||||
})
|
||||
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.Use(func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
token := extractBearerToken(c)
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||
return
|
||||
}
|
||||
token := authHeader
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token = authHeader[7:]
|
||||
}
|
||||
if token == "" || !authStore.validate(token) {
|
||||
userID, ok := tokens.Validate(token)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
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()
|
||||
})
|
||||
|
||||
tasks := api.Group("/tasks")
|
||||
{
|
||||
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) {
|
||||
var input Task
|
||||
@@ -276,7 +552,13 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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)
|
||||
})
|
||||
tasks.GET(":id", func(c *gin.Context) {
|
||||
@@ -285,11 +567,16 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
task, ok := store.get(id)
|
||||
if !ok {
|
||||
userID := c.GetInt64("user_id")
|
||||
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"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, task)
|
||||
})
|
||||
tasks.PUT(":id", func(c *gin.Context) {
|
||||
@@ -303,11 +590,17 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, ok := store.update(id, input)
|
||||
if !ok {
|
||||
userID := c.GetInt64("user_id")
|
||||
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"})
|
||||
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)
|
||||
})
|
||||
tasks.DELETE(":id", func(c *gin.Context) {
|
||||
@@ -316,10 +609,16 @@ func main() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
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"})
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Todo Control Room</title>
|
||||
<title>Todo Room</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
584
vue/src/App.vue
584
vue/src/App.vue
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { API_BASE } from './config'
|
||||
|
||||
type Task = {
|
||||
@@ -12,9 +12,181 @@ type Task = {
|
||||
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 sessionState = ref('Not connected')
|
||||
const sessionState = ref<MessageKey>('state_idle')
|
||||
const tasks = ref<Task[]>([])
|
||||
const authForm = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
const form = reactive({
|
||||
title: '',
|
||||
due_at: '',
|
||||
@@ -22,47 +194,135 @@ const form = reactive({
|
||||
tags: '',
|
||||
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) {
|
||||
sessionState.value = text
|
||||
const isEditing = computed(() => editingTaskId.value !== null)
|
||||
|
||||
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() {
|
||||
return {
|
||||
const headers: Record<string, string> = {
|
||||
'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() {
|
||||
if (!authForm.email || !authForm.password) {
|
||||
pushToast(t('credentials_required'), 'error')
|
||||
return
|
||||
}
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({
|
||||
email: authForm.email,
|
||||
password: authForm.password,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
setSessionState('Login failed')
|
||||
pushToast(t('state_login_failed'), 'error')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
token.value = data.token || 'demo-token'
|
||||
setSessionState('Connected')
|
||||
saveToken(data.token || '')
|
||||
if (!token.value) {
|
||||
pushToast(t('state_token_missing'), 'error')
|
||||
return
|
||||
}
|
||||
setSessionState('state_connected')
|
||||
isAccountOpen.value = false
|
||||
pushToast(t('toast_login_success'), 'success')
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function buildMeta(task: Task) {
|
||||
const parts: string[] = []
|
||||
if (task.status) parts.push(task.status.toUpperCase())
|
||||
if (task.due_at) parts.push(`Due: ${task.due_at}`)
|
||||
if (task.priority) parts.push(`P${task.priority}`)
|
||||
if (task.tags && task.tags.length > 0) parts.push(task.tags.join(', '))
|
||||
return parts.join(' | ')
|
||||
async function logout() {
|
||||
if (token.value) {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
})
|
||||
}
|
||||
saveToken('')
|
||||
tasks.value = []
|
||||
setSessionState('state_logged_out')
|
||||
pushToast(t('toast_logout_success'), 'success')
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
if (!token.value) {
|
||||
setSessionState('state_idle')
|
||||
tasks.value = []
|
||||
return
|
||||
}
|
||||
const response = await fetch(`${API_BASE}/tasks`, {
|
||||
headers: getHeaders(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
setSessionState('Auth required')
|
||||
setSessionState('state_idle')
|
||||
tasks.value = []
|
||||
return
|
||||
}
|
||||
@@ -70,6 +330,36 @@ async function loadTasks() {
|
||||
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() {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
@@ -82,22 +372,23 @@ async function createTask() {
|
||||
.filter(Boolean),
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/tasks`, {
|
||||
method: 'POST',
|
||||
const isUpdate = isEditing.value && editingTaskId.value !== null
|
||||
const endpoint = isUpdate ? `${API_BASE}/tasks/${editingTaskId.value}` : `${API_BASE}/tasks`
|
||||
const method = isUpdate ? 'PUT' : 'POST'
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
setSessionState('Failed to create task')
|
||||
pushToast(t(isUpdate ? 'state_update_failed' : 'state_create_failed'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
form.title = ''
|
||||
form.due_at = ''
|
||||
form.priority = '1'
|
||||
form.tags = ''
|
||||
form.description = ''
|
||||
resetTaskForm()
|
||||
editingTaskId.value = null
|
||||
isTaskOpen.value = false
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
@@ -141,89 +432,240 @@ function toISO(value: string) {
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="bg-orbit"></div>
|
||||
<main class="shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Distributed todo playground</p>
|
||||
<h1>Todo Control Room</h1>
|
||||
<p class="subhead">A bold, focused UI to drive your task workflow from a single cockpit.</p>
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">{{ t('eyebrow') }}</p>
|
||||
<h1>{{ t('headline') }}</h1>
|
||||
<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 class="status-card">
|
||||
<h2>Session</h2>
|
||||
<p>{{ sessionState }}</p>
|
||||
<button class="btn primary" type="button" @click="login">Login (demo)</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<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>
|
||||
<h2>New Task</h2>
|
||||
<p>Create tasks quickly with priority and due date.</p>
|
||||
<h3>{{ t('account') }}</h3>
|
||||
<p class="modal-sub">{{ t('account_hint') }}</p>
|
||||
</div>
|
||||
<button class="btn ghost" type="button" @click="isAccountOpen = false">{{ t('cancel') }}</button>
|
||||
</div>
|
||||
<form class="form-grid" @submit.prevent="createTask">
|
||||
<div class="modal-body">
|
||||
<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 />
|
||||
</label>
|
||||
<label>
|
||||
<span>Due</span>
|
||||
<span>{{ t('due') }}</span>
|
||||
<input v-model="form.due_at" type="datetime-local" name="due_at" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Priority</span>
|
||||
<span>{{ t('priority') }}</span>
|
||||
<select v-model="form.priority" name="priority">
|
||||
<option value="1">High</option>
|
||||
<option value="2">Medium</option>
|
||||
<option value="3">Low</option>
|
||||
<option value="1">{{ t('priority_high') }}</option>
|
||||
<option value="2">{{ t('priority_medium') }}</option>
|
||||
<option value="3">{{ t('priority_low') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tags (comma)</span>
|
||||
<span>{{ t('tags') }}</span>
|
||||
<input v-model="form.tags" type="text" name="tags" placeholder="backend, microservices" />
|
||||
</label>
|
||||
<label class="full">
|
||||
<span>Description</span>
|
||||
<span>{{ t('description') }}</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
name="description"
|
||||
placeholder="Outline scope, list risks..."
|
||||
></textarea>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Tasks</h2>
|
||||
<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 class="toast-stack" aria-live="polite" aria-atomic="true">
|
||||
<div v-for="toast in toasts" :key="toast.id" class="toast" :class="toast.tone">
|
||||
{{ toast.message }}
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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 {
|
||||
color-scheme: light;
|
||||
--bg: #f6f3ef;
|
||||
--bg-alt: #efe7de;
|
||||
--ink: #1b1a17;
|
||||
--muted: #6c5f57;
|
||||
--accent: #d97706;
|
||||
--accent-dark: #a35503;
|
||||
--card: #fff7ed;
|
||||
--stroke: rgba(27, 26, 23, 0.1);
|
||||
--shadow: 0 24px 60px rgba(27, 26, 23, 0.15);
|
||||
--bg: #f0fdfa;
|
||||
--bg-alt: #ccfbf1;
|
||||
--ink: #134e4a;
|
||||
--muted: #0f766e;
|
||||
--primary: #0d9488;
|
||||
--primary-strong: #0f766e;
|
||||
--primary-soft: #ccfbf1;
|
||||
--accent: #f97316;
|
||||
--accent-strong: #ea580c;
|
||||
--accent-soft: #ffedd5;
|
||||
--card: #ffffff;
|
||||
--stroke: #99f6e4;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -19,8 +22,8 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
background: radial-gradient(circle at top, #fff2d9 0%, var(--bg) 40%, var(--bg-alt) 100%);
|
||||
font-family: 'Fira Sans', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
@@ -29,74 +32,188 @@ body {
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-family: 'Fira Sans', sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bg-orbit {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(217, 119, 6, 0.2), transparent 50%),
|
||||
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%);
|
||||
background: var(--bg);
|
||||
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 {
|
||||
max-width: 1080px;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 80px;
|
||||
padding: 56px 24px 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 28px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.24em;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
margin-top: 12px;
|
||||
max-width: 42ch;
|
||||
.hero-copy h1 {
|
||||
font-size: clamp(2.3rem, 3vw, 3.2rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
.subhead {
|
||||
font-size: 1.05rem;
|
||||
color: var(--muted);
|
||||
margin-top: 12px;
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.status-card p {
|
||||
margin: 0;
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
margin: 0 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 28px;
|
||||
padding: 28px;
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: var(--shadow);
|
||||
animation: floatIn 0.6s ease both;
|
||||
border-radius: 24px;
|
||||
padding: 26px;
|
||||
animation: floatIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
@@ -139,7 +256,8 @@ h3 {
|
||||
padding: 12px 14px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
background: #f0fdfa;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.form-grid textarea {
|
||||
@@ -147,6 +265,13 @@ h3 {
|
||||
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 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -158,28 +283,58 @@ h3 {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
background: var(--accent);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 24px rgba(217, 119, 6, 0.3);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--stroke);
|
||||
color: var(--ink);
|
||||
background: var(--primary-soft);
|
||||
border: 1px solid transparent;
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
.btn.danger {
|
||||
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 {
|
||||
display: grid;
|
||||
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 {
|
||||
@@ -190,42 +345,101 @@ h3 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: var(--task-grid);
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-checker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.task-card h3 {
|
||||
margin: 0 0 6px;
|
||||
.task-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--primary);
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-card .meta {
|
||||
font-size: 0.85rem;
|
||||
.task-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: contents;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
color: var(--muted);
|
||||
font-family: 'Fira Code', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-card .desc {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
background: rgba(217, 119, 6, 0.15);
|
||||
color: var(--accent-dark);
|
||||
.task-desc {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -236,10 +450,122 @@ h3 {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -250,7 +576,7 @@ h3 {
|
||||
@keyframes riseIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
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) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve 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>
|
||||
@@ -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>
|
||||
|
||||
Vue’s
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user