diff --git a/go.mod b/go.mod index dd8b95b..2b4e343 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 - github.com/duke-git/lancet/v2 v2.0.2 + github.com/duke-git/lancet/v2 v2.0.4 github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac github.com/emersion/go-smtp v0.15.0 github.com/emvi/logbuch v1.2.0 @@ -20,21 +20,22 @@ require ( github.com/leandro-lugaresi/hub v1.1.1 github.com/lpar/gzipped/v2 v2.0.2 github.com/mitchellh/hashstructure/v2 v2.0.2 + github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e github.com/patrickmn/go-cache v2.1.0+incompatible github.com/satori/go.uuid v1.2.0 github.com/stretchr/testify v1.7.0 github.com/swaggo/swag v1.7.0 go.uber.org/atomic v1.9.0 - golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 + golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c gorm.io/driver/mysql v1.3.3 - gorm.io/driver/postgres v1.3.2 + gorm.io/driver/postgres v1.3.4 gorm.io/driver/sqlite v1.3.1 gorm.io/gorm v1.23.4 ) require ( - github.com/BurntSushi/toml v1.0.0 // indirect + github.com/BurntSushi/toml v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect @@ -45,6 +46,7 @@ require ( github.com/go-openapi/spec v0.20.2 // indirect github.com/go-openapi/swag v0.19.13 // indirect github.com/go-sql-driver/mysql v1.6.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.11.0 // indirect github.com/jackc/pgio v1.0.0 // indirect @@ -62,9 +64,10 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/stretchr/objx v0.2.0 // indirect + golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57 // indirect golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect - golang.org/x/sys v0.0.0-20220403020550-483a9cbc67c0 // indirect + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 7958992..347bdae 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69/go.mod h1:ML/ht github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU= github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= +github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= @@ -25,6 +27,8 @@ 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/duke-git/lancet/v2 v2.0.2 h1:U1GBY7DIhYs8Zg/+pGT4XKgKR8p4mDMT++afG6ykTrc= github.com/duke-git/lancet/v2 v2.0.2/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA= +github.com/duke-git/lancet/v2 v2.0.4 h1:IvMurTpL0cGhQmGPtkCge2eCkuiu3USQtglZJnKXxEo= +github.com/duke-git/lancet/v2 v2.0.4/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs= github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= @@ -61,6 +65,8 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= @@ -73,6 +79,7 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -93,6 +100,7 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W 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 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= @@ -165,6 +173,8 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g= +github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -230,6 +240,12 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o= golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA= +golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE= +golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -266,6 +282,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220403020550-483a9cbc67c0 h1:PgUUmg0gNMIPY2WafhL/oLyQGw+kdTNPlVWOjltpp3w= golang.org/x/sys v0.0.0-20220403020550-483a9cbc67c0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -313,6 +331,8 @@ gorm.io/driver/mysql v1.3.3 h1:jXG9ANrwBc4+bMvBcSl8zCfPBaVoPyBEBshA8dA93X8= gorm.io/driver/mysql v1.3.3/go.mod h1:ChK6AHbHgDCFZyJp0F+BmVGb06PSIoh9uVYKAlRbb2U= gorm.io/driver/postgres v1.3.2 h1:1URWk4lHWJkcudB+9bxOcNNt3uk5VfB8V2mzTPOqjRg= gorm.io/driver/postgres v1.3.2/go.mod h1:y0vEuInFKJtijuSGu9e5bs5hzzSzPK+LancpKpvbRBw= +gorm.io/driver/postgres v1.3.4 h1:evZ7plF+Bp+Lr1mO5NdPvd6M/N98XtwHixGB+y7fdEQ= +gorm.io/driver/postgres v1.3.4/go.mod h1:y0vEuInFKJtijuSGu9e5bs5hzzSzPK+LancpKpvbRBw= gorm.io/driver/sqlite v1.3.1 h1:bwfE+zTEWklBYoEodIOIBwuWHpnx52Z9zJFW5F33WLk= gorm.io/driver/sqlite v1.3.1/go.mod h1:wJx0hJspfycZ6myN38x1O/AqLtNS6c5o9TndewFbELg= gorm.io/gorm v1.23.1/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= diff --git a/main.go b/main.go index 119ee6e..fc8e950 100644 --- a/main.go +++ b/main.go @@ -182,6 +182,7 @@ func main() { metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService, metricsRepository) diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService) avatarHandler := api.NewAvatarHandler() + badgeHandler := api.NewBadgeHandler(userService, summaryService) // Compat Handlers wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService) @@ -240,6 +241,7 @@ func main() { metricsHandler.RegisterRoutes(apiRouter) diagnosticsHandler.RegisterRoutes(apiRouter) avatarHandler.RegisterRoutes(apiRouter) + badgeHandler.RegisterRoutes(apiRouter) wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter) wakatimeV1AllHandler.RegisterRoutes(apiRouter) wakatimeV1SummariesHandler.RegisterRoutes(apiRouter) diff --git a/models/compat/shields/v1/badge.go b/models/compat/shields/v1/badge.go index f9055ac..39f6dc1 100644 --- a/models/compat/shields/v1/badge.go +++ b/models/compat/shields/v1/badge.go @@ -8,8 +8,8 @@ import ( // https://shields.io/endpoint const ( - defaultLabel = "coding time" - defaultColor = "#2D3748" // not working + defaultLabel = "wakapi.dev" + defaultColor = "2F855A" ) type BadgeData struct { diff --git a/models/shared.go b/models/shared.go index ab75246..a38c30d 100644 --- a/models/shared.go +++ b/models/shared.go @@ -29,6 +29,11 @@ type Interval struct { End time.Time } +type KeyedInterval struct { + Interval + Key *IntervalKey +} + // CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format . (e.g. 1619335137.3324468) type CustomTime time.Time diff --git a/routes/api/avatar.go b/routes/api/avatar.go index 5faf49b..43b1cf3 100644 --- a/routes/api/avatar.go +++ b/routes/api/avatar.go @@ -5,7 +5,9 @@ import ( "github.com/gorilla/mux" lru "github.com/hashicorp/golang-lru" conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/utils" "net/http" + "time" ) type AvatarHandler struct { @@ -33,6 +35,10 @@ func (h *AvatarHandler) RegisterRoutes(router *mux.Router) { func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) { hash := mux.Vars(r)["hash"] + if utils.IsNoCache(r, 1*time.Hour) { + h.cache.Remove(hash) + } + if !h.cache.Contains(hash) { h.cache.Add(hash, avatars.MakeMaleAvatar(hash)) } diff --git a/routes/api/badge.go b/routes/api/badge.go new file mode 100644 index 0000000..3c69755 --- /dev/null +++ b/routes/api/badge.go @@ -0,0 +1,98 @@ +package api + +import ( + "fmt" + "github.com/duke-git/lancet/v2/maputil" + "github.com/duke-git/lancet/v2/slice" + "github.com/gorilla/mux" + conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/models" + v1 "github.com/muety/wakapi/models/compat/shields/v1" + routeutils "github.com/muety/wakapi/routes/utils" + "github.com/muety/wakapi/services" + "github.com/muety/wakapi/utils" + "github.com/narqo/go-badge" + "github.com/patrickmn/go-cache" + "net/http" + "time" +) + +type BadgeHandler struct { + config *conf.Config + cache *cache.Cache + userSrvc services.IUserService + summarySrvc services.ISummaryService +} + +func NewBadgeHandler(userService services.IUserService, summaryService services.ISummaryService) *BadgeHandler { + return &BadgeHandler{ + config: conf.Get(), + cache: cache.New(time.Hour, time.Hour), + userSrvc: userService, + summarySrvc: summaryService, + } +} + +func (h *BadgeHandler) RegisterRoutes(router *mux.Router) { + r := router.PathPrefix("/badge/{user}").Subrouter() + r.Methods(http.MethodGet).HandlerFunc(h.Get) +} + +func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) { + user, err := h.userSrvc.GetUserById(mux.Vars(r)["user"]) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + + interval, filters, err := routeutils.GetBadgeParams(r, user) + if err != nil { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(err.Error())) + return + } + + cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash()) + noCache := utils.IsNoCache(r, 1*time.Hour) + if cacheResult, ok := h.cache.Get(cacheKey); ok && !noCache { + respondSvg(w, cacheResult.([]byte)) + return + } + + params := &models.SummaryParams{ + From: interval.Start, + To: interval.End, + User: user, + Filters: filters, + } + + summary, err, status := routeutils.LoadUserSummaryByParams(h.summarySrvc, params) + if err != nil { + w.WriteHeader(status) + w.Write([]byte(err.Error())) + return + } + + badgeData := v1.NewBadgeDataFrom(summary) + if customLabel := r.URL.Query().Get("label"); customLabel != "" { + badgeData.Label = customLabel + } + if customColor := r.URL.Query().Get("color"); customColor != "" { + badgeData.Color = customColor + } + + if badgeData.Color[0:1] != "#" && !slice.Contain(maputil.Keys(badge.ColorScheme), badgeData.Color) { + badgeData.Color = "#" + badgeData.Color + } + + badgeSvg, err := badge.RenderBytes(badgeData.Label, badgeData.Message, badge.Color(badgeData.Color)) + h.cache.SetDefault(cacheKey, badgeSvg) + respondSvg(w, badgeSvg) +} + +func respondSvg(w http.ResponseWriter, data []byte) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "max-age=3600") + w.WriteHeader(http.StatusOK) + w.Write(data) +} diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go index fbc4162..48ff935 100644 --- a/routes/compat/shields/v1/badge.go +++ b/routes/compat/shields/v1/badge.go @@ -2,8 +2,8 @@ package v1 import ( "fmt" + routeutils "github.com/muety/wakapi/routes/utils" "net/http" - "regexp" "time" "github.com/gorilla/mux" @@ -15,11 +15,6 @@ import ( "github.com/patrickmn/go-cache" ) -const ( - intervalPattern = `interval:([a-z0-9_]+)` - entityFilterPattern = `(project|os|editor|language|machine|label):([_a-zA-Z0-9-\s\.]+)` -) - type BadgeHandler struct { config *conf.Config userSrvc services.IUserService @@ -53,77 +48,33 @@ func (h *BadgeHandler) RegisterRoutes(router *mux.Router) { // @Success 200 {object} v1.BadgeData // @Router /compat/shields/v1/{user}/{interval}/{filter} [get] func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) { - intervalReg := regexp.MustCompile(intervalPattern) - entityFilterReg := regexp.MustCompile(entityFilterPattern) - - var filterEntity, filterKey string - if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 { - filterEntity, filterKey = groups[1], groups[2] - } - - var interval = models.IntervalPast30Days - if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 { - if i, err := utils.ParseInterval(groups[1]); err == nil { - interval = i - } - } - - requestedUserId := mux.Vars(r)["user"] - user, err := h.userSrvc.GetUserById(requestedUserId) + user, err := h.userSrvc.GetUserById(mux.Vars(r)["user"]) if err != nil { w.WriteHeader(http.StatusNotFound) return } - _, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ()) - minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)) - // negative value means no limit - if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 { + interval, filters, err := routeutils.GetBadgeParams(r, user) + if err != nil { w.WriteHeader(http.StatusForbidden) - w.Write([]byte("requested time range too broad")) + w.Write([]byte(err.Error())) return } - var permitEntity bool - var filters *models.Filters - switch filterEntity { - case "project": - permitEntity = user.ShareProjects - filters = models.NewFiltersWith(models.SummaryProject, filterKey) - case "os": - permitEntity = user.ShareOSs - filters = models.NewFiltersWith(models.SummaryOS, filterKey) - case "editor": - permitEntity = user.ShareEditors - filters = models.NewFiltersWith(models.SummaryEditor, filterKey) - case "language": - permitEntity = user.ShareLanguages - filters = models.NewFiltersWith(models.SummaryLanguage, filterKey) - case "machine": - permitEntity = user.ShareMachines - filters = models.NewFiltersWith(models.SummaryMachine, filterKey) - case "label": - permitEntity = user.ShareLabels - filters = models.NewFiltersWith(models.SummaryLabel, filterKey) - // branches are intentionally omitted here, as only relevant in combination with a project filter - default: - permitEntity = true - filters = &models.Filters{} - } - - if !permitEntity { - w.WriteHeader(http.StatusForbidden) - w.Write([]byte("user did not opt in to share entity-specific data")) - return - } - - cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey) + cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash()) if cacheResult, ok := h.cache.Get(cacheKey); ok { utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData)) return } - summary, err, status := h.loadUserSummary(user, interval, filters) + params := &models.SummaryParams{ + From: interval.Start, + To: interval.End, + User: user, + Filters: filters, + } + + summary, err, status := routeutils.LoadUserSummaryByParams(h.summarySrvc, params) if err != nil { w.WriteHeader(status) w.Write([]byte(err.Error())) diff --git a/routes/compat/shields/v1/badge_test.go b/routes/compat/shields/v1/badge_test.go index 9bb2eec..88292df 100644 --- a/routes/compat/shields/v1/badge_test.go +++ b/routes/compat/shields/v1/badge_test.go @@ -30,7 +30,7 @@ func TestBadgeHandler_EntityPattern(t *testing.T) { {test: pathPrefix + "project:Anchr-Android_v2.0", key: "project", val: "Anchr-Android_v2.0"}, // all the way } - sut := regexp.MustCompile(entityFilterPattern) + sut := regexp.MustCompile(`(project|os|editor|language|machine|label):([^:?&/]+)`) // see entityFilterPattern in badge_utils.go for _, tc := range tests { var key, val string diff --git a/routes/utils/badge_utils.go b/routes/utils/badge_utils.go new file mode 100644 index 0000000..7b36f74 --- /dev/null +++ b/routes/utils/badge_utils.go @@ -0,0 +1,84 @@ +package utils + +import ( + "errors" + "github.com/muety/wakapi/models" + "github.com/muety/wakapi/utils" + "net/http" + "regexp" + "time" +) + +const ( + intervalPattern = `interval:([a-z0-9_]+)` + entityFilterPattern = `(project|os|editor|language|machine|label):([^:?&/]+)` +) + +var ( + intervalReg *regexp.Regexp + entityFilterReg *regexp.Regexp +) + +func init() { + intervalReg = regexp.MustCompile(intervalPattern) + entityFilterReg = regexp.MustCompile(entityFilterPattern) +} + +func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedInterval, *models.Filters, error) { + var filterEntity, filterKey string + if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 { + filterEntity, filterKey = groups[1], groups[2] + } + + var intervalKey = models.IntervalPast30Days + if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 { + if i, err := utils.ParseInterval(groups[1]); err == nil { + intervalKey = i + } + } + + _, rangeFrom, rangeTo := utils.ResolveIntervalTZ(intervalKey, requestedUser.TZ()) + interval := &models.KeyedInterval{ + Interval: models.Interval{Start: rangeFrom, End: rangeTo}, + Key: intervalKey, + } + + minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)) + // negative value means no limit + if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 { + return nil, nil, errors.New("requested time range too broad") + } + + var permitEntity bool + var filters *models.Filters + switch filterEntity { + case "project": + permitEntity = requestedUser.ShareProjects + filters = models.NewFiltersWith(models.SummaryProject, filterKey) + case "os": + permitEntity = requestedUser.ShareOSs + filters = models.NewFiltersWith(models.SummaryOS, filterKey) + case "editor": + permitEntity = requestedUser.ShareEditors + filters = models.NewFiltersWith(models.SummaryEditor, filterKey) + case "language": + permitEntity = requestedUser.ShareLanguages + filters = models.NewFiltersWith(models.SummaryLanguage, filterKey) + case "machine": + permitEntity = requestedUser.ShareMachines + filters = models.NewFiltersWith(models.SummaryMachine, filterKey) + case "label": + permitEntity = requestedUser.ShareLabels + filters = models.NewFiltersWith(models.SummaryLabel, filterKey) + // branches are intentionally omitted here, as only relevant in combination with a project filter + default: + // non-entity-specific request, just a general, in-total query + permitEntity = true + } + + if !permitEntity { + return nil, nil, errors.New("user did not opt in to share entity-specific data") + } + + return interval, filters, nil +} diff --git a/routes/utils/summary_utils.go b/routes/utils/summary_utils.go index 739434f..d07304c 100644 --- a/routes/utils/summary_utils.go +++ b/routes/utils/summary_utils.go @@ -1,7 +1,6 @@ package utils import ( - "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" @@ -9,24 +8,33 @@ import ( ) func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) { - user := middlewares.GetPrincipal(r) summaryParams, err := utils.ParseSummaryParams(r) if err != nil { return nil, err, http.StatusBadRequest } + return LoadUserSummaryByParams(ss, summaryParams) +} +func LoadUserSummaryByParams(ss services.ISummaryService, params *models.SummaryParams) (*models.Summary, error, int) { var retrieveSummary services.SummaryRetriever = ss.Retrieve - if summaryParams.Recompute { + if params.Recompute { retrieveSummary = ss.Summarize } - summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Filters, summaryParams.Recompute) + summary, err := ss.Aliased( + params.From, + params.To, + params.User, + retrieveSummary, + params.Filters, + params.Recompute, + ) if err != nil { return nil, err, http.StatusInternalServerError } - summary.FromTime = models.CustomTime(summary.FromTime.T().In(user.TZ())) - summary.ToTime = models.CustomTime(summary.ToTime.T().In(user.TZ())) + summary.FromTime = models.CustomTime(summary.FromTime.T().In(params.User.TZ())) + summary.ToTime = models.CustomTime(summary.ToTime.T().In(params.User.TZ())) return summary, nil, http.StatusOK } diff --git a/static/assets/css/app.dist.css b/static/assets/css/app.dist.css index 3bc2193..abe1771 100644 --- a/static/assets/css/app.dist.css +++ b/static/assets/css/app.dist.css @@ -1 +1 @@ -/*! tailwindcss v2.2.19 | MIT License | https://tailwindcss.com*//*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */html{-moz-tab-size:4;-o-tab-size:4;tab-size:4;line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji}hr{height:0;color:inherit}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}legend{padding:0}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}button{background-color:initial;background-image:none}fieldset,ol,ul{margin:0;padding:0}ol,ul{list-style:none}html{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{font-family:inherit;line-height:inherit}*,:after,:before{box-sizing:border-box;border:0 solid}hr{border-top-width:1px}img{border-style:solid}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input:-ms-input-placeholder,textarea:-ms-input-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button{cursor:pointer}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}button,input,optgroup,select,textarea{padding:0;line-height:inherit;color:inherit}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,:after,:before{--tw-border-opacity:1;border-color:rgba(229,231,235,var(--tw-border-opacity))}.absolute{position:absolute}.relative{position:relative}.top-0{top:0}.right-0{right:0}.z-10{z-index:10}.row-span-2{grid-row:span 2/span 2}.float-right{float:right}.m-0{margin:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-16{margin-top:4rem}.mt-20{margin-top:5rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mr-8{margin-right:2rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mb-10{margin-bottom:2.5rem}.mb-16{margin-bottom:4rem}.-mb-1{margin-bottom:-.25rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.-ml-1{margin-left:-.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-4{height:1rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-4{width:1rem}.w-12{width:3rem}.w-40{width:10rem}.w-1\/2{width:50%}.w-full{width:100%}.max-w-lg{max-width:32rem}.max-w-4xl{max-width:56rem}.max-w-screen-sm{max-width:640px}.max-w-screen-lg{max-width:1024px}.max-w-screen-xl{max-width:1280px}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.flex-shrink{flex-shrink:1}.flex-grow{flex-grow:1}@-webkit-keyframes spin{to{transform:rotate(1turn)}}@keyframes spin{to{transform:rotate(1turn)}}@-webkit-keyframes ping{75%,to{transform:scale(2);opacity:0}}@keyframes ping{75%,to{transform:scale(2);opacity:0}}@-webkit-keyframes pulse{50%{opacity:.5}}@keyframes pulse{50%{opacity:.5}}@-webkit-keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}@keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}.cursor-pointer{cursor:pointer}.cursor-not-allowed{cursor:not-allowed}.resize{resize:both}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-6{row-gap:1.5rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded-sm{border-radius:.125rem}.rounded{border-radius:.25rem}.rounded-md{border-radius:.375rem}.rounded-full{border-radius:9999px}.border-2{border-width:2px}.border-4{border-width:4px}.border{border-width:1px}.border-t{border-top-width:1px}.border-l{border-left-width:1px}.border-gray-700{--tw-border-opacity:1;border-color:rgba(55,65,81,var(--tw-border-opacity))}.border-gray-800{--tw-border-opacity:1;border-color:rgba(31,41,55,var(--tw-border-opacity))}.border-green-700{--tw-border-opacity:1;border-color:rgba(4,120,87,var(--tw-border-opacity))}.bg-transparent{background-color:initial}.bg-gray-800{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgba(17,24,39,var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgba(239,68,68,var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgba(16,185,129,var(--tw-bg-opacity))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgba(55,65,81,var(--tw-bg-opacity))}.focus\:bg-gray-800:focus,.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pt-10{padding-top:2.5rem}.pb-4{padding-bottom:1rem}.pb-10{padding-bottom:2.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-xs{font-size:.75rem;line-height:1rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-8xl{font-size:6rem;line-height:1}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-none{line-height:1}.leading-snug{line-height:1.375}.text-white{--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgba(156,163,175,var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgba(107,114,128,var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgba(75,85,99,var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgba(55,65,81,var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgba(220,38,38,var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgba(4,120,87,var(--tw-text-opacity))}.hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity))}.hover\:text-gray-400:hover{--tw-text-opacity:1;color:rgba(156,163,175,var(--tw-text-opacity))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgba(107,114,128,var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,:after,:before{--tw-shadow:0 0 #0000}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px 0 rgba(0,0,0,0.06)}.shadow,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,0.1),0 2px 4px -1px rgba(0,0,0,0.06)}.outline-none{outline:2px solid transparent;outline-offset:2px}*,:after,:before{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000}body{font-family:Source Sans\ 3,Roboto,Helvetica Neue,Helvetica,Arial,sans-serif}[v-cloak]{display:none}.bg-gray-850{background-color:#242b3a}.hover\:bg-gray-850:hover{--bg-opacity:1;background-color:#242b3a}.text-xxs{font-size:.65rem}.text-8xl{font-size:5rem;line-height:1.1}.imp\:cursor-not-allowed{cursor:not-allowed!important}.h1{margin:0;font-size:1.875rem;line-height:2.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.h1-subcaption{--tw-text-opacity:1;color:rgba(75,85,99,var(--tw-text-opacity))}.btn-default,.h1-subcaption{font-size:.875rem;line-height:1.25rem}.btn-default{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.btn-default:hover{--bg-opacity:1;background-color:#242b3a}.btn-disabled{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(75,85,99,var(--tw-text-opacity))}.btn-primary{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(4,120,87,var(--tw-bg-opacity))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgba(6,95,70,var(--tw-bg-opacity))}.btn-primary{padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.btn-danger{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(220,38,38,var(--tw-bg-opacity))}.btn-danger:hover{--tw-bg-opacity:1;background-color:rgba(185,28,28,var(--tw-bg-opacity))}.btn-danger{padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.input-default{width:100%;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem}.input-default:focus{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.input-default{padding:.5rem 1rem;--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity));outline:2px solid transparent;outline-offset:2px;background-color:#242b3a}.select-default{cursor:pointer;width:100%;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem}.select-default:focus{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.select-default{padding:.5rem 1rem;--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity));outline:2px solid transparent;outline-offset:2px;background-color:#242b3a}.menu-item{display:flex;cursor:pointer;align-items:center}.menu-item>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.menu-item{border-radius:.25rem;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600}.menu-item:hover{--bg-opacity:1;background-color:#242b3a}.submenu-item{border-radius:.25rem}.submenu-item:hover{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.submenu-item{padding:.25rem;text-align:right}.chip{margin-bottom:.25rem;display:inline-block;border-radius:.25rem;border-radius:9999px;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;background-color:#242b3a}.chip,.link{font-weight:600}.link{color:rgba(156,163,175,var(--tw-text-opacity))}.link,.link:hover{--tw-text-opacity:1}.link:hover{color:rgba(209,213,219,var(--tw-text-opacity))}::-webkit-calendar-picker-indicator{filter:invert(1);cursor:pointer}@media (min-width:640px){.sm\:inline-block{display:inline-block}.sm\:flex{display:flex}}@media (min-width:768px){.md\:mb-0{margin-bottom:0}.md\:flex{display:flex}.md\:w-1\/2{width:50%}.md\:w-1\/3{width:33.333333%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/4{width:75%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-nowrap{flex-wrap:nowrap}.md\:px-10{padding-left:2.5rem;padding-right:2.5rem}}@media (min-width:1024px){.lg\:inline-block{display:inline-block}.lg\:w-3\/4{width:75%}.lg\:px-24{padding-left:6rem;padding-right:6rem}} \ No newline at end of file +/*! tailwindcss v2.2.19 | MIT License | https://tailwindcss.com*//*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */html{-moz-tab-size:4;-o-tab-size:4;tab-size:4;line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji}hr{height:0;color:inherit}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}legend{padding:0}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}button{background-color:initial;background-image:none}fieldset,ol,ul{margin:0;padding:0}ol,ul{list-style:none}html{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{font-family:inherit;line-height:inherit}*,:after,:before{box-sizing:border-box;border:0 solid}hr{border-top-width:1px}img{border-style:solid}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input:-ms-input-placeholder,textarea:-ms-input-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button{cursor:pointer}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}button,input,optgroup,select,textarea{padding:0;line-height:inherit;color:inherit}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,:after,:before{--tw-border-opacity:1;border-color:rgba(229,231,235,var(--tw-border-opacity))}.absolute{position:absolute}.relative{position:relative}.top-0{top:0}.right-0{right:0}.z-10{z-index:10}.row-span-2{grid-row:span 2/span 2}.float-right{float:right}.m-0{margin:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-16{margin-top:4rem}.mt-20{margin-top:5rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mr-8{margin-right:2rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mb-10{margin-bottom:2.5rem}.mb-16{margin-bottom:4rem}.-mb-1{margin-bottom:-.25rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.-ml-1{margin-left:-.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-4{height:1rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-4{width:1rem}.w-12{width:3rem}.w-40{width:10rem}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-2\/3{width:66.666667%}.w-full{width:100%}.max-w-lg{max-width:32rem}.max-w-4xl{max-width:56rem}.max-w-screen-sm{max-width:640px}.max-w-screen-lg{max-width:1024px}.max-w-screen-xl{max-width:1280px}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.flex-shrink{flex-shrink:1}.flex-grow{flex-grow:1}@-webkit-keyframes spin{to{transform:rotate(1turn)}}@keyframes spin{to{transform:rotate(1turn)}}@-webkit-keyframes ping{75%,to{transform:scale(2);opacity:0}}@keyframes ping{75%,to{transform:scale(2);opacity:0}}@-webkit-keyframes pulse{50%{opacity:.5}}@keyframes pulse{50%{opacity:.5}}@-webkit-keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}@keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}.cursor-pointer{cursor:pointer}.cursor-not-allowed{cursor:not-allowed}.resize{resize:both}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-6{row-gap:1.5rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded-sm{border-radius:.125rem}.rounded{border-radius:.25rem}.rounded-md{border-radius:.375rem}.rounded-full{border-radius:9999px}.border-2{border-width:2px}.border-4{border-width:4px}.border{border-width:1px}.border-t{border-top-width:1px}.border-l{border-left-width:1px}.border-gray-700{--tw-border-opacity:1;border-color:rgba(55,65,81,var(--tw-border-opacity))}.border-gray-800{--tw-border-opacity:1;border-color:rgba(31,41,55,var(--tw-border-opacity))}.border-green-700{--tw-border-opacity:1;border-color:rgba(4,120,87,var(--tw-border-opacity))}.bg-transparent{background-color:initial}.bg-gray-800{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgba(17,24,39,var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgba(239,68,68,var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgba(16,185,129,var(--tw-bg-opacity))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgba(55,65,81,var(--tw-bg-opacity))}.focus\:bg-gray-800:focus,.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pt-10{padding-top:2.5rem}.pb-4{padding-bottom:1rem}.pb-10{padding-bottom:2.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-xs{font-size:.75rem;line-height:1rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-8xl{font-size:6rem;line-height:1}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-none{line-height:1}.leading-snug{line-height:1.375}.text-white{--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgba(156,163,175,var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgba(107,114,128,var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgba(75,85,99,var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgba(55,65,81,var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgba(220,38,38,var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgba(4,120,87,var(--tw-text-opacity))}.hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity))}.hover\:text-gray-400:hover{--tw-text-opacity:1;color:rgba(156,163,175,var(--tw-text-opacity))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgba(107,114,128,var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,:after,:before{--tw-shadow:0 0 #0000}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px 0 rgba(0,0,0,0.06)}.shadow,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,0.1),0 2px 4px -1px rgba(0,0,0,0.06)}.outline-none{outline:2px solid transparent;outline-offset:2px}*,:after,:before{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000}body{font-family:Source Sans\ 3,Roboto,Helvetica Neue,Helvetica,Arial,sans-serif}[v-cloak]{display:none}.bg-gray-850{background-color:#242b3a}.hover\:bg-gray-850:hover{--bg-opacity:1;background-color:#242b3a}.text-xxs{font-size:.65rem}.text-8xl{font-size:5rem;line-height:1.1}.imp\:cursor-not-allowed{cursor:not-allowed!important}.h1{margin:0;font-size:1.875rem;line-height:2.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.h1-subcaption{--tw-text-opacity:1;color:rgba(75,85,99,var(--tw-text-opacity))}.btn-default,.h1-subcaption{font-size:.875rem;line-height:1.25rem}.btn-default{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity));padding:.5rem 1rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.btn-default:hover{--bg-opacity:1;background-color:#242b3a}.btn-disabled{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(75,85,99,var(--tw-text-opacity))}.btn-primary{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(4,120,87,var(--tw-bg-opacity))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgba(6,95,70,var(--tw-bg-opacity))}.btn-primary{padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.btn-danger{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgba(220,38,38,var(--tw-bg-opacity))}.btn-danger:hover{--tw-bg-opacity:1;background-color:rgba(185,28,28,var(--tw-bg-opacity))}.btn-danger{padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}.input-default{width:100%;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem}.input-default:focus{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.input-default{padding:.5rem 1rem;--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity));outline:2px solid transparent;outline-offset:2px;background-color:#242b3a}.select-default{cursor:pointer;width:100%;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem}.select-default:focus{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.select-default{padding:.5rem 1rem;--tw-text-opacity:1;color:rgba(209,213,219,var(--tw-text-opacity));outline:2px solid transparent;outline-offset:2px;background-color:#242b3a}.menu-item{display:flex;cursor:pointer;align-items:center}.menu-item>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.menu-item{border-radius:.25rem;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600}.menu-item:hover{--bg-opacity:1;background-color:#242b3a}.submenu-item{border-radius:.25rem}.submenu-item:hover{--tw-bg-opacity:1;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.submenu-item{padding:.25rem;text-align:right}.chip{margin-bottom:.25rem;display:inline-block;border-radius:.25rem;border-radius:9999px;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;background-color:#242b3a}.chip,.link{font-weight:600}.link{color:rgba(156,163,175,var(--tw-text-opacity))}.link,.link:hover{--tw-text-opacity:1}.link:hover{color:rgba(209,213,219,var(--tw-text-opacity))}::-webkit-calendar-picker-indicator{filter:invert(1);cursor:pointer}@media (min-width:640px){.sm\:inline-block{display:inline-block}.sm\:flex{display:flex}}@media (min-width:768px){.md\:mb-0{margin-bottom:0}.md\:flex{display:flex}.md\:w-1\/2{width:50%}.md\:w-1\/3{width:33.333333%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/4{width:75%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-nowrap{flex-wrap:nowrap}.md\:px-10{padding-left:2.5rem;padding-right:2.5rem}}@media (min-width:1024px){.lg\:inline-block{display:inline-block}.lg\:w-3\/4{width:75%}.lg\:px-24{padding-left:6rem;padding-right:6rem}} \ No newline at end of file diff --git a/static/assets/css/app.dist.css.br b/static/assets/css/app.dist.css.br index 34b8ad3..2bc790a 100644 Binary files a/static/assets/css/app.dist.css.br and b/static/assets/css/app.dist.css.br differ diff --git a/static/assets/js/components/summary.js b/static/assets/js/components/summary.js index cbe9d49..25d873c 100644 --- a/static/assets/js/components/summary.js +++ b/static/assets/js/components/summary.js @@ -1,3 +1,9 @@ PetiteVue.createApp({ $delimiters: ['${', '}'], + get currentInterval() { + const urlParams = new URLSearchParams(window.location.search) + return urlParams.has('interval') + ? urlParams.get('interval') + : null + } }).mount('#summary-page') \ No newline at end of file diff --git a/utils/http.go b/utils/http.go index afbeb44..e2ab8b9 100644 --- a/utils/http.go +++ b/utils/http.go @@ -4,8 +4,24 @@ import ( "encoding/json" "github.com/muety/wakapi/config" "net/http" + "regexp" + "strconv" + "strings" + "time" ) +const ( + cacheMaxAgePattern = `max-age=(\d+)` +) + +var ( + cacheMaxAgeRe *regexp.Regexp +) + +func init() { + cacheMaxAgeRe = regexp.MustCompile(cacheMaxAgePattern) +} + func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) @@ -13,3 +29,16 @@ func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object inte config.Log().Request(r).Error("error while writing json response: %v", err) } } + +func IsNoCache(r *http.Request, cacheTtl time.Duration) bool { + cacheControl := r.Header.Get("cache-control") + if strings.Contains(cacheControl, "no-cache") { + return true + } + if match := cacheMaxAgeRe.FindStringSubmatch(cacheControl); match != nil && len(match) > 1 { + if maxAge, _ := strconv.Atoi(match[1]); time.Duration(maxAge)*time.Second <= cacheTtl { + return true + } + } + return false +} diff --git a/version.txt b/version.txt index 45674f1..fd06a92 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.3.3 \ No newline at end of file +2.3.4 \ No newline at end of file diff --git a/views/settings.tpl.html b/views/settings.tpl.html index 3de5d7a..1f3c637 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -525,41 +525,53 @@
- + - The integration with Shields.IO allows to generate badges for README pages or forums. To enable this feature, you need to grant public, unauthorized access to the respective endpoints. See Permissions.

- Only available on public instances, not on localhost. + This integration with allows to generate badges for README pages or forums. To enable this feature, you need to grant public, unauthorized access to the respective endpoints. See Permissions. Adapt the URL's label and color parameters for customized badges.

+ In addition, there is an endpoint compatible with Shields.IO to allow for even more customization (e.g. different styles). Only available on public instances, not on localhost.
{{ if ne .User.ShareDataMaxDays 0 }}
-
+
Shields.io badge
-
+
Shields.io badge
+
+ +
+
+ Shields.io badge +
+
diff --git a/views/summary.tpl.html b/views/summary.tpl.html index af5ecbb..c219c84 100644 --- a/views/summary.tpl.html +++ b/views/summary.tpl.html @@ -16,194 +16,201 @@ {{ if .User.HasData }} -
-
+
+
+
+
+ + {{ end }} + +
+ + {{ if .User.HasData }} + + {{ if not .IsProjectDetails }} + +
+
+ Total Time + {{ .TotalTime | duration }} +
+
+ Total Heartbeats + {{ .NumHeartbeats }} +
+
+ Top Project + {{ .MaxByToString 0 }} +
+
+ Top Language + {{ .MaxByToString 1 }} +
+
+ Top OS + {{ .MaxByToString 3 }} +
+
+ Top Editor + {{ .MaxByToString 2 }} +
+
+ {{ else }} +
+

Project "{{ .GetProjectFilter }}"

+
+

{{ .TotalTime | duration }}

+
+ Coding Time Badge +
+
+
+ {{ end }} + +
+ + + + +
+
+ Languages +
+ +
+
+ + +
+ +
+
+ Editors +
+ +
+
+ + +
+ + + + + +
+ +
+
+ + {{ else }} + +
+
+ User welcome illustration +
+

Welcome to Wakapi!

+

+ It looks like there is no data available for the specified time range.
If you logged in to Wakapi for the first time, see the setup instructions below on how to get started. +

+
+

Setup Instructions

+
+ # Step 1: Download WakaTime plugin for your IDE
+ # See: https://wakatime.com/plugins

+ + # Step 2: Set your ~/.wakatime.cfg to this:

+ + [settings]
+ api_url = %s/api
+ api_key = {{ .ApiKey }}

+ + # Step 3: Start coding and then check back here! +
+
+
+ + {{ end }} + +
-{{ end }} - -
- - {{ if .User.HasData }} - - {{ if not .IsProjectDetails }} - -
-
- Total Time - {{ .TotalTime | duration }} -
-
- Total Heartbeats - {{ .NumHeartbeats }} -
-
- Top Project - {{ .MaxByToString 0 }} -
-
- Top Language - {{ .MaxByToString 1 }} -
-
- Top OS - {{ .MaxByToString 3 }} -
-
- Top Editor - {{ .MaxByToString 2 }} -
-
- {{ else }} -
-

Project "{{ .GetProjectFilter }}"

-

{{ .TotalTime | duration }}

-
- {{ end }} - -
- - - - -
-
- Languages -
- -
-
- - -
- -
-
- Editors -
- -
-
- - -
- - - - - -
- -
-
- - {{ else }} - -
-
- User welcome illustration -
-

Welcome to Wakapi!

-

- It looks like there is no data available for the specified time range.
If you logged in to Wakapi for the first time, see the setup instructions below on how to get started. -

-
-

Setup Instructions

-
- # Step 1: Download WakaTime plugin for your IDE
- # See: https://wakatime.com/plugins

- - # Step 2: Set your ~/.wakatime.cfg to this:

- - [settings]
- api_url = %s/api
- api_key = {{ .ApiKey }}

- - # Step 3: Start coding and then check back here! -
-
-
- - {{ end }} - -
- {{ template "footer.tpl.html" . }} {{ template "foot.tpl.html" . }}