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
|
go 1.25.5
|
||||||
|
|
||||||
require github.com/gin-gonic/gin v1.10.0
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/jackc/pgconn v1.14.3
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2
|
||||||
|
github.com/segmentio/kafka-go v0.4.50
|
||||||
|
golang.org/x/crypto v0.36.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.15.9 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
74
go.sum
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 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
@@ -28,12 +37,37 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
|
|||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
|
||||||
|
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
|
||||||
|
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||||
|
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
||||||
|
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -45,43 +79,63 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
|
||||||
|
github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
631
main.go
631
main.go
@@ -1,12 +1,26 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/segmentio/kafka-go"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
@@ -21,120 +35,224 @@ type Task struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type taskStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
nextID int64
|
|
||||||
items map[int64]Task
|
|
||||||
}
|
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type authStore struct {
|
type tokenManager struct {
|
||||||
mu sync.Mutex
|
secret []byte
|
||||||
nextID int64
|
ttl time.Duration
|
||||||
users map[string]User
|
|
||||||
sessions map[string]int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTaskStore() *taskStore {
|
type postgresStore struct {
|
||||||
return &taskStore{
|
pool *pgxpool.Pool
|
||||||
nextID: 1,
|
}
|
||||||
items: make(map[int64]Task),
|
|
||||||
|
type tokenCache struct {
|
||||||
|
client *redis.Client
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskEmitter struct {
|
||||||
|
writer *kafka.Writer
|
||||||
|
topic string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTokenManager(secret string, ttl time.Duration) *tokenManager {
|
||||||
|
return &tokenManager{
|
||||||
|
secret: []byte(secret),
|
||||||
|
ttl: ttl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAuthStore() *authStore {
|
func (t *tokenManager) Generate(user User) (string, error) {
|
||||||
return &authStore{
|
payload := struct {
|
||||||
nextID: 1,
|
UserID int64 `json:"uid"`
|
||||||
users: make(map[string]User),
|
Exp int64 `json:"exp"`
|
||||||
sessions: make(map[string]int64),
|
}{
|
||||||
|
UserID: user.ID,
|
||||||
|
Exp: time.Now().Add(t.ttl).Unix(),
|
||||||
}
|
}
|
||||||
}
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
func (s *authStore) register(email, password string) (User, bool) {
|
return "", err
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := s.users[email]; exists {
|
|
||||||
return User{}, false
|
|
||||||
}
|
}
|
||||||
user := User{
|
encoded := base64.RawURLEncoding.EncodeToString(raw)
|
||||||
ID: s.nextID,
|
sig := t.sign(encoded)
|
||||||
Email: email,
|
return encoded + "." + sig, nil
|
||||||
Password: password,
|
}
|
||||||
|
|
||||||
|
func (t *tokenManager) Validate(token string) (int64, bool) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, false
|
||||||
}
|
}
|
||||||
s.nextID++
|
payload, sig := parts[0], parts[1]
|
||||||
s.users[email] = user
|
if !t.verify(payload, sig) {
|
||||||
return user, true
|
return 0, false
|
||||||
}
|
|
||||||
|
|
||||||
func (s *authStore) login(email, password string) (string, bool) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
user, ok := s.users[email]
|
|
||||||
if !ok || user.Password != password {
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
token := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(user.ID, 10)
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||||
s.sessions[token] = user.ID
|
if err != nil {
|
||||||
return token, true
|
return 0, false
|
||||||
}
|
|
||||||
|
|
||||||
func (s *authStore) validate(token string) bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
_, ok := s.sessions[token]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *taskStore) list() []Task {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
result := make([]Task, 0, len(s.items))
|
|
||||||
for _, t := range s.items {
|
|
||||||
result = append(result, t)
|
|
||||||
}
|
}
|
||||||
return result
|
var data struct {
|
||||||
|
UserID int64 `json:"uid"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &data); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if data.UserID == 0 || time.Now().Unix() > data.Exp {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return data.UserID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *taskStore) get(id int64) (Task, bool) {
|
func (t *tokenManager) sign(payload string) string {
|
||||||
s.mu.Lock()
|
mac := hmac.New(sha256.New, t.secret)
|
||||||
defer s.mu.Unlock()
|
mac.Write([]byte(payload))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
t, ok := s.items[id]
|
|
||||||
return t, ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *taskStore) create(input Task) Task {
|
func (t *tokenManager) verify(payload, signature string) bool {
|
||||||
s.mu.Lock()
|
expected := t.sign(payload)
|
||||||
defer s.mu.Unlock()
|
return hmac.Equal([]byte(signature), []byte(expected))
|
||||||
|
}
|
||||||
|
|
||||||
input.ID = s.nextID
|
func newPostgresStore(ctx context.Context, url string) (*postgresStore, error) {
|
||||||
s.nextID++
|
pool, err := pgxpool.New(ctx, url)
|
||||||
now := time.Now().UTC()
|
if err != nil {
|
||||||
input.CreatedAt = now
|
return nil, err
|
||||||
input.UpdatedAt = now
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store := &postgresStore{pool: pool}
|
||||||
|
if err := store.initSchema(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) initSchema(ctx context.Context) error {
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
due_at TEXT,
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if _, err := s.pool.Exec(ctx, stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) Register(ctx context.Context, email, password string) (User, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
err = s.pool.QueryRow(ctx, `INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`, email, string(hashed)).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return User{}, errAlreadyExists
|
||||||
|
}
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return User{ID: id, Email: email}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) Login(ctx context.Context, email, password string) (User, error) {
|
||||||
|
var user User
|
||||||
|
var hash string
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT id, email, password_hash FROM users WHERE email = $1`, email).Scan(&user.ID, &user.Email, &hash)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, errInvalidCredentials
|
||||||
|
}
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return User{}, errInvalidCredentials
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY id DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := []Task{}
|
||||||
|
for rows.Next() {
|
||||||
|
var task Task
|
||||||
|
var tags []string
|
||||||
|
if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.Tags = tags
|
||||||
|
result = append(result, task)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) Get(ctx context.Context, userID, id int64) (Task, error) {
|
||||||
|
var task Task
|
||||||
|
var tags []string
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 AND id = $2`, userID, id).
|
||||||
|
Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
|
task.Tags = tags
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) Create(ctx context.Context, userID int64, input Task) (Task, error) {
|
||||||
if input.Status == "" {
|
if input.Status == "" {
|
||||||
input.Status = "todo"
|
input.Status = "todo"
|
||||||
}
|
}
|
||||||
s.items[input.ID] = input
|
var tags []string
|
||||||
return input
|
if input.Tags != nil {
|
||||||
|
tags = input.Tags
|
||||||
|
}
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
||||||
|
RETURNING id, created_at, updated_at`,
|
||||||
|
userID, input.Title, input.Description, input.Status, input.DueAt, input.Priority, tags,
|
||||||
|
).Scan(&input.ID, &input.CreatedAt, &input.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *taskStore) update(id int64, input Task) (Task, bool) {
|
func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task) (Task, error) {
|
||||||
s.mu.Lock()
|
existing, err := s.Get(ctx, userID, id)
|
||||||
defer s.mu.Unlock()
|
if err != nil {
|
||||||
|
return Task{}, err
|
||||||
existing, ok := s.items[id]
|
|
||||||
if !ok {
|
|
||||||
return Task{}, false
|
|
||||||
}
|
}
|
||||||
if input.Title != "" {
|
if input.Title != "" {
|
||||||
existing.Title = input.Title
|
existing.Title = input.Title
|
||||||
@@ -154,25 +272,139 @@ func (s *taskStore) update(id int64, input Task) (Task, bool) {
|
|||||||
if input.Tags != nil {
|
if input.Tags != nil {
|
||||||
existing.Tags = input.Tags
|
existing.Tags = input.Tags
|
||||||
}
|
}
|
||||||
existing.UpdatedAt = time.Now().UTC()
|
var updatedAt time.Time
|
||||||
s.items[id] = existing
|
err = s.pool.QueryRow(ctx, `
|
||||||
return existing, true
|
UPDATE tasks
|
||||||
|
SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, updated_at = now()
|
||||||
|
WHERE id = $7 AND user_id = $8
|
||||||
|
RETURNING updated_at`,
|
||||||
|
existing.Title, existing.Description, existing.Status, existing.DueAt, existing.Priority, existing.Tags, id, userID,
|
||||||
|
).Scan(&updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
|
existing.UpdatedAt = updatedAt
|
||||||
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *taskStore) delete(id int64) bool {
|
func (s *postgresStore) Delete(ctx context.Context, userID, id int64) error {
|
||||||
s.mu.Lock()
|
result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
defer s.mu.Unlock()
|
if err != nil {
|
||||||
|
return err
|
||||||
if _, ok := s.items[id]; !ok {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
delete(s.items, id)
|
if result.RowsAffected() == 0 {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) Close() {
|
||||||
|
if s.pool != nil {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTokenCache(addr, password string, db int) (*tokenCache, error) {
|
||||||
|
if strings.TrimSpace(addr) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: password,
|
||||||
|
DB: db,
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tokenCache{client: client, prefix: "auth:token:"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenCache) Save(ctx context.Context, token string, ttl time.Duration) error {
|
||||||
|
return c.client.Set(ctx, c.prefix+token, "1", ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenCache) Delete(ctx context.Context, token string) error {
|
||||||
|
return c.client.Del(ctx, c.prefix+token).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenCache) Exists(ctx context.Context, token string) (bool, error) {
|
||||||
|
count, err := c.client.Exists(ctx, c.prefix+token).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTaskEmitter(brokers []string, topic string) *taskEmitter {
|
||||||
|
if len(brokers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writer := &kafka.Writer{
|
||||||
|
Addr: kafka.TCP(brokers...),
|
||||||
|
Topic: topic,
|
||||||
|
Balancer: &kafka.LeastBytes{},
|
||||||
|
}
|
||||||
|
return &taskEmitter{writer: writer, topic: topic}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *taskEmitter) Emit(ctx context.Context, eventType string, task Task, userID int64) {
|
||||||
|
if e == nil || e.writer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"type": eventType,
|
||||||
|
"task_id": task.ID,
|
||||||
|
"user_id": userID,
|
||||||
|
"status": task.Status,
|
||||||
|
"priority": task.Priority,
|
||||||
|
"at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := e.writer.WriteMessages(writeCtx, kafka.Message{
|
||||||
|
Key: []byte(strconv.FormatInt(task.ID, 10)),
|
||||||
|
Value: data,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("kafka write failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *taskEmitter) Close() {
|
||||||
|
if e == nil || e.writer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := e.writer.Close(); err != nil {
|
||||||
|
log.Printf("kafka close failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errAlreadyExists = errors.New("already exists")
|
||||||
|
errInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
store := newTaskStore()
|
store, tokens, cache, emitter := buildDependencies()
|
||||||
authStore := newAuthStore()
|
defer store.Close()
|
||||||
|
if cache != nil {
|
||||||
|
defer cache.client.Close()
|
||||||
|
}
|
||||||
|
defer emitter.Close()
|
||||||
|
|
||||||
gin.SetMode(gin.DebugMode)
|
gin.SetMode(gin.DebugMode)
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
router.RedirectTrailingSlash = false
|
router.RedirectTrailingSlash = false
|
||||||
@@ -193,16 +425,6 @@ func main() {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/", func(c *gin.Context) {
|
|
||||||
c.File("test/web/index.html")
|
|
||||||
})
|
|
||||||
router.GET("/styles.css", func(c *gin.Context) {
|
|
||||||
c.File("test/web/styles.css")
|
|
||||||
})
|
|
||||||
router.GET("/app.js", func(c *gin.Context) {
|
|
||||||
c.File("test/web/app.js")
|
|
||||||
})
|
|
||||||
|
|
||||||
auth := router.Group("/api/v1/auth")
|
auth := router.Group("/api/v1/auth")
|
||||||
{
|
{
|
||||||
auth.POST("/register", func(c *gin.Context) {
|
auth.POST("/register", func(c *gin.Context) {
|
||||||
@@ -214,15 +436,20 @@ func main() {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
input.Email = strings.TrimSpace(input.Email)
|
||||||
if input.Email == "" || input.Password == "" {
|
if input.Email == "" || input.Password == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, ok := authStore.register(input.Email, input.Password)
|
user, err := store.Register(c.Request.Context(), input.Email, input.Password)
|
||||||
if !ok {
|
if err != nil {
|
||||||
|
if errors.Is(err, errAlreadyExists) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email})
|
c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email})
|
||||||
})
|
})
|
||||||
auth.POST("/login", func(c *gin.Context) {
|
auth.POST("/login", func(c *gin.Context) {
|
||||||
@@ -234,41 +461,90 @@ func main() {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
input.Email = strings.TrimSpace(input.Email)
|
||||||
if input.Email == "" || input.Password == "" {
|
if input.Email == "" || input.Password == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, ok := authStore.login(input.Email, input.Password)
|
user, err := store.Login(c.Request.Context(), input.Email, input.Password)
|
||||||
if !ok {
|
if err != nil {
|
||||||
|
if errors.Is(err, errInvalidCredentials) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := tokens.Generate(user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "token generation failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache != nil {
|
||||||
|
if err := cache.Save(c.Request.Context(), token, tokens.ttl); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
})
|
})
|
||||||
|
auth.POST("/logout", func(c *gin.Context) {
|
||||||
|
token := extractBearerToken(c)
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := tokens.Validate(token); !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache != nil {
|
||||||
|
if err := cache.Delete(c.Request.Context(), token); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
api.Use(func(c *gin.Context) {
|
api.Use(func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
token := extractBearerToken(c)
|
||||||
if authHeader == "" {
|
if token == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := authHeader
|
userID, ok := tokens.Validate(token)
|
||||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
if !ok {
|
||||||
token = authHeader[7:]
|
|
||||||
}
|
|
||||||
if token == "" || !authStore.validate(token) {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cache != nil {
|
||||||
|
exists, err := cache.Exists(c.Request.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Set("user_id", userID)
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
tasks := api.Group("/tasks")
|
tasks := api.Group("/tasks")
|
||||||
{
|
{
|
||||||
tasks.GET("", func(c *gin.Context) {
|
tasks.GET("", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, store.list())
|
userID := c.GetInt64("user_id")
|
||||||
|
items, err := store.List(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tasks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
})
|
})
|
||||||
tasks.POST("", func(c *gin.Context) {
|
tasks.POST("", func(c *gin.Context) {
|
||||||
var input Task
|
var input Task
|
||||||
@@ -276,7 +552,13 @@ func main() {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created := store.create(input)
|
userID := c.GetInt64("user_id")
|
||||||
|
created, err := store.Create(c.Request.Context(), userID, input)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitter.Emit(c.Request.Context(), "task.created", created, userID)
|
||||||
c.JSON(http.StatusCreated, created)
|
c.JSON(http.StatusCreated, created)
|
||||||
})
|
})
|
||||||
tasks.GET(":id", func(c *gin.Context) {
|
tasks.GET(":id", func(c *gin.Context) {
|
||||||
@@ -285,11 +567,16 @@ func main() {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
task, ok := store.get(id)
|
userID := c.GetInt64("user_id")
|
||||||
if !ok {
|
task, err := store.Get(c.Request.Context(), userID, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, task)
|
c.JSON(http.StatusOK, task)
|
||||||
})
|
})
|
||||||
tasks.PUT(":id", func(c *gin.Context) {
|
tasks.PUT(":id", func(c *gin.Context) {
|
||||||
@@ -303,11 +590,17 @@ func main() {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated, ok := store.update(id, input)
|
userID := c.GetInt64("user_id")
|
||||||
if !ok {
|
updated, err := store.Update(c.Request.Context(), userID, id, input)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitter.Emit(c.Request.Context(), "task.updated", updated, userID)
|
||||||
c.JSON(http.StatusOK, updated)
|
c.JSON(http.StatusOK, updated)
|
||||||
})
|
})
|
||||||
tasks.DELETE(":id", func(c *gin.Context) {
|
tasks.DELETE(":id", func(c *gin.Context) {
|
||||||
@@ -316,10 +609,16 @@ func main() {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !store.delete(id) {
|
userID := c.GetInt64("user_id")
|
||||||
|
if err := store.Delete(c.Request.Context(), userID, id); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitter.Emit(c.Request.Context(), "task.deleted", Task{ID: id}, userID)
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -329,6 +628,78 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildDependencies() (*postgresStore, *tokenManager, *tokenCache, *taskEmitter) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||||
|
if dbURL == "" {
|
||||||
|
dbURL = "postgres://todo:todo@localhost:5432/todo?sslmode=disable"
|
||||||
|
}
|
||||||
|
store, err := newPostgresStore(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("postgres connection failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := strings.TrimSpace(os.Getenv("AUTH_SECRET"))
|
||||||
|
if secret == "" {
|
||||||
|
secret = "dev-secret-change-me"
|
||||||
|
}
|
||||||
|
tokens := newTokenManager(secret, 24*time.Hour)
|
||||||
|
|
||||||
|
redisAddr := strings.TrimSpace(os.Getenv("REDIS_ADDR"))
|
||||||
|
redisPassword := os.Getenv("REDIS_PASSWORD")
|
||||||
|
redisDB := parseEnvInt("REDIS_DB", 0)
|
||||||
|
cache, err := newTokenCache(redisAddr, redisPassword, redisDB)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("redis connection failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
|
||||||
|
topic := strings.TrimSpace(os.Getenv("KAFKA_TOPIC"))
|
||||||
|
if topic == "" {
|
||||||
|
topic = "todo.tasks"
|
||||||
|
}
|
||||||
|
emitter := newTaskEmitter(brokers, topic)
|
||||||
|
|
||||||
|
return store, tokens, cache, emitter
|
||||||
|
}
|
||||||
|
|
||||||
func parseID(value string) (int64, error) {
|
func parseID(value string) (int64, error) {
|
||||||
return strconv.ParseInt(value, 10, 64)
|
return strconv.ParseInt(value, 10, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractBearerToken(c *gin.Context) string {
|
||||||
|
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
return strings.TrimSpace(authHeader[7:])
|
||||||
|
}
|
||||||
|
return authHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnvInt(key string, fallback int) int {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
item := strings.TrimSpace(part)
|
||||||
|
if item != "" {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Todo Control Room</title>
|
<title>Todo Room</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
584
vue/src/App.vue
584
vue/src/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { API_BASE } from './config'
|
import { API_BASE } from './config'
|
||||||
|
|
||||||
type Task = {
|
type Task = {
|
||||||
@@ -12,9 +12,181 @@ type Task = {
|
|||||||
status?: string
|
status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocaleKey = 'en' | 'zh'
|
||||||
|
|
||||||
|
type MessageKey =
|
||||||
|
| 'eyebrow'
|
||||||
|
| 'headline'
|
||||||
|
| 'subhead'
|
||||||
|
| 'new_task'
|
||||||
|
| 'account'
|
||||||
|
| 'login'
|
||||||
|
| 'register'
|
||||||
|
| 'logout'
|
||||||
|
| 'total'
|
||||||
|
| 'open'
|
||||||
|
| 'complete'
|
||||||
|
| 'tasks'
|
||||||
|
| 'refresh'
|
||||||
|
| 'clear_completed'
|
||||||
|
| 'empty'
|
||||||
|
| 'title'
|
||||||
|
| 'due'
|
||||||
|
| 'priority'
|
||||||
|
| 'tags'
|
||||||
|
| 'description'
|
||||||
|
| 'create_task'
|
||||||
|
| 'cancel'
|
||||||
|
| 'email'
|
||||||
|
| 'password'
|
||||||
|
| 'account_hint'
|
||||||
|
| 'credentials_required'
|
||||||
|
| 'toast_login_success'
|
||||||
|
| 'toast_register_success'
|
||||||
|
| 'toast_logout_success'
|
||||||
|
| 'mark_complete'
|
||||||
|
| 'undo'
|
||||||
|
| 'priority_high'
|
||||||
|
| 'priority_medium'
|
||||||
|
| 'priority_low'
|
||||||
|
| 'delete'
|
||||||
|
| 'edit'
|
||||||
|
| 'edit_task'
|
||||||
|
| 'save_task'
|
||||||
|
| 'state_idle'
|
||||||
|
| 'state_connected'
|
||||||
|
| 'state_auth_required'
|
||||||
|
| 'state_login_failed'
|
||||||
|
| 'state_register_failed'
|
||||||
|
| 'state_registered'
|
||||||
|
| 'state_token_missing'
|
||||||
|
| 'state_logged_out'
|
||||||
|
| 'state_create_failed'
|
||||||
|
| 'state_update_failed'
|
||||||
|
| 'state_restored'
|
||||||
|
| 'language'
|
||||||
|
| 'lang_en'
|
||||||
|
| 'lang_zh'
|
||||||
|
|
||||||
|
const messages: Record<LocaleKey, Record<MessageKey, string>> = {
|
||||||
|
en: {
|
||||||
|
eyebrow: 'Productivity OS',
|
||||||
|
headline: 'SuperTodo Mission Control',
|
||||||
|
subhead: 'Plan, prioritize, and close tasks with a crisp, no-noise workflow.',
|
||||||
|
new_task: 'New Task',
|
||||||
|
account: 'Account',
|
||||||
|
login: 'Login',
|
||||||
|
register: 'Register',
|
||||||
|
logout: 'Logout',
|
||||||
|
total: 'Total',
|
||||||
|
open: 'Open',
|
||||||
|
complete: 'Complete',
|
||||||
|
tasks: 'Tasks',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
clear_completed: 'Clear Completed',
|
||||||
|
empty: 'No tasks yet.',
|
||||||
|
title: 'Title',
|
||||||
|
due: 'Due',
|
||||||
|
priority: 'Priority',
|
||||||
|
tags: 'Tags (comma)',
|
||||||
|
description: 'Description',
|
||||||
|
create_task: 'Create Task',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
account_hint: 'Create an account or login to sync tasks.',
|
||||||
|
credentials_required: 'Email and password required',
|
||||||
|
toast_login_success: 'Login successful',
|
||||||
|
toast_register_success: 'Register successful',
|
||||||
|
toast_logout_success: 'Logged out',
|
||||||
|
mark_complete: 'Complete',
|
||||||
|
undo: 'Undo',
|
||||||
|
priority_high: 'High',
|
||||||
|
priority_medium: 'Medium',
|
||||||
|
priority_low: 'Low',
|
||||||
|
delete: 'Delete',
|
||||||
|
edit: 'Edit',
|
||||||
|
edit_task: 'Edit Task',
|
||||||
|
save_task: 'Save Changes',
|
||||||
|
state_idle: 'Not connected',
|
||||||
|
state_connected: 'Connected',
|
||||||
|
state_auth_required: 'Auth required',
|
||||||
|
state_login_failed: 'Login failed',
|
||||||
|
state_register_failed: 'Register failed',
|
||||||
|
state_registered: 'Registered, now login',
|
||||||
|
state_token_missing: 'Token missing',
|
||||||
|
state_logged_out: 'Logged out',
|
||||||
|
state_create_failed: 'Failed to create task',
|
||||||
|
state_update_failed: 'Failed to update task',
|
||||||
|
state_restored: 'Session restored',
|
||||||
|
language: 'Language',
|
||||||
|
lang_en: 'English',
|
||||||
|
lang_zh: 'Chinese',
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
eyebrow: '效率中枢',
|
||||||
|
headline: 'SuperTodo 控制台',
|
||||||
|
subhead: '清晰规划、明确优先级、快速收尾,让任务流保持锋利。',
|
||||||
|
new_task: '新建任务',
|
||||||
|
account: '账户',
|
||||||
|
login: '登录',
|
||||||
|
register: '注册',
|
||||||
|
logout: '退出',
|
||||||
|
total: '总计',
|
||||||
|
open: '待办',
|
||||||
|
complete: '完成率',
|
||||||
|
tasks: '任务列表',
|
||||||
|
refresh: '刷新',
|
||||||
|
clear_completed: '清理已完成',
|
||||||
|
empty: '还没有任务。',
|
||||||
|
title: '标题',
|
||||||
|
due: '截止',
|
||||||
|
priority: '优先级',
|
||||||
|
tags: '标签 (逗号分隔)',
|
||||||
|
description: '描述',
|
||||||
|
create_task: '创建任务',
|
||||||
|
cancel: '取消',
|
||||||
|
email: '邮箱',
|
||||||
|
password: '密码',
|
||||||
|
account_hint: '登录或注册后同步任务。',
|
||||||
|
credentials_required: '请输入邮箱和密码',
|
||||||
|
toast_login_success: '登录成功',
|
||||||
|
toast_register_success: '注册成功',
|
||||||
|
toast_logout_success: '已退出登录',
|
||||||
|
mark_complete: '完成',
|
||||||
|
undo: '撤销',
|
||||||
|
priority_high: '高',
|
||||||
|
priority_medium: '中',
|
||||||
|
priority_low: '低',
|
||||||
|
delete: '删除',
|
||||||
|
edit: '编辑',
|
||||||
|
edit_task: '编辑任务',
|
||||||
|
save_task: '保存修改',
|
||||||
|
state_idle: '未连接',
|
||||||
|
state_connected: '已连接',
|
||||||
|
state_auth_required: '需要登录',
|
||||||
|
state_login_failed: '登录失败',
|
||||||
|
state_register_failed: '注册失败',
|
||||||
|
state_registered: '注册成功,请登录',
|
||||||
|
state_token_missing: '缺少令牌',
|
||||||
|
state_logged_out: '已退出',
|
||||||
|
state_create_failed: '创建任务失败',
|
||||||
|
state_update_failed: '更新任务失败',
|
||||||
|
state_restored: '会话已恢复',
|
||||||
|
language: '语言',
|
||||||
|
lang_en: '英文',
|
||||||
|
lang_zh: '中文',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = ref<LocaleKey>('zh')
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const sessionState = ref('Not connected')
|
const sessionState = ref<MessageKey>('state_idle')
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
|
const authForm = reactive({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
due_at: '',
|
due_at: '',
|
||||||
@@ -22,47 +194,135 @@ const form = reactive({
|
|||||||
tags: '',
|
tags: '',
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
const isAccountOpen = ref(false)
|
||||||
|
const isTaskOpen = ref(false)
|
||||||
|
const autoSyncId = ref<number | null>(null)
|
||||||
|
const editingTaskId = ref<number | string | null>(null)
|
||||||
|
const toasts = ref<Array<{ id: number; message: string; tone: 'success' | 'error' | 'info' }>>([])
|
||||||
|
|
||||||
function setSessionState(text: string) {
|
const isEditing = computed(() => editingTaskId.value !== null)
|
||||||
sessionState.value = text
|
|
||||||
|
const totalCount = computed(() => tasks.value.length)
|
||||||
|
const doneCount = computed(() => tasks.value.filter((task) => task.status === 'done').length)
|
||||||
|
const todoCount = computed(() => Math.max(totalCount.value - doneCount.value, 0))
|
||||||
|
const completionRate = computed(() =>
|
||||||
|
totalCount.value === 0 ? 0 : Math.round((doneCount.value / totalCount.value) * 100),
|
||||||
|
)
|
||||||
|
const sortedTasks = computed(() => {
|
||||||
|
return [...tasks.value].sort((a, b) => {
|
||||||
|
const pa = a.priority ?? 0
|
||||||
|
const pb = b.priority ?? 0
|
||||||
|
if (pa !== pb) return pa - pb
|
||||||
|
return String(a.id ?? '').localeCompare(String(b.id ?? ''))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const t = (key: MessageKey) => messages[locale.value][key] ?? key
|
||||||
|
|
||||||
|
function setSessionState(key: MessageKey) {
|
||||||
|
sessionState.value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushToast(message: string, tone: 'success' | 'error' | 'info' = 'info') {
|
||||||
|
const id = Date.now() + Math.floor(Math.random() * 1000)
|
||||||
|
toasts.value.push({ id, message, tone })
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||||
|
}, 3200)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeaders() {
|
function getHeaders() {
|
||||||
return {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: token.value ? `Bearer ${token.value}` : '',
|
|
||||||
}
|
}
|
||||||
|
if (token.value) {
|
||||||
|
headers.Authorization = `Bearer ${token.value}`
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToken(value: string) {
|
||||||
|
token.value = value
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem('todo_token', value)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('todo_token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register() {
|
||||||
|
if (!authForm.email || !authForm.password) {
|
||||||
|
pushToast(t('credentials_required'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: authForm.email,
|
||||||
|
password: authForm.password,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
pushToast(t('state_register_failed'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushToast(t('toast_register_success'), 'success')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
if (!authForm.email || !authForm.password) {
|
||||||
|
pushToast(t('credentials_required'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: authForm.email,
|
||||||
|
password: authForm.password,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setSessionState('Login failed')
|
pushToast(t('state_login_failed'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
token.value = data.token || 'demo-token'
|
saveToken(data.token || '')
|
||||||
setSessionState('Connected')
|
if (!token.value) {
|
||||||
|
pushToast(t('state_token_missing'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionState('state_connected')
|
||||||
|
isAccountOpen.value = false
|
||||||
|
pushToast(t('toast_login_success'), 'success')
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMeta(task: Task) {
|
async function logout() {
|
||||||
const parts: string[] = []
|
if (token.value) {
|
||||||
if (task.status) parts.push(task.status.toUpperCase())
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
if (task.due_at) parts.push(`Due: ${task.due_at}`)
|
method: 'POST',
|
||||||
if (task.priority) parts.push(`P${task.priority}`)
|
headers: getHeaders(),
|
||||||
if (task.tags && task.tags.length > 0) parts.push(task.tags.join(', '))
|
})
|
||||||
return parts.join(' | ')
|
}
|
||||||
|
saveToken('')
|
||||||
|
tasks.value = []
|
||||||
|
setSessionState('state_logged_out')
|
||||||
|
pushToast(t('toast_logout_success'), 'success')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
|
if (!token.value) {
|
||||||
|
setSessionState('state_idle')
|
||||||
|
tasks.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
const response = await fetch(`${API_BASE}/tasks`, {
|
const response = await fetch(`${API_BASE}/tasks`, {
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setSessionState('Auth required')
|
setSessionState('state_idle')
|
||||||
tasks.value = []
|
tasks.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -70,6 +330,36 @@ async function loadTasks() {
|
|||||||
tasks.value = Array.isArray(data) ? data : []
|
tasks.value = Array.isArray(data) ? data : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetTaskForm() {
|
||||||
|
form.title = ''
|
||||||
|
form.due_at = ''
|
||||||
|
form.priority = '1'
|
||||||
|
form.tags = ''
|
||||||
|
form.description = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewTask() {
|
||||||
|
editingTaskId.value = null
|
||||||
|
resetTaskForm()
|
||||||
|
isTaskOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditTask(task: Task) {
|
||||||
|
editingTaskId.value = task.id ?? null
|
||||||
|
form.title = task.title || ''
|
||||||
|
form.description = task.description || ''
|
||||||
|
form.due_at = toLocalInput(task.due_at || '')
|
||||||
|
form.priority = task.priority ? String(task.priority) : '1'
|
||||||
|
form.tags = Array.isArray(task.tags) ? task.tags.join(', ') : ''
|
||||||
|
isTaskOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTaskModal() {
|
||||||
|
isTaskOpen.value = false
|
||||||
|
editingTaskId.value = null
|
||||||
|
resetTaskForm()
|
||||||
|
}
|
||||||
|
|
||||||
async function createTask() {
|
async function createTask() {
|
||||||
const payload = {
|
const payload = {
|
||||||
title: form.title,
|
title: form.title,
|
||||||
@@ -82,22 +372,23 @@ async function createTask() {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/tasks`, {
|
const isUpdate = isEditing.value && editingTaskId.value !== null
|
||||||
method: 'POST',
|
const endpoint = isUpdate ? `${API_BASE}/tasks/${editingTaskId.value}` : `${API_BASE}/tasks`
|
||||||
|
const method = isUpdate ? 'PUT' : 'POST'
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method,
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setSessionState('Failed to create task')
|
pushToast(t(isUpdate ? 'state_update_failed' : 'state_create_failed'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
form.title = ''
|
resetTaskForm()
|
||||||
form.due_at = ''
|
editingTaskId.value = null
|
||||||
form.priority = '1'
|
isTaskOpen.value = false
|
||||||
form.tags = ''
|
|
||||||
form.description = ''
|
|
||||||
await loadTasks()
|
await loadTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,89 +432,240 @@ function toISO(value: string) {
|
|||||||
if (Number.isNaN(date.getTime())) return ''
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
return date.toISOString()
|
return date.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toLocalInput(value: string) {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const offset = date.getTimezoneOffset() * 60000
|
||||||
|
return new Date(date.getTime() - offset).toISOString().slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDue(value: string) {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}/${month}/${day} ${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocale() {
|
||||||
|
locale.value = locale.value === 'zh' ? 'en' : 'zh'
|
||||||
|
localStorage.setItem('todo_locale', locale.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('todo_token')
|
||||||
|
const savedLocale = localStorage.getItem('todo_locale') as LocaleKey | null
|
||||||
|
if (savedLocale === 'en' || savedLocale === 'zh') {
|
||||||
|
locale.value = savedLocale
|
||||||
|
}
|
||||||
|
if (saved) {
|
||||||
|
token.value = saved
|
||||||
|
setSessionState('state_restored')
|
||||||
|
loadTasks()
|
||||||
|
}
|
||||||
|
autoSyncId.value = window.setInterval(() => {
|
||||||
|
if (token.value) {
|
||||||
|
loadTasks()
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (autoSyncId.value !== null) {
|
||||||
|
window.clearInterval(autoSyncId.value)
|
||||||
|
autoSyncId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-orbit"></div>
|
<div class="bg-orbit"></div>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div>
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Distributed todo playground</p>
|
<p class="eyebrow">{{ t('eyebrow') }}</p>
|
||||||
<h1>Todo Control Room</h1>
|
<h1>{{ t('headline') }}</h1>
|
||||||
<p class="subhead">A bold, focused UI to drive your task workflow from a single cockpit.</p>
|
<p class="subhead">{{ t('subhead') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-panel">
|
||||||
|
<div class="status-board">
|
||||||
|
<div class="session-card">
|
||||||
|
<div>
|
||||||
|
<p class="session-state">{{ t(sessionState) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-controls">
|
||||||
|
<button
|
||||||
|
v-if="token"
|
||||||
|
class="btn ghost danger compact"
|
||||||
|
type="button"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
{{ t('logout') }}
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn ghost compact" type="button" @click="isAccountOpen = true">
|
||||||
|
{{ t('login') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn ghost compact" type="button" @click.stop="toggleLocale">
|
||||||
|
{{ locale === 'zh' ? t('lang_en') : t('lang_zh') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<p>{{ t('total') }}</p>
|
||||||
|
<h3>{{ totalCount }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<p>{{ t('open') }}</p>
|
||||||
|
<h3>{{ todoCount }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<p>{{ t('complete') }}</p>
|
||||||
|
<h3>{{ completionRate }}%</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-card">
|
|
||||||
<h2>Session</h2>
|
|
||||||
<p>{{ sessionState }}</p>
|
|
||||||
<button class="btn primary" type="button" @click="login">Login (demo)</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
|
<h2>{{ t('tasks') }}</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" type="button" @click="openNewTask">{{ t('new_task') }}</button>
|
||||||
|
<button class="btn ghost" type="button" @click="loadTasks">{{ t('refresh') }}</button>
|
||||||
|
<button class="btn ghost" type="button" @click="clearCompleted">{{ t('clear_completed') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-list">
|
||||||
|
<div v-if="tasks.length === 0" class="empty">{{ t('empty') }}</div>
|
||||||
|
<div v-else class="task-header">
|
||||||
|
<span class="task-spacer" aria-hidden="true"></span>
|
||||||
|
<span class="task-title">{{ t('title') }}</span>
|
||||||
|
<span class="task-due">{{ t('due') }}</span>
|
||||||
|
<span class="task-desc">{{ t('description') }}</span>
|
||||||
|
<span class="task-actions-placeholder" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
<article
|
||||||
|
v-for="task in sortedTasks"
|
||||||
|
:key="task.id ?? task.title"
|
||||||
|
class="task-card"
|
||||||
|
:class="{ completed: task.status === 'done' }"
|
||||||
|
>
|
||||||
|
<div class="task-main">
|
||||||
|
<div class="task-checker">
|
||||||
|
<input
|
||||||
|
class="task-check"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="task.status === 'done'"
|
||||||
|
@change="toggleStatus(task)"
|
||||||
|
:aria-label="task.status === 'done' ? t('undo') : t('mark_complete')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="task-content">
|
||||||
|
<div class="task-info">
|
||||||
|
<span class="task-title">{{ task.title || t('title') }}</span>
|
||||||
|
<span class="task-due">{{ formatDue(task.due_at || '') || '-' }}</span>
|
||||||
|
<span class="task-desc">{{ task.description || t('description') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="btn ghost toggle" type="button" @click="toggleStatus(task)">
|
||||||
|
{{ task.status === 'done' ? t('undo') : t('mark_complete') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn ghost" type="button" @click="openEditTask(task)">{{ t('edit') }}</button>
|
||||||
|
<button class="btn ghost danger" type="button" @click="deleteTask(task)">{{ t('delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div v-if="isAccountOpen" class="modal-backdrop" @click.self="isAccountOpen = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>New Task</h2>
|
<h3>{{ t('account') }}</h3>
|
||||||
<p>Create tasks quickly with priority and due date.</p>
|
<p class="modal-sub">{{ t('account_hint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn ghost" type="button" @click="isAccountOpen = false">{{ t('cancel') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="form-grid" @submit.prevent="createTask">
|
<div class="modal-body">
|
||||||
<label>
|
<label>
|
||||||
<span>Title</span>
|
<span>{{ t('email') }}</span>
|
||||||
|
<input v-model="authForm.email" type="email" placeholder="you@company.com" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t('password') }}</span>
|
||||||
|
<input v-model="authForm.password" type="password" placeholder="••••••" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn primary" type="button" @click="login">{{ t('login') }}</button>
|
||||||
|
<button class="btn ghost" type="button" @click="register">{{ t('register') }}</button>
|
||||||
|
<button class="btn ghost danger" type="button" @click="logout">{{ t('logout') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isTaskOpen" class="modal-backdrop" @click.self="closeTaskModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3>{{ isEditing ? t('edit_task') : t('new_task') }}</h3>
|
||||||
|
<p class="modal-sub">{{ isEditing ? t('save_task') : t('create_task') }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn ghost" type="button" @click="closeTaskModal">{{ t('cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
<form class="modal-body" @submit.prevent="createTask">
|
||||||
|
<label>
|
||||||
|
<span>{{ t('title') }}</span>
|
||||||
<input v-model="form.title" type="text" name="title" placeholder="Plan service split" required />
|
<input v-model="form.title" type="text" name="title" placeholder="Plan service split" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Due</span>
|
<span>{{ t('due') }}</span>
|
||||||
<input v-model="form.due_at" type="datetime-local" name="due_at" />
|
<input v-model="form.due_at" type="datetime-local" name="due_at" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Priority</span>
|
<span>{{ t('priority') }}</span>
|
||||||
<select v-model="form.priority" name="priority">
|
<select v-model="form.priority" name="priority">
|
||||||
<option value="1">High</option>
|
<option value="1">{{ t('priority_high') }}</option>
|
||||||
<option value="2">Medium</option>
|
<option value="2">{{ t('priority_medium') }}</option>
|
||||||
<option value="3">Low</option>
|
<option value="3">{{ t('priority_low') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Tags (comma)</span>
|
<span>{{ t('tags') }}</span>
|
||||||
<input v-model="form.tags" type="text" name="tags" placeholder="backend, microservices" />
|
<input v-model="form.tags" type="text" name="tags" placeholder="backend, microservices" />
|
||||||
</label>
|
</label>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
<span>Description</span>
|
<span>{{ t('description') }}</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Outline scope, list risks..."
|
placeholder="Outline scope, list risks..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn primary" type="submit">Create Task</button>
|
<div class="modal-actions">
|
||||||
|
<button class="btn primary" type="submit">
|
||||||
|
{{ isEditing ? t('save_task') : t('create_task') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn ghost" type="button" @click="closeTaskModal">{{ t('cancel') }}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<div class="toast-stack" aria-live="polite" aria-atomic="true">
|
||||||
<div class="panel-head">
|
<div v-for="toast in toasts" :key="toast.id" class="toast" :class="toast.tone">
|
||||||
<h2>Tasks</h2>
|
{{ toast.message }}
|
||||||
<div class="actions">
|
|
||||||
<button class="btn ghost" type="button" @click="loadTasks">Refresh</button>
|
|
||||||
<button class="btn ghost" type="button" @click="clearCompleted">Clear Completed</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-list">
|
|
||||||
<div v-if="tasks.length === 0" class="empty">No tasks yet.</div>
|
|
||||||
<article v-for="task in tasks" :key="task.id ?? task.title" class="task-card">
|
|
||||||
<div class="task-main">
|
|
||||||
<div>
|
|
||||||
<h3>{{ task.title || 'Untitled task' }}</h3>
|
|
||||||
<p class="meta">{{ buildMeta(task) }}</p>
|
|
||||||
<p class="desc">{{ task.description || '' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="badge">{{ task.status || 'todo' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="task-actions">
|
|
||||||
<button class="btn ghost toggle" type="button" @click="toggleStatus(task)">Toggle Status</button>
|
|
||||||
<button class="btn ghost delete" type="button" @click="deleteTask(task)">Delete</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f6f3ef;
|
--bg: #f0fdfa;
|
||||||
--bg-alt: #efe7de;
|
--bg-alt: #ccfbf1;
|
||||||
--ink: #1b1a17;
|
--ink: #134e4a;
|
||||||
--muted: #6c5f57;
|
--muted: #0f766e;
|
||||||
--accent: #d97706;
|
--primary: #0d9488;
|
||||||
--accent-dark: #a35503;
|
--primary-strong: #0f766e;
|
||||||
--card: #fff7ed;
|
--primary-soft: #ccfbf1;
|
||||||
--stroke: rgba(27, 26, 23, 0.1);
|
--accent: #f97316;
|
||||||
--shadow: 0 24px 60px rgba(27, 26, 23, 0.15);
|
--accent-strong: #ea580c;
|
||||||
|
--accent-soft: #ffedd5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--stroke: #99f6e4;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -19,8 +22,8 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
font-family: 'Fira Sans', sans-serif;
|
||||||
background: radial-gradient(circle at top, #fff2d9 0%, var(--bg) 40%, var(--bg-alt) 100%);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -29,74 +32,188 @@ body {
|
|||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
font-family: 'Syne', sans-serif;
|
font-family: 'Fira Sans', sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-orbit {
|
.bg-orbit {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: radial-gradient(circle at 20% 20%, rgba(217, 119, 6, 0.2), transparent 50%),
|
background: var(--bg);
|
||||||
radial-gradient(circle at 80% 10%, rgba(30, 64, 175, 0.15), transparent 45%),
|
|
||||||
radial-gradient(circle at 70% 80%, rgba(190, 24, 93, 0.12), transparent 60%);
|
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-orbit::before,
|
||||||
|
.bg-orbit::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 42%;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-orbit::before {
|
||||||
|
width: 520px;
|
||||||
|
height: 520px;
|
||||||
|
background: #a7f3d0;
|
||||||
|
top: -180px;
|
||||||
|
left: -120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-orbit::after {
|
||||||
|
width: 440px;
|
||||||
|
height: 440px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
bottom: -160px;
|
||||||
|
right: -120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 1080px;
|
max-width: 1120px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px 24px 80px;
|
padding: 56px 24px 88px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 32px;
|
gap: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 280px;
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||||
gap: 24px;
|
gap: 28px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.24em;
|
||||||
font-size: 0.72rem;
|
font-size: 0.7rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subhead {
|
.hero-copy h1 {
|
||||||
font-size: 1rem;
|
font-size: clamp(2.3rem, 3vw, 3.2rem);
|
||||||
color: var(--muted);
|
line-height: 1.05;
|
||||||
margin-top: 12px;
|
|
||||||
max-width: 42ch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.subhead {
|
||||||
background: var(--card);
|
font-size: 1.05rem;
|
||||||
border: 1px solid var(--stroke);
|
color: var(--muted);
|
||||||
border-radius: 18px;
|
margin-top: 12px;
|
||||||
padding: 20px;
|
max-width: 46ch;
|
||||||
box-shadow: var(--shadow);
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-board {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-board .session-card {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-state {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.compact {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-pill {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-pill.live {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card p {
|
.stat-card {
|
||||||
margin: 0;
|
background: var(--card);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
margin: 0 0 8px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: rgba(255, 255, 255, 0.6);
|
background: var(--card);
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
border-radius: 28px;
|
border-radius: 24px;
|
||||||
padding: 28px;
|
padding: 26px;
|
||||||
backdrop-filter: blur(6px);
|
animation: floatIn 0.2s ease both;
|
||||||
box-shadow: var(--shadow);
|
|
||||||
animation: floatIn 0.6s ease both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-head {
|
.panel-head {
|
||||||
@@ -139,7 +256,8 @@ h3 {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: #fff;
|
background: #f0fdfa;
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid textarea {
|
.form-grid textarea {
|
||||||
@@ -147,6 +265,13 @@ h3 {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-grid input:focus,
|
||||||
|
.form-grid select:focus,
|
||||||
|
.form-grid textarea:focus {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid .full {
|
.form-grid .full {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -158,28 +283,58 @@ h3 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--accent);
|
background: var(--primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 12px 24px rgba(217, 119, 6, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.ghost {
|
.btn.ghost {
|
||||||
background: transparent;
|
background: var(--primary-soft);
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid transparent;
|
||||||
color: var(--ink);
|
color: var(--primary-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn.danger {
|
||||||
transform: translateY(-2px);
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-list {
|
.task-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
--task-grid: 40px minmax(160px, 1.3fr) 220px minmax(240px, 2fr) 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--task-grid);
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header span {
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card {
|
.task-card {
|
||||||
@@ -190,42 +345,101 @@ h3 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
animation: riseIn 0.4s ease both;
|
animation: riseIn 0.2s ease both;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 12px 30px rgba(13, 148, 136, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-main {
|
.task-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--task-grid);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checker {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
align-items: flex-start;
|
min-width: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card h3 {
|
.task-check {
|
||||||
margin: 0 0 6px;
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card .meta {
|
.task-content {
|
||||||
font-size: 0.85rem;
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-info {
|
||||||
|
display: contents;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card .desc {
|
.task-desc {
|
||||||
margin: 6px 0 0;
|
color: var(--muted);
|
||||||
}
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
.badge {
|
white-space: nowrap;
|
||||||
padding: 6px 12px;
|
overflow: hidden;
|
||||||
border-radius: 999px;
|
text-overflow: ellipsis;
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
background: rgba(217, 119, 6, 0.15);
|
|
||||||
color: var(--accent-dark);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-actions {
|
.task-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card.completed {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-color: #5eead4;
|
||||||
|
box-shadow: 0 16px 36px rgba(13, 148, 136, 0.12);
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card.completed::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(167, 243, 208, 0.75) 0%,
|
||||||
|
rgba(167, 243, 208, 0.35) 55%,
|
||||||
|
rgba(236, 253, 245, 0) 100%
|
||||||
|
);
|
||||||
|
transform: translateX(-120%);
|
||||||
|
animation: sweepGreen 0.8s ease-out forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card.completed .task-main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -236,10 +450,122 @@ h3 {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 118, 110, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
padding: 20px;
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
animation: riseIn 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-sub {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body input,
|
||||||
|
.modal-body select,
|
||||||
|
.modal-body textarea {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #f0fdfa;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body textarea {
|
||||||
|
min-height: 90px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 14px 30px rgba(13, 148, 136, 0.18);
|
||||||
|
animation: toastIn 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
border-color: #5eead4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-color: #fca5a5;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
border-color: var(--stroke);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes floatIn {
|
@keyframes floatIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(24px);
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -250,7 +576,7 @@ h3 {
|
|||||||
@keyframes riseIn {
|
@keyframes riseIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(12px);
|
transform: translateY(6px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -258,12 +584,65 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes sweepGreen {
|
||||||
|
from {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.hero {
|
.hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checker {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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