From bc69ad4e3308c809dc1503bfee0e56b6e0993c70 Mon Sep 17 00:00:00 2001 From: wolves Date: Sun, 25 Jan 2026 18:03:15 +0800 Subject: [PATCH] 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. --- go.mod | 29 +- go.sum | 74 +- main.go | 647 ++++++++++++++---- vue/index.html | 2 +- vue/src/App.vue | 592 ++++++++++++++-- vue/src/assets/base.css | 86 --- vue/src/assets/logo.svg | 1 - vue/src/assets/main.css | 525 ++++++++++++-- vue/src/components/HelloWorld.vue | 41 -- vue/src/components/TheWelcome.vue | 95 --- vue/src/components/WelcomeItem.vue | 87 --- vue/src/components/icons/IconCommunity.vue | 7 - .../components/icons/IconDocumentation.vue | 7 - vue/src/components/icons/IconEcosystem.vue | 7 - vue/src/components/icons/IconSupport.vue | 7 - vue/src/components/icons/IconTooling.vue | 19 - 16 files changed, 1567 insertions(+), 659 deletions(-) delete mode 100644 vue/src/assets/base.css delete mode 100644 vue/src/assets/logo.svg delete mode 100644 vue/src/components/HelloWorld.vue delete mode 100644 vue/src/components/TheWelcome.vue delete mode 100644 vue/src/components/WelcomeItem.vue delete mode 100644 vue/src/components/icons/IconCommunity.vue delete mode 100644 vue/src/components/icons/IconDocumentation.vue delete mode 100644 vue/src/components/icons/IconEcosystem.vue delete mode 100644 vue/src/components/icons/IconSupport.vue delete mode 100644 vue/src/components/icons/IconTooling.vue diff --git a/go.mod b/go.mod index 12fd9f0..99afc2c 100644 --- a/go.mod +++ b/go.mod @@ -2,33 +2,52 @@ module wolves.top/todo go 1.25.5 -require github.com/gin-gonic/gin v1.10.0 +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/jackc/pgconn v1.14.3 + github.com/jackc/pgx/v5 v5.8.0 + github.com/redis/go-redis/v9 v9.17.2 + github.com/segmentio/kafka-go v0.4.50 + golang.org/x/crypto v0.36.0 +) require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.3 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.15.9 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7f08abb..441fac6 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,23 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -28,12 +37,37 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -45,43 +79,63 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc= +github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 1b317e7..6562532 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,26 @@ package main import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "log" "net/http" + "os" "strconv" - "sync" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/jackc/pgconn" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + "github.com/segmentio/kafka-go" + "golang.org/x/crypto/bcrypt" ) type Task struct { @@ -21,120 +35,224 @@ type Task struct { UpdatedAt time.Time `json:"updated_at"` } -type taskStore struct { - mu sync.Mutex - nextID int64 - items map[int64]Task -} - type User struct { - ID int64 `json:"id"` - Email string `json:"email"` - Password string `json:"-"` + ID int64 `json:"id"` + Email string `json:"email"` } -type authStore struct { - mu sync.Mutex - nextID int64 - users map[string]User - sessions map[string]int64 +type tokenManager struct { + secret []byte + ttl time.Duration } -func newTaskStore() *taskStore { - return &taskStore{ - nextID: 1, - items: make(map[int64]Task), +type postgresStore struct { + pool *pgxpool.Pool +} + +type tokenCache struct { + client *redis.Client + prefix string +} + +type taskEmitter struct { + writer *kafka.Writer + topic string +} + +func newTokenManager(secret string, ttl time.Duration) *tokenManager { + return &tokenManager{ + secret: []byte(secret), + ttl: ttl, } } -func newAuthStore() *authStore { - return &authStore{ - nextID: 1, - users: make(map[string]User), - sessions: make(map[string]int64), +func (t *tokenManager) Generate(user User) (string, error) { + payload := struct { + UserID int64 `json:"uid"` + Exp int64 `json:"exp"` + }{ + UserID: user.ID, + Exp: time.Now().Add(t.ttl).Unix(), } -} - -func (s *authStore) register(email, password string) (User, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - if _, exists := s.users[email]; exists { - return User{}, false + raw, err := json.Marshal(payload) + if err != nil { + return "", err } - user := User{ - ID: s.nextID, - Email: email, - Password: password, + encoded := base64.RawURLEncoding.EncodeToString(raw) + sig := t.sign(encoded) + return encoded + "." + sig, nil +} + +func (t *tokenManager) Validate(token string) (int64, bool) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return 0, false } - s.nextID++ - s.users[email] = user - return user, true -} - -func (s *authStore) login(email, password string) (string, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - user, ok := s.users[email] - if !ok || user.Password != password { - return "", false + payload, sig := parts[0], parts[1] + if !t.verify(payload, sig) { + return 0, false } - token := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(user.ID, 10) - s.sessions[token] = user.ID - return token, true -} - -func (s *authStore) validate(token string) bool { - s.mu.Lock() - defer s.mu.Unlock() - - _, ok := s.sessions[token] - return ok -} - -func (s *taskStore) list() []Task { - s.mu.Lock() - defer s.mu.Unlock() - - result := make([]Task, 0, len(s.items)) - for _, t := range s.items { - result = append(result, t) + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return 0, false } - return result + var data struct { + UserID int64 `json:"uid"` + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(raw, &data); err != nil { + return 0, false + } + if data.UserID == 0 || time.Now().Unix() > data.Exp { + return 0, false + } + return data.UserID, true } -func (s *taskStore) get(id int64) (Task, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - t, ok := s.items[id] - return t, ok +func (t *tokenManager) sign(payload string) string { + mac := hmac.New(sha256.New, t.secret) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } -func (s *taskStore) create(input Task) Task { - s.mu.Lock() - defer s.mu.Unlock() +func (t *tokenManager) verify(payload, signature string) bool { + expected := t.sign(payload) + return hmac.Equal([]byte(signature), []byte(expected)) +} - input.ID = s.nextID - s.nextID++ - now := time.Now().UTC() - input.CreatedAt = now - input.UpdatedAt = now +func newPostgresStore(ctx context.Context, url string) (*postgresStore, error) { + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + store := &postgresStore{pool: pool} + if err := store.initSchema(ctx); err != nil { + pool.Close() + return nil, err + } + return store, nil +} + +func (s *postgresStore) initSchema(ctx context.Context) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE TABLE IF NOT EXISTS tasks ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL, + due_at TEXT, + priority INT NOT NULL DEFAULT 0, + tags TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + )`, + } + for _, stmt := range statements { + if _, err := s.pool.Exec(ctx, stmt); err != nil { + return err + } + } + return nil +} + +func (s *postgresStore) Register(ctx context.Context, email, password string) (User, error) { + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return User{}, err + } + var id int64 + err = s.pool.QueryRow(ctx, `INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`, email, string(hashed)).Scan(&id) + if err != nil { + if isUniqueViolation(err) { + return User{}, errAlreadyExists + } + return User{}, err + } + return User{ID: id, Email: email}, nil +} + +func (s *postgresStore) Login(ctx context.Context, email, password string) (User, error) { + var user User + var hash string + err := s.pool.QueryRow(ctx, `SELECT id, email, password_hash FROM users WHERE email = $1`, email).Scan(&user.ID, &user.Email, &hash) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return User{}, errInvalidCredentials + } + return User{}, err + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return User{}, errInvalidCredentials + } + return user, nil +} + +func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error) { + rows, err := s.pool.Query(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY id DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []Task{} + for rows.Next() { + var task Task + var tags []string + if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt); err != nil { + return nil, err + } + task.Tags = tags + result = append(result, task) + } + return result, rows.Err() +} + +func (s *postgresStore) Get(ctx context.Context, userID, id int64) (Task, error) { + var task Task + var tags []string + err := s.pool.QueryRow(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 AND id = $2`, userID, id). + Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt) + if err != nil { + return Task{}, err + } + task.Tags = tags + return task, nil +} + +func (s *postgresStore) Create(ctx context.Context, userID int64, input Task) (Task, error) { if input.Status == "" { input.Status = "todo" } - s.items[input.ID] = input - return input + var tags []string + if input.Tags != nil { + tags = input.Tags + } + err := s.pool.QueryRow(ctx, ` + INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now()) + RETURNING id, created_at, updated_at`, + userID, input.Title, input.Description, input.Status, input.DueAt, input.Priority, tags, + ).Scan(&input.ID, &input.CreatedAt, &input.UpdatedAt) + if err != nil { + return Task{}, err + } + return input, nil } -func (s *taskStore) update(id int64, input Task) (Task, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - existing, ok := s.items[id] - if !ok { - return Task{}, false +func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task) (Task, error) { + existing, err := s.Get(ctx, userID, id) + if err != nil { + return Task{}, err } if input.Title != "" { existing.Title = input.Title @@ -154,25 +272,139 @@ func (s *taskStore) update(id int64, input Task) (Task, bool) { if input.Tags != nil { existing.Tags = input.Tags } - existing.UpdatedAt = time.Now().UTC() - s.items[id] = existing - return existing, true + var updatedAt time.Time + err = s.pool.QueryRow(ctx, ` + UPDATE tasks + SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, updated_at = now() + WHERE id = $7 AND user_id = $8 + RETURNING updated_at`, + existing.Title, existing.Description, existing.Status, existing.DueAt, existing.Priority, existing.Tags, id, userID, + ).Scan(&updatedAt) + if err != nil { + return Task{}, err + } + existing.UpdatedAt = updatedAt + return existing, nil } -func (s *taskStore) delete(id int64) bool { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.items[id]; !ok { - return false +func (s *postgresStore) Delete(ctx context.Context, userID, id int64) error { + result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return err } - delete(s.items, id) - return true + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +func (s *postgresStore) Close() { + if s.pool != nil { + s.pool.Close() + } +} + +func newTokenCache(addr, password string, db int) (*tokenCache, error) { + if strings.TrimSpace(addr) == "" { + return nil, nil + } + client := redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + return nil, err + } + return &tokenCache{client: client, prefix: "auth:token:"}, nil +} + +func (c *tokenCache) Save(ctx context.Context, token string, ttl time.Duration) error { + return c.client.Set(ctx, c.prefix+token, "1", ttl).Err() +} + +func (c *tokenCache) Delete(ctx context.Context, token string) error { + return c.client.Del(ctx, c.prefix+token).Err() +} + +func (c *tokenCache) Exists(ctx context.Context, token string) (bool, error) { + count, err := c.client.Exists(ctx, c.prefix+token).Result() + if err != nil { + return false, err + } + return count == 1, nil +} + +func newTaskEmitter(brokers []string, topic string) *taskEmitter { + if len(brokers) == 0 { + return nil + } + writer := &kafka.Writer{ + Addr: kafka.TCP(brokers...), + Topic: topic, + Balancer: &kafka.LeastBytes{}, + } + return &taskEmitter{writer: writer, topic: topic} +} + +func (e *taskEmitter) Emit(ctx context.Context, eventType string, task Task, userID int64) { + if e == nil || e.writer == nil { + return + } + payload := map[string]any{ + "type": eventType, + "task_id": task.ID, + "user_id": userID, + "status": task.Status, + "priority": task.Priority, + "at": time.Now().UTC().Format(time.RFC3339), + } + data, err := json.Marshal(payload) + if err != nil { + return + } + writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if err := e.writer.WriteMessages(writeCtx, kafka.Message{ + Key: []byte(strconv.FormatInt(task.ID, 10)), + Value: data, + }); err != nil { + log.Printf("kafka write failed: %v", err) + } +} + +func (e *taskEmitter) Close() { + if e == nil || e.writer == nil { + return + } + if err := e.writer.Close(); err != nil { + log.Printf("kafka close failed: %v", err) + } +} + +var ( + errAlreadyExists = errors.New("already exists") + errInvalidCredentials = errors.New("invalid credentials") +) + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return true + } + return false } func main() { - store := newTaskStore() - authStore := newAuthStore() + store, tokens, cache, emitter := buildDependencies() + defer store.Close() + if cache != nil { + defer cache.client.Close() + } + defer emitter.Close() + gin.SetMode(gin.DebugMode) router := gin.Default() router.RedirectTrailingSlash = false @@ -193,16 +425,6 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - router.GET("/", func(c *gin.Context) { - c.File("test/web/index.html") - }) - router.GET("/styles.css", func(c *gin.Context) { - c.File("test/web/styles.css") - }) - router.GET("/app.js", func(c *gin.Context) { - c.File("test/web/app.js") - }) - auth := router.Group("/api/v1/auth") { auth.POST("/register", func(c *gin.Context) { @@ -214,13 +436,18 @@ func main() { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + input.Email = strings.TrimSpace(input.Email) if input.Email == "" || input.Password == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"}) return } - user, ok := authStore.register(input.Email, input.Password) - if !ok { - c.JSON(http.StatusConflict, gin.H{"error": "user already exists"}) + user, err := store.Register(c.Request.Context(), input.Email, input.Password) + if err != nil { + if errors.Is(err, errAlreadyExists) { + c.JSON(http.StatusConflict, gin.H{"error": "user already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"}) return } c.JSON(http.StatusCreated, gin.H{"id": user.ID, "email": user.Email}) @@ -234,41 +461,90 @@ func main() { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + input.Email = strings.TrimSpace(input.Email) if input.Email == "" || input.Password == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"}) return } - token, ok := authStore.login(input.Email, input.Password) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + user, err := store.Login(c.Request.Context(), input.Email, input.Password) + if err != nil { + if errors.Is(err, errInvalidCredentials) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"}) return } + token, err := tokens.Generate(user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token generation failed"}) + return + } + if cache != nil { + if err := cache.Save(c.Request.Context(), token, tokens.ttl); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"}) + return + } + } c.JSON(http.StatusOK, gin.H{"token": token}) }) + auth.POST("/logout", func(c *gin.Context) { + token := extractBearerToken(c) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"}) + return + } + if _, ok := tokens.Validate(token); !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + if cache != nil { + if err := cache.Delete(c.Request.Context(), token); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"}) + return + } + } + c.Status(http.StatusNoContent) + }) } api := router.Group("/api/v1") api.Use(func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { + token := extractBearerToken(c) + if token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"}) return } - token := authHeader - if len(authHeader) > 7 && authHeader[:7] == "Bearer " { - token = authHeader[7:] - } - if token == "" || !authStore.validate(token) { + userID, ok := tokens.Validate(token) + if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } + if cache != nil { + exists, err := cache.Exists(c.Request.Context(), token) + if err != nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "token cache unavailable"}) + return + } + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + } + c.Set("user_id", userID) c.Next() }) tasks := api.Group("/tasks") { tasks.GET("", func(c *gin.Context) { - c.JSON(http.StatusOK, store.list()) + userID := c.GetInt64("user_id") + items, err := store.List(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tasks"}) + return + } + c.JSON(http.StatusOK, items) }) tasks.POST("", func(c *gin.Context) { var input Task @@ -276,7 +552,13 @@ func main() { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - created := store.create(input) + userID := c.GetInt64("user_id") + created, err := store.Create(c.Request.Context(), userID, input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"}) + return + } + emitter.Emit(c.Request.Context(), "task.created", created, userID) c.JSON(http.StatusCreated, created) }) tasks.GET(":id", func(c *gin.Context) { @@ -285,9 +567,14 @@ func main() { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } - task, ok := store.get(id) - if !ok { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + userID := c.GetInt64("user_id") + task, err := store.Get(c.Request.Context(), userID, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task"}) return } c.JSON(http.StatusOK, task) @@ -303,11 +590,17 @@ func main() { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - updated, ok := store.update(id, input) - if !ok { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + userID := c.GetInt64("user_id") + updated, err := store.Update(c.Request.Context(), userID, id, input) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"}) return } + emitter.Emit(c.Request.Context(), "task.updated", updated, userID) c.JSON(http.StatusOK, updated) }) tasks.DELETE(":id", func(c *gin.Context) { @@ -316,10 +609,16 @@ func main() { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } - if !store.delete(id) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + userID := c.GetInt64("user_id") + if err := store.Delete(c.Request.Context(), userID, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"}) return } + emitter.Emit(c.Request.Context(), "task.deleted", Task{ID: id}, userID) c.Status(http.StatusNoContent) }) } @@ -329,6 +628,78 @@ func main() { } } +func buildDependencies() (*postgresStore, *tokenManager, *tokenCache, *taskEmitter) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) + if dbURL == "" { + dbURL = "postgres://todo:todo@localhost:5432/todo?sslmode=disable" + } + store, err := newPostgresStore(ctx, dbURL) + if err != nil { + log.Fatalf("postgres connection failed: %v", err) + } + + secret := strings.TrimSpace(os.Getenv("AUTH_SECRET")) + if secret == "" { + secret = "dev-secret-change-me" + } + tokens := newTokenManager(secret, 24*time.Hour) + + redisAddr := strings.TrimSpace(os.Getenv("REDIS_ADDR")) + redisPassword := os.Getenv("REDIS_PASSWORD") + redisDB := parseEnvInt("REDIS_DB", 0) + cache, err := newTokenCache(redisAddr, redisPassword, redisDB) + if err != nil { + log.Fatalf("redis connection failed: %v", err) + } + + brokers := splitCSV(os.Getenv("KAFKA_BROKERS")) + topic := strings.TrimSpace(os.Getenv("KAFKA_TOPIC")) + if topic == "" { + topic = "todo.tasks" + } + emitter := newTaskEmitter(brokers, topic) + + return store, tokens, cache, emitter +} + func parseID(value string) (int64, error) { return strconv.ParseInt(value, 10, 64) } + +func extractBearerToken(c *gin.Context) string { + authHeader := strings.TrimSpace(c.GetHeader("Authorization")) + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimSpace(authHeader[7:]) + } + return authHeader +} + +func parseEnvInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func splitCSV(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item != "" { + result = append(result, item) + } + } + return result +} diff --git a/vue/index.html b/vue/index.html index 2fde181..307d935 100644 --- a/vue/index.html +++ b/vue/index.html @@ -4,7 +4,7 @@ - Todo Control Room + Todo Room
diff --git a/vue/src/App.vue b/vue/src/App.vue index cacdccf..c3ce475 100644 --- a/vue/src/App.vue +++ b/vue/src/App.vue @@ -1,5 +1,5 @@ diff --git a/vue/src/assets/base.css b/vue/src/assets/base.css deleted file mode 100644 index 8816868..0000000 --- a/vue/src/assets/base.css +++ /dev/null @@ -1,86 +0,0 @@ -/* color palette from */ -: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; -} diff --git a/vue/src/assets/logo.svg b/vue/src/assets/logo.svg deleted file mode 100644 index 7565660..0000000 --- a/vue/src/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vue/src/assets/main.css b/vue/src/assets/main.css index 9837f99..9b72cb4 100644 --- a/vue/src/assets/main.css +++ b/vue/src/assets/main.css @@ -1,16 +1,19 @@ -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Syne:wght@600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap'); :root { color-scheme: light; - --bg: #f6f3ef; - --bg-alt: #efe7de; - --ink: #1b1a17; - --muted: #6c5f57; - --accent: #d97706; - --accent-dark: #a35503; - --card: #fff7ed; - --stroke: rgba(27, 26, 23, 0.1); - --shadow: 0 24px 60px rgba(27, 26, 23, 0.15); + --bg: #f0fdfa; + --bg-alt: #ccfbf1; + --ink: #134e4a; + --muted: #0f766e; + --primary: #0d9488; + --primary-strong: #0f766e; + --primary-soft: #ccfbf1; + --accent: #f97316; + --accent-strong: #ea580c; + --accent-soft: #ffedd5; + --card: #ffffff; + --stroke: #99f6e4; } * { @@ -19,8 +22,8 @@ body { margin: 0; - font-family: 'Space Grotesk', sans-serif; - background: radial-gradient(circle at top, #fff2d9 0%, var(--bg) 40%, var(--bg-alt) 100%); + font-family: 'Fira Sans', sans-serif; + background: var(--bg); color: var(--ink); min-height: 100vh; overflow-x: hidden; @@ -29,74 +32,188 @@ body { h1, h2, h3 { - font-family: 'Syne', sans-serif; + font-family: 'Fira Sans', sans-serif; margin: 0; } .bg-orbit { position: fixed; inset: 0; - background: radial-gradient(circle at 20% 20%, rgba(217, 119, 6, 0.2), transparent 50%), - radial-gradient(circle at 80% 10%, rgba(30, 64, 175, 0.15), transparent 45%), - radial-gradient(circle at 70% 80%, rgba(190, 24, 93, 0.12), transparent 60%); + background: var(--bg); z-index: -1; + overflow: hidden; +} + +.bg-orbit::before, +.bg-orbit::after { + content: ''; + position: absolute; + border-radius: 42%; + opacity: 0.65; +} + +.bg-orbit::before { + width: 520px; + height: 520px; + background: #a7f3d0; + top: -180px; + left: -120px; +} + +.bg-orbit::after { + width: 440px; + height: 440px; + background: var(--accent-soft); + bottom: -160px; + right: -120px; } .shell { - max-width: 1080px; + max-width: 1120px; margin: 0 auto; - padding: 48px 24px 80px; + padding: 56px 24px 88px; display: flex; flex-direction: column; - gap: 32px; + gap: 28px; } .hero { display: grid; - grid-template-columns: minmax(0, 1fr) 280px; - gap: 24px; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 28px; align-items: stretch; } .eyebrow { text-transform: uppercase; - letter-spacing: 0.2em; - font-size: 0.72rem; + letter-spacing: 0.24em; + font-size: 0.7rem; color: var(--muted); margin: 0 0 12px; } -.subhead { - font-size: 1rem; - color: var(--muted); - margin-top: 12px; - max-width: 42ch; +.hero-copy h1 { + font-size: clamp(2.3rem, 3vw, 3.2rem); + line-height: 1.05; } -.status-card { - background: var(--card); - border: 1px solid var(--stroke); - border-radius: 18px; - padding: 20px; - box-shadow: var(--shadow); +.subhead { + font-size: 1.05rem; + color: var(--muted); + margin-top: 12px; + max-width: 46ch; +} + +.hero-actions { + display: flex; + gap: 12px; + margin-top: 20px; + flex-wrap: wrap; +} + +.hero-panel { display: flex; flex-direction: column; + gap: 16px; +} + +.status-board { + background: var(--card); + border: 1px solid var(--stroke); + border-radius: 22px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.session-card { + background: var(--card); + border: 1px solid var(--stroke); + border-radius: 20px; + padding: 18px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.status-board .session-card { + border: none; + padding: 0; +} + +.label { + margin: 0 0 6px; + color: var(--muted); + font-size: 0.85rem; +} + +.session-state { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.session-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.btn.compact { + padding: 6px 12px; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.session-pill { + padding: 8px 14px; + border-radius: 999px; + border: 1px solid var(--stroke); + background: var(--bg-alt); + color: var(--muted); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.session-pill.live { + background: var(--primary-soft); + color: var(--primary-strong); + border-color: var(--primary); +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } -.status-card p { - margin: 0; +.stat-card { + background: var(--card); + border: 1px solid var(--stroke); + border-radius: 18px; + padding: 14px 16px; +} + +.stat-card p { + margin: 0 0 8px; color: var(--muted); + font-size: 0.85rem; +} + +.stat-card h3 { + font-size: 1.4rem; } .panel { - background: rgba(255, 255, 255, 0.6); + background: var(--card); border: 1px solid var(--stroke); - border-radius: 28px; - padding: 28px; - backdrop-filter: blur(6px); - box-shadow: var(--shadow); - animation: floatIn 0.6s ease both; + border-radius: 24px; + padding: 26px; + animation: floatIn 0.2s ease both; } .panel-head { @@ -139,7 +256,8 @@ h3 { padding: 12px 14px; font-size: 0.95rem; font-family: inherit; - background: #fff; + background: #f0fdfa; + color: var(--ink); } .form-grid textarea { @@ -147,6 +265,13 @@ h3 { resize: vertical; } +.form-grid input:focus, +.form-grid select:focus, +.form-grid textarea:focus { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + .form-grid .full { grid-column: 1 / -1; } @@ -158,28 +283,58 @@ h3 { cursor: pointer; font-weight: 600; font-family: inherit; - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; } .btn.primary { - background: var(--accent); + background: var(--primary); color: #fff; - box-shadow: 0 12px 24px rgba(217, 119, 6, 0.3); } .btn.ghost { - background: transparent; - border: 1px solid var(--stroke); - color: var(--ink); + background: var(--primary-soft); + border: 1px solid transparent; + color: var(--primary-strong); } -.btn:hover { - transform: translateY(-2px); +.btn.danger { + background: var(--accent-soft); + color: var(--accent-strong); +} + +.btn.primary:hover { + background: var(--primary-strong); +} + +.btn.ghost:hover { + border-color: var(--primary); +} + +.btn.danger:hover { + border-color: var(--accent); } .task-list { display: grid; gap: 16px; + --task-grid: 40px minmax(160px, 1.3fr) 220px minmax(240px, 2fr) 240px; +} + +.task-header { + display: grid; + grid-template-columns: var(--task-grid); + align-items: center; + gap: 20px; + padding: 0 18px; + font-size: 0.8rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + text-align: left; +} + +.task-header span { + text-align: left; } .task-card { @@ -190,42 +345,101 @@ h3 { display: flex; flex-direction: column; gap: 14px; - animation: riseIn 0.4s ease both; + animation: riseIn 0.2s ease both; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.task-card:hover { + border-color: var(--primary); + box-shadow: 0 12px 30px rgba(13, 148, 136, 0.1); } .task-main { + display: grid; + grid-template-columns: var(--task-grid); + gap: 20px; + align-items: center; +} + +.task-checker { display: flex; - justify-content: space-between; - gap: 16px; - align-items: flex-start; + align-items: center; + gap: 10px; + min-width: 40px; } -.task-card h3 { - margin: 0 0 6px; +.task-check { + width: 18px; + height: 18px; + border-radius: 6px; + border: 1px solid var(--primary); + accent-color: var(--primary); + cursor: pointer; } -.task-card .meta { - font-size: 0.85rem; +.task-content { + display: contents; +} + +.task-info { + display: contents; + font-size: 0.9rem; + color: var(--ink); +} + +.task-title { + font-weight: 600; +} + +.task-due { color: var(--muted); + font-family: 'Fira Code', monospace; + white-space: nowrap; } -.task-card .desc { - margin: 6px 0 0; -} - -.badge { - padding: 6px 12px; - border-radius: 999px; - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.1em; - background: rgba(217, 119, 6, 0.15); - color: var(--accent-dark); +.task-desc { + color: var(--muted); + font-size: 0.85rem; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .task-actions { display: flex; gap: 12px; + align-items: center; + justify-content: flex-end; +} + +.task-card.completed { + position: relative; + overflow: hidden; + border-color: #5eead4; + box-shadow: 0 16px 36px rgba(13, 148, 136, 0.12); + background: #ecfdf5; +} + +.task-card.completed::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + rgba(167, 243, 208, 0.75) 0%, + rgba(167, 243, 208, 0.35) 55%, + rgba(236, 253, 245, 0) 100% + ); + transform: translateX(-120%); + animation: sweepGreen 0.8s ease-out forwards; + pointer-events: none; + z-index: 0; +} + +.task-card.completed .task-main { + position: relative; + z-index: 1; } .empty { @@ -236,10 +450,122 @@ h3 { color: var(--muted); } +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 118, 110, 0.2); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 20; +} + +.modal { + background: var(--card); + border-radius: 20px; + border: 1px solid var(--stroke); + padding: 20px; + width: min(560px, 92vw); + display: flex; + flex-direction: column; + gap: 16px; + animation: riseIn 0.2s ease both; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.modal-sub { + margin: 6px 0 0; + color: var(--muted); +} + +.modal-body { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.modal-body label { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 0.85rem; + color: var(--muted); +} + +.modal-body input, +.modal-body select, +.modal-body textarea { + border-radius: 12px; + border: 1px solid var(--stroke); + padding: 12px 14px; + font-size: 0.95rem; + font-family: inherit; + background: #f0fdfa; + color: var(--ink); +} + +.modal-body textarea { + min-height: 90px; + resize: vertical; +} + +.modal-body .full { + grid-column: 1 / -1; +} + +.modal-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.toast-stack { + position: fixed; + top: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 12px; + z-index: 40; +} + +.toast { + min-width: 220px; + max-width: 320px; + background: var(--card); + border: 1px solid var(--stroke); + border-radius: 14px; + padding: 12px 16px; + font-size: 0.9rem; + color: var(--ink); + box-shadow: 0 14px 30px rgba(13, 148, 136, 0.18); + animation: toastIn 0.2s ease both; +} + +.toast.success { + border-color: #5eead4; +} + +.toast.error { + border-color: #fca5a5; + color: #b91c1c; +} + +.toast.info { + border-color: var(--stroke); +} + @keyframes floatIn { from { opacity: 0; - transform: translateY(24px); + transform: translateY(8px); } to { opacity: 1; @@ -250,7 +576,7 @@ h3 { @keyframes riseIn { from { opacity: 0; - transform: translateY(12px); + transform: translateY(6px); } to { opacity: 1; @@ -258,12 +584,65 @@ h3 { } } +@keyframes sweepGreen { + from { + transform: translateX(-120%); + } + to { + transform: translateX(0); + } +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} + @media (max-width: 900px) { .hero { grid-template-columns: 1fr; } + .stat-grid { + grid-template-columns: 1fr; + } + .form-grid { grid-template-columns: 1fr; } + + .modal-body { + grid-template-columns: 1fr; + } + + .task-main { + grid-template-columns: 1fr; + gap: 12px; + } + + .task-checker { + min-width: 0; + } + + .task-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .task-header { + display: none; + } } diff --git a/vue/src/components/HelloWorld.vue b/vue/src/components/HelloWorld.vue deleted file mode 100644 index a2eabd1..0000000 --- a/vue/src/components/HelloWorld.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/vue/src/components/TheWelcome.vue b/vue/src/components/TheWelcome.vue deleted file mode 100644 index 8b731d9..0000000 --- a/vue/src/components/TheWelcome.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/vue/src/components/WelcomeItem.vue b/vue/src/components/WelcomeItem.vue deleted file mode 100644 index 6d7086a..0000000 --- a/vue/src/components/WelcomeItem.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/vue/src/components/icons/IconCommunity.vue b/vue/src/components/icons/IconCommunity.vue deleted file mode 100644 index 2dc8b05..0000000 --- a/vue/src/components/icons/IconCommunity.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/vue/src/components/icons/IconDocumentation.vue b/vue/src/components/icons/IconDocumentation.vue deleted file mode 100644 index 6d4791c..0000000 --- a/vue/src/components/icons/IconDocumentation.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/vue/src/components/icons/IconEcosystem.vue b/vue/src/components/icons/IconEcosystem.vue deleted file mode 100644 index c3a4f07..0000000 --- a/vue/src/components/icons/IconEcosystem.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/vue/src/components/icons/IconSupport.vue b/vue/src/components/icons/IconSupport.vue deleted file mode 100644 index 7452834..0000000 --- a/vue/src/components/icons/IconSupport.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/vue/src/components/icons/IconTooling.vue b/vue/src/components/icons/IconTooling.vue deleted file mode 100644 index 660598d..0000000 --- a/vue/src/components/icons/IconTooling.vue +++ /dev/null @@ -1,19 +0,0 @@ - -