diff --git a/cmd/main.go b/cmd/main.go index 99b0761..04c8d88 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,8 +4,7 @@ import ( "log" "os" - "github.com/shurco/litecart/internal/app" - "github.com/shurco/litecart/internal/core" + app "github.com/shurco/litecart/internal" ) var ( @@ -15,7 +14,7 @@ var ( ) func main() { - flags := core.Flags{ + flags := app.Flags{ Serve: true, } diff --git a/go.sum b/go.sum index badd4fa..c1f978e 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,6 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= @@ -78,8 +76,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stripe/stripe-go/v74 v74.26.0 h1:enbhLtjKGWvJKcGM0f2CazqFSXzpHXcQ42nG2PNsWK0= -github.com/stripe/stripe-go/v74 v74.26.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/stripe/stripe-go/v74 v74.27.0 h1:e2uVDaWQeOp8i1c11BGIOOv6yN1+7b7QfYyiF9QzAa0= github.com/stripe/stripe-go/v74 v74.27.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -111,7 +107,6 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8= golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= diff --git a/golangci.yaml b/golangci.yaml deleted file mode 100644 index bc927b1..0000000 --- a/golangci.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# This file configures github.com/golangci/golangci-lint. - -run: - timeout: 10m - tests: true - # default is true. Enables skipping of directories: - # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ - skip-dirs-use-default: true - -linters: - disable-all: true - enable: - - govet # Suspicious constructs - - errcheck # Unchecked errors - - staticcheck # Static analysis checks - - gosimple # Simplify a code - - structcheck # Unused struct fields - - varcheck # Unused global variables and constants - - ineffassign # Unused assignments to existing variables - - deadcode # Unused code - - typecheck # Parses and type-checks Go code - - rowserrcheck # database/sql.Rows.Err() checked - - unconvert # Unnecessary type conversions - - goconst # Repeated strings that could be replaced by a constant - - gofmt # Whether the code was gofmt-ed - - goimports # Unused imports - - misspell # Misspelled English words in comments - - lll # Long lines - - unparam # Unused function parameters - - nakedret # Naked returns in functions greater than a specified function length (?) - - exportloopref # Unpinned variables in go programs - - nolintlint # Ill-formed or insufficient nolint directives - - depguard # Package imports are in a list of acceptable packages - - gosec # Security problems - - unused # Unused constants, variables, functions - - goprintffuncname # Printf-like functions are named with f at the end - - exportloopref # Exporting pointers for loop variables - - dupl # Code clone detection - - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. - -linters-settings: - gofmt: - simplify: true - goconst: - min-len: 3 - min-occurrences: 3 - -issues: - #new-from-rev: origin/main # report only new issues with reference to main branch - exclude-rules: - - path: _test\.go - linters: - - gosec - - unparam - - lll - include: - - EXC0012 # EXC0012 revive: Annoying issue about not having a comment. The rare codebase has such comments - - EXC0013 # EXC0013 revive: Annoying issue about not having a comment. The rare codebase has such comments - - EXC0014 # EXC0014 revive: Annoying issue about not having a comment. The rare codebase has such comments - - EXC0015 # EXC0015 revive: Annoying issue about not having a comment. The rare codebase has such comments \ No newline at end of file diff --git a/internal/app.go b/internal/app.go new file mode 100644 index 0000000..9853d56 --- /dev/null +++ b/internal/app.go @@ -0,0 +1,125 @@ +package app + +import ( + "embed" + "fmt" + "net" + "net/http" + "os" + "strings" + + "github.com/armon/go-proxyproto" + "github.com/rs/zerolog" + + "github.com/gofiber/contrib/fiberzerolog" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" + + "github.com/shurco/litecart/internal/queries" + "github.com/shurco/litecart/internal/routes" + "github.com/shurco/litecart/pkg/fsutil" +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +var ( + DevMode bool + MainDomain string +) + +type Flags struct { + Serve bool +} + +// NewApp is ... +func NewApp(flags Flags) error { + DevMode = true + log := zerolog.New(os.Stderr).With().Timestamp().Logger() + + if flags.Serve { + if err := fsutil.MkDirs(0775, "./uploads"); err != nil { + log.Err(err).Send() + return err + } + + if err := queries.InitQueries(embedMigrations); err != nil { + log.Err(err).Send() + return err + } + db := queries.DB() + + // web web server + var views *html.Engine + if DevMode { + views = html.New("../web/views", ".html") + views.Reload(true) + } else { + views = html.NewFileSystem(http.Dir("../web/views"), ".html") + } + views.Delims("{#", "#}") + views.AddFunc( + "arr", func(els ...any) []any { + return els + }, + ) + + app := fiber.New(fiber.Config{ + DisableStartupMessage: true, + Views: views, + }) + + app.Use(fiberzerolog.New(fiberzerolog.Config{ + Logger: &log, + })) + + app.Static("/", "../web/public") + app.Static("/uploads", "./uploads") + + app.Use(func(c *fiber.Ctx) error { + // init install + mainPath := strings.Split(c.Path(), "/")[1] + if !db.IsInstalled() { + if c.Path() != "/_/install" && mainPath != "api" { + return c.Redirect("/_/install") + } + } else if c.Path() == "/_/install" { + return c.Redirect("/_") + } + + // init main domain + if MainDomain == "" { + hostname := strings.Split(c.Hostname(), ".") + if len(hostname) > 2 { + hostname = hostname[1:] + } + MainDomain = strings.Join(hostname, ".") + } + + // check subdomain + if len(c.Subdomains()) > 0 { + if !db.CheckSubdomain(c.Subdomains()[0]) && !DevMode { + return c.Redirect(fmt.Sprintf("%s://%s", c.Protocol(), MainDomain), fiber.StatusMovedPermanently) + } + } + + return c.Next() + }) + + routes.SiteRoutes(app) + routes.AdminRoutes(app) + routes.ApiRoutes(app) + routes.NotFoundRoute(app) + + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", 8080)) + if err != nil { + log.Err(err).Send() + } + proxyListener := &proxyproto.Listener{Listener: ln} + if err := app.Listener(proxyListener); err != nil { + log.Err(err).Send() + } + } + + return nil +} diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 75c374e..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,73 +0,0 @@ -package app - -import ( - "embed" - "os" - - "github.com/rs/zerolog" - - "github.com/shurco/litecart/internal/app/queries" - "github.com/shurco/litecart/internal/core" - "github.com/shurco/litecart/pkg/fsutil" -) - -//go:embed migrations/*.sql -var embedMigrations embed.FS - -// Base is ... -type Base struct { - core.Core - DB *queries.Base -} - -// NewApp is ... -func NewApp(flags core.Flags) error { - app := &Base{} - app.DevMode = true - app.Log = zerolog.New(os.Stderr).With().Timestamp().Logger() - - if flags.Serve { - if err := fsutil.MkDirs(0775, "./uploads"); err != nil { - app.Log.Err(err).Send() - return err - } - - // init database - sqlite, err := app.InitDB("./lc_base/data.db", embedMigrations) - if err != nil { - app.Log.Err(err).Send() - return err - } - - app.DB = &queries.Base{ - AuthQueries: queries.AuthQueries{DB: sqlite}, - InstallQueries: queries.InstallQueries{DB: sqlite}, - SettingQueries: queries.SettingQueries{DB: sqlite}, - } - - // init jwt settings - app.JWT, err = app.DB.SettingJWT() - if err != nil { - app.Log.Err(err).Send() - return err - } - - // init stripe settings - app.Stripe, err = app.DB.SettingStripe() - if err != nil { - app.Log.Err(err).Send() - return err - } - if app.Stripe.SecretKey != "" { - app.Stripe.Client = core.InitStripeClient(app.Stripe.SecretKey) - } - - // web web server - if err := app.initWebServer(8080); err != nil { - app.Log.Err(err).Send() - return err - } - } - - return nil -} diff --git a/internal/app/controllers/auth.go b/internal/app/controllers/auth.go deleted file mode 100644 index 1bd847c..0000000 --- a/internal/app/controllers/auth.go +++ /dev/null @@ -1,84 +0,0 @@ -package controllers - -import ( - "time" - - "github.com/gofiber/fiber/v2" - "github.com/google/uuid" - - "github.com/shurco/litecart/internal/app/models" - "github.com/shurco/litecart/internal/app/queries" - "github.com/shurco/litecart/pkg/crypto" - "github.com/shurco/litecart/pkg/jwtutil" - "github.com/shurco/litecart/pkg/validator" - "github.com/shurco/litecart/pkg/webutil" -) - -func SignIn(q *queries.Base, j *jwtutil.Setting) fiber.Handler { - return func(c *fiber.Ctx) error { - request := new(models.SignIn) - - if err := c.BodyParser(request); err != nil { - return webutil.StatusBadRequest(c, err) - } - - if err := validator.Struct(request); err != nil { - return webutil.StatusBadRequest(c, err) - } - - passwordHash, err := q.GetPasswordByEmail(request.Email) - if err != nil { - return webutil.StatusBadRequest(c, err.Error()) - } - - compareUserPassword := crypto.ComparePasswords(passwordHash, request.Password) - if !compareUserPassword { - return webutil.StatusBadRequest(c, "wrong user email address or password") - } - - // Generate a new pair of access and refresh tokens. - userID := uuid.New() - expires := time.Now().Add(time.Hour * time.Duration(j.SecretExpireHours)).Unix() - token, err := jwtutil.GenerateNewToken(j.Secret, userID.String(), expires, nil) - if err != nil { - return webutil.Response(c, fiber.StatusInternalServerError, "Internal server error", err.Error()) - } - - // Add session record - if err := q.AddSession(userID.String(), "admin", expires); err != nil { - return webutil.Response(c, fiber.StatusInternalServerError, "Failed to save token", err.Error()) - } - - c.Cookie(&fiber.Cookie{ - Name: "token", - Value: token, - Expires: time.Now().Add(24 * time.Hour), - //HTTPOnly: true, - SameSite: "lax", - }) - - return webutil.StatusOK(c, "Token", token) - } -} - -func SignOut(q *queries.Base, secret string) fiber.Handler { - return func(c *fiber.Ctx) error { - claims, err := jwtutil.ExtractTokenMetadata(c, secret) - if err != nil { - return webutil.Response(c, fiber.StatusInternalServerError, "Internal server error", err.Error()) - } - - if err := q.DeleteSession(claims.ID); err != nil { - return webutil.Response(c, fiber.StatusInternalServerError, "Failed to delete token", err.Error()) - } - - c.Cookie(&fiber.Cookie{ - Name: "token", - Expires: time.Now().Add(-(time.Hour * 2)), - //HTTPOnly: true, - SameSite: "lax", - }) - - return c.SendStatus(fiber.StatusNoContent) - } -} diff --git a/internal/app/controllers/install.go b/internal/app/controllers/install.go deleted file mode 100644 index 34f4819..0000000 --- a/internal/app/controllers/install.go +++ /dev/null @@ -1,32 +0,0 @@ -package controllers - -import ( - "github.com/gofiber/fiber/v2" - - "github.com/shurco/litecart/internal/app/models" - "github.com/shurco/litecart/internal/app/queries" - "github.com/shurco/litecart/pkg/validator" - "github.com/shurco/litecart/pkg/webutil" -) - -// Install is ... -func Install(q *queries.Base) fiber.Handler { - return func(c *fiber.Ctx) error { - request := new(models.Install) - - if err := c.BodyParser(request); err != nil { - return webutil.StatusBadRequest(c, err) - } - - if err := validator.Struct(request); err != nil { - return webutil.StatusBadRequest(c, err) - } - - err := q.Install(request) - if err != nil { - return webutil.StatusBadRequest(c, err.Error()) - } - - return webutil.Response(c, fiber.StatusOK, "Cart installed", nil) - } -} diff --git a/internal/app/controllers/product.go b/internal/app/controllers/product.go deleted file mode 100644 index cbf0dab..0000000 --- a/internal/app/controllers/product.go +++ /dev/null @@ -1,43 +0,0 @@ -package controllers - -import ( - "github.com/gofiber/fiber/v2" - - "github.com/shurco/litecart/internal/app/queries" - "github.com/shurco/litecart/pkg/webutil" -) - -// ListProduct is ... -func ListProduct(q *queries.Base) fiber.Handler { - return func(c *fiber.Ctx) error { - return webutil.Response(c, fiber.StatusOK, "ListProduct", nil) - } -} - -// GetProduct is ... -func GetProduct(q *queries.Base) fiber.Handler { - return func(c *fiber.Ctx) error { - return webutil.Response(c, fiber.StatusOK, "GetProduct", nil) - } -} - -// AddProduct is ... -func AddProduct(q *queries.Base) fiber.Handler { - return func(c *fiber.Ctx) error { - return webutil.Response(c, fiber.StatusOK, "AddProduct", nil) - } -} - -// UpdateProduct is ... -func UpdateProduct(q *queries.Base) fiber.Handler { - return func(c *fiber.Ctx) error { - return webutil.Response(c, fiber.StatusOK, "UpdateProduct", nil) - } -} - -// DeleteProduct is ... -func DeleteProduct(q *queries.Base) fiber.Handler { - return func(c *fiber.Ctx) error { - return webutil.Response(c, fiber.StatusOK, "DeleteProduct", nil) - } -} diff --git a/internal/app/queries/queries.go b/internal/app/queries/queries.go deleted file mode 100644 index bb7a4d9..0000000 --- a/internal/app/queries/queries.go +++ /dev/null @@ -1,7 +0,0 @@ -package queries - -type Base struct { - AuthQueries - InstallQueries - SettingQueries -} diff --git a/internal/app/routes/api_routes.go b/internal/app/routes/api_routes.go deleted file mode 100644 index c0ae5cd..0000000 --- a/internal/app/routes/api_routes.go +++ /dev/null @@ -1,36 +0,0 @@ -package routes - -import ( - "github.com/gofiber/fiber/v2" - - "github.com/shurco/litecart/internal/app/controllers" - "github.com/shurco/litecart/internal/app/queries" - "github.com/shurco/litecart/internal/core" - "github.com/shurco/litecart/internal/core/middleware" - "github.com/shurco/litecart/pkg/webutil" -) - -// ApiRoutes is ... -func ApiRoutes(c *core.Core, q *queries.Base) { - route := c.Fiber.Group("/api") - route.Post("/install", controllers.Install(q)) - - sign := c.Fiber.Group("/api/sign") - sign.Post("/in", controllers.SignIn(q, c.JWT)) - sign.Post("/out", middleware.JWTProtected(c.JWT.Secret), controllers.SignOut(q, c.JWT.Secret)) - - product := c.Fiber.Group("/product", middleware.JWTProtected(c.JWT.Secret)) - product.Get("/", controllers.ListProduct(q)) - product.Get("/:id", controllers.GetProduct(q)) - product.Post("/", controllers.AddProduct(q)) - product.Patch("/", controllers.UpdateProduct(q)) - product.Delete("/", controllers.DeleteProduct(q)) - - route.Get("/cart", func(c *fiber.Ctx) error { - return webutil.Response(c, fiber.StatusOK, "Cart", "ok") - }) - - route.Post("/checkout-session", func(c *fiber.Ctx) error { - return webutil.Response(c, fiber.StatusOK, "Checkout Session", "ok") - }) -} diff --git a/internal/app/web.go b/internal/app/web.go deleted file mode 100644 index 758366d..0000000 --- a/internal/app/web.go +++ /dev/null @@ -1,97 +0,0 @@ -package app - -import ( - "fmt" - "net" - "net/http" - "strings" - - "github.com/armon/go-proxyproto" - "github.com/gofiber/contrib/fiberzerolog" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/template/html/v2" - - "github.com/shurco/litecart/internal/app/routes" -) - -const ( - webPort = 8080 - templateExt = ".html" - templateViews = "../web/views" - templatePublic = "../web/public" - uploadsPublic = "./uploads" -) - -func (b *Base) initWebServer(port int) error { - var views *html.Engine - if b.DevMode { - views = html.New(templateViews, templateExt) - views.Reload(true) - } else { - views = html.NewFileSystem(http.Dir(templateViews), templateExt) - } - views.Delims("{#", "#}") - views.AddFunc( - "arr", func(els ...any) []any { - return els - }, - ) - - b.Fiber = fiber.New(fiber.Config{ - DisableStartupMessage: true, - Views: views, - }) - - b.Fiber.Use(fiberzerolog.New(fiberzerolog.Config{ - Logger: &b.Log, - })) - - b.Fiber.Static("/", templatePublic) - b.Fiber.Static("/uploads", uploadsPublic) - - b.Fiber.Use(func(c *fiber.Ctx) error { - // init install - mainPath := strings.Split(c.Path(), "/")[1] - if !b.DB.IsInstalled() { - if c.Path() != "/_/install" && mainPath != "api" { - return c.Redirect("/_/install") - } - } else if c.Path() == "/_/install" { - return c.Redirect("/_") - } - - // init main domain - if b.MainDomain == "" { - hostname := strings.Split(c.Hostname(), ".") - if len(hostname) > 2 { - hostname = hostname[1:] - } - b.MainDomain = strings.Join(hostname, ".") - } - - // check subdomain - if len(c.Subdomains()) > 0 { - if !b.DB.CheckSubdomain(c.Subdomains()[0]) && !b.DevMode { - return c.Redirect(fmt.Sprintf("%s://%s", c.Protocol(), b.MainDomain), fiber.StatusMovedPermanently) - } - } - - return c.Next() - }) - - routes.SiteRoutes(&b.Core, b.DB) - routes.AdminRoutes(&b.Core, b.DB) - routes.ApiRoutes(&b.Core, b.DB) - routes.NotFoundRoute(b.Fiber) - - ln, err := net.Listen("tcp", fmt.Sprintf(":%d", webPort)) - if err != nil { - b.Log.Err(err).Send() - } - proxyListener := &proxyproto.Listener{Listener: ln} - if err := b.Fiber.Listener(proxyListener); err != nil { - b.Log.Err(err).Send() - } - - return nil -} diff --git a/internal/core/core.go b/internal/core/core.go deleted file mode 100644 index 67bf6d3..0000000 --- a/internal/core/core.go +++ /dev/null @@ -1,22 +0,0 @@ -package core - -import ( - "github.com/gofiber/fiber/v2" - "github.com/rs/zerolog" - "github.com/shurco/litecart/pkg/jwtutil" -) - -// Flags is ... -type Flags struct { - Serve bool -} - -// Core is ... -type Core struct { - DevMode bool - MainDomain string - Log zerolog.Logger - Fiber *fiber.App - Stripe *Stripe - JWT *jwtutil.Setting -} diff --git a/internal/core/stripe.go b/internal/core/stripe.go deleted file mode 100644 index 61260e9..0000000 --- a/internal/core/stripe.go +++ /dev/null @@ -1,17 +0,0 @@ -package core - -import ( - "github.com/stripe/stripe-go/v74/client" -) - -type Stripe struct { - SecretKey string - WebhookKey string - Client *client.API -} - -func InitStripeClient(secretKey string) *client.API { - client := &client.API{} - client.Init(secretKey, nil) - return client -} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..5410128 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "github.com/shurco/litecart/internal/models" + "github.com/shurco/litecart/internal/queries" + "github.com/shurco/litecart/pkg/crypto" + "github.com/shurco/litecart/pkg/jwtutil" + "github.com/shurco/litecart/pkg/validator" + "github.com/shurco/litecart/pkg/webutil" +) + +func SignIn(c *fiber.Ctx) error { + db := queries.DB() + request := new(models.SignIn) + + if err := c.BodyParser(request); err != nil { + return webutil.StatusBadRequest(c, err) + } + + if err := validator.Struct(request); err != nil { + return webutil.StatusBadRequest(c, err) + } + + passwordHash, err := db.GetPasswordByEmail(request.Email) + if err != nil { + return webutil.StatusBadRequest(c, err.Error()) + } + + compareUserPassword := crypto.ComparePasswords(passwordHash, request.Password) + if !compareUserPassword { + return webutil.StatusBadRequest(c, "wrong user email address or password") + } + + // Generate a new pair of access and refresh tokens. + settingJWT, err := db.SettingJWT() + if err != nil { + return err + } + + userID := uuid.New() + expires := time.Now().Add(time.Hour * time.Duration(settingJWT.SecretExpireHours)).Unix() + token, err := jwtutil.GenerateNewToken(settingJWT.Secret, userID.String(), expires, nil) + if err != nil { + return webutil.Response(c, fiber.StatusInternalServerError, "Internal server error", err.Error()) + } + + // Add session record + if err := db.AddSession(userID.String(), "admin", expires); err != nil { + return webutil.Response(c, fiber.StatusInternalServerError, "Failed to save token", err.Error()) + } + + c.Cookie(&fiber.Cookie{ + Name: "token", + Value: token, + Expires: time.Now().Add(24 * time.Hour), + //HTTPOnly: true, + SameSite: "lax", + }) + + return webutil.StatusOK(c, "Token", token) +} + +func SignOut(c *fiber.Ctx) error { + db := queries.DB() + settingJWT, err := db.SettingJWT() + if err != nil { + return err + } + + claims, err := jwtutil.ExtractTokenMetadata(c, settingJWT.Secret) + if err != nil { + return webutil.Response(c, fiber.StatusInternalServerError, "Internal server error", err.Error()) + } + + if err := db.DeleteSession(claims.ID); err != nil { + return webutil.Response(c, fiber.StatusInternalServerError, "Failed to delete token", err.Error()) + } + + c.Cookie(&fiber.Cookie{ + Name: "token", + Expires: time.Now().Add(-(time.Hour * 2)), + //HTTPOnly: true, + SameSite: "lax", + }) + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/internal/app/controllers/auth.http b/internal/handlers/auth.http similarity index 100% rename from internal/app/controllers/auth.http rename to internal/handlers/auth.http diff --git a/internal/handlers/install.go b/internal/handlers/install.go new file mode 100644 index 0000000..c8a8d47 --- /dev/null +++ b/internal/handlers/install.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/shurco/litecart/internal/models" + "github.com/shurco/litecart/internal/queries" + "github.com/shurco/litecart/pkg/validator" + "github.com/shurco/litecart/pkg/webutil" +) + +// Install is ... +func Install(c *fiber.Ctx) error { + db := queries.DB() + request := new(models.Install) + + if err := c.BodyParser(request); err != nil { + return webutil.StatusBadRequest(c, err) + } + + if err := validator.Struct(request); err != nil { + return webutil.StatusBadRequest(c, err) + } + + err := db.Install(request) + if err != nil { + return webutil.StatusBadRequest(c, err.Error()) + } + + return webutil.Response(c, fiber.StatusOK, "Cart installed", nil) +} diff --git a/internal/app/controllers/install.http b/internal/handlers/install.http similarity index 100% rename from internal/app/controllers/install.http rename to internal/handlers/install.http diff --git a/internal/handlers/product.go b/internal/handlers/product.go new file mode 100644 index 0000000..963ca66 --- /dev/null +++ b/internal/handlers/product.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/shurco/litecart/pkg/webutil" +) + +// ListProduct is ... +func ListProduct(c *fiber.Ctx) error { + return webutil.Response(c, fiber.StatusOK, "ListProduct", nil) +} + +// GetProduct is ... +func GetProduct(c *fiber.Ctx) error { + return webutil.Response(c, fiber.StatusOK, "GetProduct", nil) +} + +// AddProduct is ... +func AddProduct(c *fiber.Ctx) error { + return webutil.Response(c, fiber.StatusOK, "AddProduct", nil) +} + +// UpdateProduct is ... +func UpdateProduct(c *fiber.Ctx) error { + return webutil.Response(c, fiber.StatusOK, "UpdateProduct", nil) +} + +// DeleteProduct is ... +func DeleteProduct(c *fiber.Ctx) error { + return webutil.Response(c, fiber.StatusOK, "DeleteProduct", nil) +} diff --git a/internal/core/middleware/fiber.go b/internal/middleware/fiber.go similarity index 100% rename from internal/core/middleware/fiber.go rename to internal/middleware/fiber.go diff --git a/internal/core/middleware/jwt.go b/internal/middleware/jwt.go similarity index 76% rename from internal/core/middleware/jwt.go rename to internal/middleware/jwt.go index 3dfb50a..1ac2e41 100644 --- a/internal/core/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -3,16 +3,20 @@ package middleware import ( "strings" + jwtMiddleware "github.com/gofiber/contrib/jwt" "github.com/gofiber/fiber/v2" - "github.com/shurco/litecart/pkg/webutil" - jwtMiddleware "github.com/gofiber/contrib/jwt" + "github.com/shurco/litecart/internal/queries" + "github.com/shurco/litecart/pkg/webutil" ) // JWTProtected is ... -func JWTProtected(secret string) func(*fiber.Ctx) error { +func JWTProtected() func(*fiber.Ctx) error { + db := queries.DB() + settingJWT, _ := db.SettingJWT() + config := jwtMiddleware.Config{ - SigningKey: jwtMiddleware.SigningKey{Key: []byte(secret)}, + SigningKey: jwtMiddleware.SigningKey{Key: []byte(settingJWT.Secret)}, ContextKey: "jwt", ErrorHandler: jwtError, TokenLookup: "cookie:token", diff --git a/internal/app/migrations/20230714135923_init_db.sql b/internal/migrations/20230714135923_init_db.sql similarity index 100% rename from internal/app/migrations/20230714135923_init_db.sql rename to internal/migrations/20230714135923_init_db.sql diff --git a/internal/app/models/auth.go b/internal/models/auth.go similarity index 100% rename from internal/app/models/auth.go rename to internal/models/auth.go diff --git a/internal/app/models/core.go b/internal/models/core.go similarity index 100% rename from internal/app/models/core.go rename to internal/models/core.go diff --git a/internal/app/models/install.go b/internal/models/install.go similarity index 100% rename from internal/app/models/install.go rename to internal/models/install.go diff --git a/internal/app/models/products.go b/internal/models/products.go similarity index 100% rename from internal/app/models/products.go rename to internal/models/products.go diff --git a/internal/app/queries/auth.go b/internal/queries/auth.go similarity index 100% rename from internal/app/queries/auth.go rename to internal/queries/auth.go diff --git a/internal/app/queries/install.go b/internal/queries/install.go similarity index 96% rename from internal/app/queries/install.go rename to internal/queries/install.go index b18f975..376399b 100644 --- a/internal/app/queries/install.go +++ b/internal/queries/install.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/shurco/litecart/internal/app/models" + "github.com/shurco/litecart/internal/models" "github.com/shurco/litecart/pkg/crypto" ) diff --git a/internal/app/products.go b/internal/queries/products.go similarity index 98% rename from internal/app/products.go rename to internal/queries/products.go index b09cee9..711ae6c 100644 --- a/internal/app/products.go +++ b/internal/queries/products.go @@ -1,4 +1,4 @@ -package app +package queries /* func (b *Base) GetStripeProducts() []models.Product { diff --git a/internal/core/db.go b/internal/queries/queries.go similarity index 65% rename from internal/core/db.go rename to internal/queries/queries.go index 3284292..4fabaff 100644 --- a/internal/core/db.go +++ b/internal/queries/queries.go @@ -1,8 +1,9 @@ -package core +package queries import ( - "database/sql" "embed" + + "database/sql" "fmt" "github.com/pressly/goose/v3" @@ -10,7 +11,15 @@ import ( _ "modernc.org/sqlite" ) -func (b *Core) InitDB(dbPath string, migrations embed.FS) (*sql.DB, error) { +var db *Base + +type Base struct { + AuthQueries + InstallQueries + SettingQueries +} + +func InitDB(dbPath string, migrations embed.FS) (*sql.DB, error) { if !fsutil.IsFile(dbPath) { // create db if _, err := fsutil.OpenFile(dbPath, fsutil.FsCWFlags, 0666); err != nil { @@ -45,3 +54,22 @@ func Migrate(dbPath string, migrations embed.FS) error { } return nil } + +func InitQueries(embed embed.FS) error { + // init database + sqlite, err := InitDB("./lc_base/data.db", embed) + if err != nil { + return err + } + + db = &Base{ + AuthQueries: AuthQueries{DB: sqlite}, + InstallQueries: InstallQueries{DB: sqlite}, + SettingQueries: SettingQueries{DB: sqlite}, + } + return nil +} + +func DB() *Base { + return db +} diff --git a/internal/app/queries/setting.go b/internal/queries/setting.go similarity index 93% rename from internal/app/queries/setting.go rename to internal/queries/setting.go index 9f27420..dbc1067 100644 --- a/internal/app/queries/setting.go +++ b/internal/queries/setting.go @@ -4,14 +4,20 @@ import ( "database/sql" "strconv" - "github.com/shurco/litecart/internal/core" "github.com/shurco/litecart/pkg/jwtutil" + "github.com/stripe/stripe-go/v74/client" ) type SettingQueries struct { *sql.DB } +type Stripe struct { + SecretKey string + WebhookKey string + Client *client.API +} + // IsInstalled is ... func (q *SettingQueries) IsInstalled() bool { var installed bool @@ -97,8 +103,8 @@ func (q *SettingQueries) SettingJWT() (*jwtutil.Setting, error) { } // SettingStripe is ... -func (q *SettingQueries) SettingStripe() (*core.Stripe, error) { - settings := &core.Stripe{} +func (q *SettingQueries) SettingStripe() (*Stripe, error) { + settings := &Stripe{} query := `SELECT "key", "value" FROM "setting" WHERE "key" IN (?, ?)` rows, err := q.DB.Query(query, "stripe_secret_key", "stripe_webhook_secret_key") diff --git a/internal/app/routes/admin_routes.go b/internal/routes/admin_routes.go similarity index 53% rename from internal/app/routes/admin_routes.go rename to internal/routes/admin_routes.go index 03bda33..2888954 100644 --- a/internal/app/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -3,14 +3,12 @@ package routes import ( "github.com/gofiber/fiber/v2" - "github.com/shurco/litecart/internal/app/queries" - "github.com/shurco/litecart/internal/core" - "github.com/shurco/litecart/internal/core/middleware" + "github.com/shurco/litecart/internal/middleware" ) // AdminRoutes is ... -func AdminRoutes(c *core.Core, q *queries.Base) { - route := c.Fiber.Group("/_") +func AdminRoutes(c *fiber.App) { + route := c.Group("/_") route.Get("/install", func(c *fiber.Ctx) error { return c.Render("admin/install", nil, "admin/layouts/clear") @@ -20,36 +18,39 @@ func AdminRoutes(c *core.Core, q *queries.Base) { return c.Render("admin/signin", nil, "admin/layouts/clear") }) - route.Get("/", middleware.JWTProtected(c.JWT.Secret), func(c *fiber.Ctx) error { + route.Get("/", middleware.JWTProtected(), func(c *fiber.Ctx) error { return c.Render("admin/index", fiber.Map{ "Title": "Hello, World!", }, "admin/layouts/main") }) - product := route.Group("/products", middleware.JWTProtected(c.JWT.Secret)) - product.Get("/", middleware.JWTProtected(c.JWT.Secret), func(c *fiber.Ctx) error { + // product section + product := route.Group("/products", middleware.JWTProtected()) + product.Get("/", middleware.JWTProtected(), func(c *fiber.Ctx) error { return c.Render("admin/products", fiber.Map{ "Menu": "products", }, "admin/layouts/main") }) - product.Get("/add", middleware.JWTProtected(c.JWT.Secret), func(c *fiber.Ctx) error { + product.Get("/add", middleware.JWTProtected(), func(c *fiber.Ctx) error { return c.Render("admin/products_add", fiber.Map{ "Menu": "products", }, "admin/layouts/main") }) - product.Get("/update", middleware.JWTProtected(c.JWT.Secret), func(c *fiber.Ctx) error { + product.Get("/update", middleware.JWTProtected(), func(c *fiber.Ctx) error { return c.Render("admin/products_update", fiber.Map{ "Menu": "products", }, "admin/layouts/main") }) - route.Get("/invoices", middleware.JWTProtected(c.JWT.Secret), func(c *fiber.Ctx) error { + // invoice section + route.Get("/invoices", middleware.JWTProtected(), func(c *fiber.Ctx) error { return c.Render("admin/invoices", fiber.Map{ "Menu": "invoices", }, "admin/layouts/main") }) - route.Get("/settings", middleware.JWTProtected(c.JWT.Secret), func(c *fiber.Ctx) error { + // setting section + route.Get("/settings", middleware.JWTProtected(), func(c *fiber.Ctx) error { return c.Render("admin/settings", fiber.Map{ "Menu": "settings", }, "admin/layouts/main") diff --git a/internal/routes/api_routes.go b/internal/routes/api_routes.go new file mode 100644 index 0000000..f6c750e --- /dev/null +++ b/internal/routes/api_routes.go @@ -0,0 +1,34 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/shurco/litecart/internal/handlers" + "github.com/shurco/litecart/internal/middleware" + "github.com/shurco/litecart/pkg/webutil" +) + +// ApiRoutes is ... +func ApiRoutes(c *fiber.App) { + route := c.Group("/api") + route.Post("/install", handlers.Install) + + sign := c.Group("/api/sign") + sign.Post("/in", handlers.SignIn) + sign.Post("/out", middleware.JWTProtected(), handlers.SignOut) + + product := c.Group("/product", middleware.JWTProtected()) + product.Get("/", handlers.ListProduct) + product.Get("/:id", handlers.GetProduct) + product.Post("/", handlers.AddProduct) + product.Patch("/", handlers.UpdateProduct) + product.Delete("/", handlers.DeleteProduct) + + route.Get("/cart", func(c *fiber.Ctx) error { + return webutil.Response(c, fiber.StatusOK, "Cart", "ok") + }) + + route.Post("/checkout-session", func(c *fiber.Ctx) error { + return webutil.Response(c, fiber.StatusOK, "Checkout Session", "ok") + }) +} diff --git a/internal/app/routes/not_found_route.go b/internal/routes/not_found_route.go similarity index 100% rename from internal/app/routes/not_found_route.go rename to internal/routes/not_found_route.go diff --git a/internal/app/routes/site_routes.go b/internal/routes/site_routes.go similarity index 75% rename from internal/app/routes/site_routes.go rename to internal/routes/site_routes.go index 6d992e8..38c42ab 100644 --- a/internal/app/routes/site_routes.go +++ b/internal/routes/site_routes.go @@ -2,14 +2,12 @@ package routes import ( "github.com/gofiber/fiber/v2" - "github.com/shurco/litecart/internal/app/queries" - "github.com/shurco/litecart/internal/core" "github.com/shurco/litecart/pkg/webutil" ) // SiteRoutes is ... -func SiteRoutes(c *core.Core, q *queries.Base) { - route := c.Fiber.Group("/") +func SiteRoutes(c *fiber.App) { + route := c.Group("/") route.Get("/", func(c *fiber.Ctx) error { return c.Render("site/index", fiber.Map{ diff --git a/scripts/migration b/scripts/migration index 7ba02bd..1b6284d 100755 --- a/scripts/migration +++ b/scripts/migration @@ -5,7 +5,7 @@ ROOT_PATH="$(git rev-parse --show-toplevel)" source ${ROOT_PATH}/scripts/_helper -MIGRATION_DIR=${ROOT_PATH}/internal/app/migrations +MIGRATION_DIR=${ROOT_PATH}/internal/migrations DB_POSTFIX="goose_db_version" MIGRATION=$1 GOOSE_ACTION=$2 diff --git a/web/public/assets/css/style.css b/web/public/assets/css/style.css index 278d974..ffe1e0b 100644 --- a/web/public/assets/css/style.css +++ b/web/public/assets/css/style.css @@ -1 +1 @@ -/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;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;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,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{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-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}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],select,textarea{--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,select:focus,textarea:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid #0000;outline-offset:2px}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple]{background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid #0000;outline-offset:2px}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E")}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=checkbox]:indeterminate,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:#0000}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:#0000}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-x-0{left:0;right:0}.inset-y-0{bottom:0;top:0}.-start-full{inset-inline-start:-100%}.bottom-0{bottom:0}.end-0{inset-inline-end:0}.start-2{inset-inline-start:.5rem}.start-2\.5{inset-inline-start:.625rem}.top-0{top:0}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-0{margin-bottom:0}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-5{height:1.25rem}.h-screen{height:100vh}.w-48{width:12rem}.w-5{width:1.25rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-screen-xl{max-width:1280px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.place-content-center{place-content:center}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-4{gap:1rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-y-2>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(2px*var(--tw-divide-y-reverse));border-top-width:calc(2px*(1 - var(--tw-divide-y-reverse)))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.overflow-hidden{overflow:hidden}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.border{border-width:1px}.border-e{border-inline-end-width:1px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-transparent{background-color:initial}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-4{padding:1rem}.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-16{padding-bottom:4rem;padding-top:4rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pe-10{-webkit-padding-end:2.5rem;padding-inline-end:2.5rem}.pl-4{padding-left:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.tracking-widest{letter-spacing:.1em}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.placeholder-transparent::-moz-placeholder{color:#0000}.placeholder-transparent::placeholder{color:#0000}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.focus-within\:border-blue-600:focus-within{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity))}.focus-within\:ring-1:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-within\:ring-blue-600:focus-within{--tw-ring-opacity:1;--tw-ring-color:rgb(37 99 235/var(--tw-ring-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.hover\:bg-green-50:hover{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.hover\:text-green-500:hover{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.focus\:border-transparent:focus{border-color:#0000}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.active\:bg-indigo-500:active{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.group:hover .group-hover\:start-4{inset-inline-start:1rem}.group:hover .group-hover\:ms-4{-webkit-margin-start:1rem;margin-inline-start:1rem}.peer:-moz-placeholder-shown~.peer-placeholder-shown\:top-1\/2{top:50%}.peer:placeholder-shown~.peer-placeholder-shown\:top-1\/2{top:50%}.peer:-moz-placeholder-shown~.peer-placeholder-shown\:text-sm{font-size:.875rem;line-height:1.25rem}.peer:placeholder-shown~.peer-placeholder-shown\:text-sm{font-size:.875rem;line-height:1.25rem}.peer:focus~.peer-focus\:top-0{top:0}.peer:focus~.peer-focus\:text-xs{font-size:.75rem;line-height:1rem}:is([dir=rtl] .rtl\:rotate-180){--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}}@media (min-width:1024px){.lg\:grid-cols-\[1fr_120px\]{grid-template-columns:1fr 120px}.lg\:gap-8{gap:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file +/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;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;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,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{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-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}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],select,textarea{--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,select:focus,textarea:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid #0000;outline-offset:2px}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple]{background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{--tw-shadow:0 0 #0000;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid #0000;outline-offset:2px}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E")}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=checkbox]:indeterminate,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:#0000}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:#0000}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-x-0{left:0;right:0}.inset-y-0{bottom:0;top:0}.-start-full{inset-inline-start:-100%}.bottom-0{bottom:0}.end-0{inset-inline-end:0}.start-2{inset-inline-start:.5rem}.start-2\.5{inset-inline-start:.625rem}.top-0{top:0}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-0{margin-bottom:0}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-5{height:1.25rem}.h-screen{height:100vh}.w-48{width:12rem}.w-5{width:1.25rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-screen-xl{max-width:1280px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.place-content-center{place-content:center}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-4{gap:1rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-y-2>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(2px*var(--tw-divide-y-reverse));border-top-width:calc(2px*(1 - var(--tw-divide-y-reverse)))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.overflow-hidden{overflow:hidden}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.border{border-width:1px}.border-e{border-inline-end-width:1px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-transparent{background-color:initial}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-4{padding:1rem}.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-16{padding-bottom:4rem;padding-top:4rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pb-4{padding-bottom:1rem}.pe-10{-webkit-padding-end:2.5rem;padding-inline-end:2.5rem}.pl-4{padding-left:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.tracking-widest{letter-spacing:.1em}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.placeholder-transparent::-moz-placeholder{color:#0000}.placeholder-transparent::placeholder{color:#0000}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.focus-within\:border-blue-600:focus-within{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity))}.focus-within\:ring-1:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-within\:ring-blue-600:focus-within{--tw-ring-opacity:1;--tw-ring-color:rgb(37 99 235/var(--tw-ring-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.hover\:bg-green-50:hover{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.hover\:text-green-500:hover{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.focus\:border-transparent:focus{border-color:#0000}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.active\:bg-indigo-500:active{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.group:hover .group-hover\:start-4{inset-inline-start:1rem}.group:hover .group-hover\:ms-4{-webkit-margin-start:1rem;margin-inline-start:1rem}.peer:-moz-placeholder-shown~.peer-placeholder-shown\:top-1\/2{top:50%}.peer:placeholder-shown~.peer-placeholder-shown\:top-1\/2{top:50%}.peer:-moz-placeholder-shown~.peer-placeholder-shown\:text-sm{font-size:.875rem;line-height:1.25rem}.peer:placeholder-shown~.peer-placeholder-shown\:text-sm{font-size:.875rem;line-height:1.25rem}.peer:focus~.peer-focus\:top-0{top:0}.peer:focus~.peer-focus\:text-xs{font-size:.75rem;line-height:1rem}:is([dir=rtl] .rtl\:rotate-180){--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}}@media (min-width:1024px){.lg\:grid-cols-\[1fr_120px\]{grid-template-columns:1fr 120px}.lg\:gap-8{gap:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file diff --git a/web/views/admin/products.html b/web/views/admin/products.html index 5b169e3..b5d2dd4 100644 --- a/web/views/admin/products.html +++ b/web/views/admin/products.html @@ -1,5 +1,5 @@
-
+

Products