From e68dfc445fc6595a7856d53f60fad759744f407b Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 18 May 2024 16:47:26 -0400 Subject: [PATCH] feat(admin): adding user & importing --- api/app-admin-routes.go | 283 ++++++++++++++++-- api/app-routes.go | 24 +- api/auth.go | 93 ++---- api/common.go | 13 +- api/ko-routes.go | 19 +- api/utils.go | 11 + assets/style.css | 2 +- assets/sw.js | 20 +- .../20240510123707_import_basepath.go | 38 +++ database/models.go | 1 + database/query.sql | 4 +- database/query.sql.go | 18 +- database/schema.sql | 1 + database/views.sql | 2 - go.mod | 1 + sqlc.yaml | 4 + templates/base.tmpl | 13 + templates/pages/admin-import-results.tmpl | 46 +++ templates/pages/admin-import.tmpl | 8 +- templates/pages/admin-users.tmpl | 28 ++ templates/pages/documents.tmpl | 17 +- 21 files changed, 497 insertions(+), 149 deletions(-) create mode 100644 database/migrations/20240510123707_import_basepath.go create mode 100644 templates/pages/admin-import-results.tmpl diff --git a/api/app-admin-routes.go b/api/app-admin-routes.go index 22cf205..f57d208 100644 --- a/api/app-admin-routes.go +++ b/api/app-admin-routes.go @@ -3,6 +3,7 @@ package api import ( "archive/zip" "bufio" + "crypto/md5" "encoding/json" "fmt" "io" @@ -12,14 +13,19 @@ import ( "os" "path/filepath" "slices" + "sort" "strings" "time" + argon2 "github.com/alexedwards/argon2id" "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" "github.com/itchyny/gojq" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" + "reichard.io/antholume/database" "reichard.io/antholume/metadata" + "reichard.io/antholume/utils" ) type adminAction string @@ -59,12 +65,13 @@ type operationType string const ( opUpdate operationType = "UPDATE" opCreate operationType = "CREATE" + opDelete operationType = "DELETE" ) type requestAdminUpdateUser struct { User string `form:"user"` - Password string `form:"password"` - isAdmin bool `form:"is_admin"` + Password *string `form:"password"` + isAdmin *bool `form:"is_admin"` Operation operationType `form:"operation"` } @@ -72,6 +79,22 @@ type requestAdminLogs struct { Filter string `form:"filter"` } +type importStatus string + +const ( + importFailed importStatus = "FAILED" + importSuccess importStatus = "SUCCESS" + importExists importStatus = "EXISTS" +) + +type importResult struct { + ID string + Name string + Path string + Status importStatus + Error error +} + func (api *API) appPerformAdminAction(c *gin.Context) { templateVars, _ := api.getBaseTemplateVars("admin", c) @@ -82,15 +105,18 @@ func (api *API) appPerformAdminAction(c *gin.Context) { return } - // TODO - Messages switch rAdminAction.Action { case adminMetadataMatch: // TODO // 1. Documents xref most recent metadata table? // 2. Select all / deselect? case adminCacheTables: - go api.db.CacheTempTables() - // TODO - Message + go func() { + err := api.db.CacheTempTables() + if err != nil { + log.Error("Unable to cache temp tables: ", err) + } + }() case adminRestore: api.processRestoreFile(rAdminAction, c) return @@ -252,17 +278,18 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) { var err error switch rAdminUserUpdate.Operation { case opCreate: - err = api.createUser(rAdminUserUpdate.User, rAdminUserUpdate.Password) + err = api.createUser(rAdminUserUpdate) case opUpdate: - err = fmt.Errorf("unimplemented") + err = api.updateUser(rAdminUserUpdate) + case opDelete: + err = api.deleteUser(rAdminUserUpdate) default: appErrorPage(c, http.StatusNotFound, "Unknown user operation") return - } if err != nil { - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create user: %v", err)) + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create or update user: %v", err)) return } @@ -338,27 +365,83 @@ func (api *API) appPerformAdminImport(c *gin.Context) { return } - // TODO - Store results for approval? + // Get import directory + baseDirectory := filepath.Clean(rAdminImport.Directory) - // Walk import directory & copy or import files - importDirectory := filepath.Clean(rAdminImport.Directory) - _ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error { + // Get data directory + absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents")) + + // Validate different path + if absoluteDataPath == baseDirectory { + appErrorPage(c, http.StatusBadRequest, "Directory is the same as data path") + return + } + + // Do Transaction + tx, err := api.db.DB.Begin() + if err != nil { + log.Error("Transaction Begin DB Error:", err) + apiErrorPage(c, http.StatusBadRequest, "Unknown error") + return + } + + // Defer & Start Transaction + defer func() { + if err := tx.Rollback(); err != nil { + log.Error("DB Rollback Error:", err) + } + }() + qtx := api.db.Queries.WithTx(tx) + + // Track imports + importResults := make([]importResult, 0) + + // Walk Directory & Import + err = filepath.WalkDir(baseDirectory, func(currentPath string, f fs.DirEntry, err error) error { if err != nil { return err } + if f.IsDir() { return nil } - // Get metadata - fileMeta, err := metadata.GetMetadata(currentPath) + // Get relative path + relFilePath, err := filepath.Rel(baseDirectory, currentPath) if err != nil { - fmt.Printf("metadata error: %v\n", err) + log.Warnf("path error: %v", err) return nil } - // Only needed if copying - newName := deriveBaseFileName(fileMeta) + // Track imports + iResult := importResult{ + Path: relFilePath, + Status: importFailed, + } + defer func() { + importResults = append(importResults, iResult) + }() + + // Get metadata + fileMeta, err := metadata.GetMetadata(currentPath) + if err != nil { + log.Errorf("metadata error: %v", err) + iResult.Error = err + return nil + } + iResult.ID = *fileMeta.PartialMD5 + iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title) + + // Check already exists + _, err = qtx.GetDocument(api.db.Ctx, *fileMeta.PartialMD5) + if err == nil { + log.Warnf("document already exists: %s", *fileMeta.PartialMD5) + iResult.Status = importExists + return nil + } + + // TODO - Import Copy + // newName := deriveBaseFileName(fileMeta) // Open File on Disk // file, err := os.Open(currentPath) @@ -367,17 +450,45 @@ func (api *API) appPerformAdminImport(c *gin.Context) { // } // defer file.Close() - // TODO - BasePath in DB - // TODO - Copy / Import - - fmt.Printf("New File Metadata: %s\n", newName) + // Upsert document + if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ + ID: *fileMeta.PartialMD5, + Title: fileMeta.Title, + Author: fileMeta.Author, + Description: fileMeta.Description, + Md5: fileMeta.MD5, + Words: fileMeta.WordCount, + Filepath: &relFilePath, + Basepath: &baseDirectory, + }); err != nil { + log.Errorf("UpsertDocument DB Error: %v", err) + iResult.Error = err + return nil + } + iResult.Status = importSuccess return nil }) + if err != nil { + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import Failed: %v", err)) + return + } - templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory) + // Commit transaction + if err := tx.Commit(); err != nil { + log.Error("Transaction Commit DB Error: ", err) + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import DB Error: %v", err)) + return + } - c.HTML(http.StatusOK, "page/admin-import", templateVars) + // Sort import results + sort.Slice(importResults, func(i int, j int) bool { + return importStatusPriority(importResults[i].Status) < + importStatusPriority(importResults[j].Status) + }) + + templateVars["Data"] = importResults + c.HTML(http.StatusOK, "page/admin-import-results", templateVars) } func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) { @@ -521,7 +632,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte c.Redirect(http.StatusFound, "/login") } -// Restore all data func (api *API) restoreData(zipReader *zip.Reader) error { // Ensure Directories api.cfg.EnsureDirectories() @@ -552,7 +662,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error { return nil } -// Remove all data func (api *API) removeData() error { allPaths := []string{ "covers", @@ -575,7 +684,6 @@ func (api *API) removeData() error { return nil } -// Backup all data func (api *API) createBackup(w io.Writer, directories []string) error { ar := zip.NewWriter(w) @@ -628,7 +736,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error { if err != nil { return err } - io.Copy(newDbFile, dbFile) + + _, err = io.Copy(newDbFile, dbFile) + if err != nil { + return err + } // Backup Covers & Documents for _, dir := range directories { @@ -641,3 +753,118 @@ func (api *API) createBackup(w io.Writer, directories []string) error { ar.Close() return nil } + +func (api *API) createUser(createRequest requestAdminUpdateUser) error { + // Validate Necessary Parameters + if createRequest.User == "" { + return fmt.Errorf("username can't be empty") + } + if createRequest.Password == nil || *createRequest.Password == "" { + return fmt.Errorf("password can't be empty") + } + + // Base Params + createParams := database.CreateUserParams{ + ID: createRequest.User, + } + + // Handle Admin (Explicit or False) + if createRequest.isAdmin != nil { + createParams.Admin = *createRequest.isAdmin + } else { + createParams.Admin = false + } + + // Parse Password + password := fmt.Sprintf("%x", md5.Sum([]byte(*createRequest.Password))) + hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) + if err != nil { + return fmt.Errorf("unable to create hashed password") + } + createParams.Pass = &hashedPassword + + // Generate Auth Hash + rawAuthHash, err := utils.GenerateToken(64) + if err != nil { + return fmt.Errorf("unable to create token for user") + } + authHash := fmt.Sprintf("%x", rawAuthHash) + createParams.AuthHash = &authHash + + // Create user in DB + if rows, err := api.db.Queries.CreateUser(api.db.Ctx, createParams); err != nil { + log.Error("CreateUser DB Error:", err) + return fmt.Errorf("unable to create user") + } else if rows == 0 { + log.Warn("User Already Exists:", createParams.ID) + return fmt.Errorf("user already exists") + } + + return nil +} + +func (api *API) updateUser(updateRequest requestAdminUpdateUser) error { + // Validate Necessary Parameters + if updateRequest.User == "" { + return fmt.Errorf("username can't be empty") + } + if updateRequest.Password == nil && updateRequest.isAdmin == nil { + return fmt.Errorf("nothing to update") + } + + // Base Params + updateParams := database.UpdateUserParams{ + UserID: updateRequest.User, + } + + // Handle Admin (Update or Existing) + if updateRequest.isAdmin != nil { + updateParams.Admin = *updateRequest.isAdmin + } else { + user, err := api.db.Queries.GetUser(api.db.Ctx, updateRequest.User) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err)) + } + updateParams.Admin = user.Admin + } + + // TODO: + // - Validate Not Last Admin + + // Handle Password + if updateRequest.Password != nil { + if *updateRequest.Password == "" { + return fmt.Errorf("password can't be empty") + } + + // Parse Password + password := fmt.Sprintf("%x", md5.Sum([]byte(*updateRequest.Password))) + hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) + if err != nil { + return fmt.Errorf("unable to create hashed password") + } + updateParams.Password = &hashedPassword + + // Generate Auth Hash + rawAuthHash, err := utils.GenerateToken(64) + if err != nil { + return fmt.Errorf("unable to create token for user") + } + authHash := fmt.Sprintf("%x", rawAuthHash) + updateParams.AuthHash = &authHash + } + + // Update User + _, err := api.db.Queries.UpdateUser(api.db.Ctx, updateParams) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err)) + } + + return nil +} + +func (api *API) deleteUser(updateRequest requestAdminUpdateUser) error { + // TODO: + // - Validate Not Last Admin + return errors.New("unimplemented") +} diff --git a/api/app-routes.go b/api/app-routes.go index 9a3d833..f0041b2 100644 --- a/api/app-routes.go +++ b/api/app-routes.go @@ -314,7 +314,11 @@ func (api *API) appGetSearch(c *gin.Context) { templateVars, _ := api.getBaseTemplateVars("search", c) var sParams searchParams - c.BindQuery(&sParams) + err := c.BindQuery(&sParams) + if err != nil { + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) + return + } // Only Handle Query if sParams.Query != nil && sParams.Source != nil { @@ -595,7 +599,6 @@ func (api *API) appEditDocument(c *gin.Context) { } c.Redirect(http.StatusFound, "./") - return } func (api *API) appDeleteDocument(c *gin.Context) { @@ -764,6 +767,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) { // Derive Extension on MIME fileMime, err := mimetype.DetectFile(tempFilePath) + if err != nil { + log.Warn("MIME Detect Error: ", err) + sendDownloadMessage("Unable to download file", gin.H{"Error": true}) + return + } fileExtension := fileMime.Extension() // Derive Filename @@ -951,7 +959,11 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats } // Defer & Start Transaction - defer tx.Rollback() + defer func() { + if err := tx.Rollback(); err != nil { + log.Error("DB Rollback Error:", err) + } + }() qtx := api.db.Queries.WithTx(tx) for _, item := range documents { @@ -1000,7 +1012,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { var qParams queryParams - c.BindQuery(&qParams) + err := c.BindQuery(&qParams) + if err != nil { + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) + return qParams + } if qParams.Limit == nil { qParams.Limit = &defaultLimit diff --git a/api/auth.go b/api/auth.go index 63f8f4f..8dda00a 100644 --- a/api/auth.go +++ b/api/auth.go @@ -28,18 +28,13 @@ type authKOHeader struct { AuthKey string `header:"x-auth-key"` } -// OPDS Auth Headers -type authOPDSHeader struct { - Authorization string `header:"authorization"` -} - func (api *API) authorizeCredentials(username string, password string) (auth *authData) { user, err := api.db.Queries.GetUser(api.db.Ctx, username) if err != nil { return } - if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true { + if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match { return } @@ -57,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) { session := sessions.Default(c) // Check Session First - if auth, ok := api.getSession(session); ok == true { + if auth, ok := api.getSession(session); ok { c.Set("Authorization", auth) c.Header("Cache-Control", "private") c.Next() @@ -98,7 +93,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) { user, rawPassword, hasAuth := c.Request.BasicAuth() // Validate Auth Fields - if hasAuth != true || user == "" || rawPassword == "" { + if !hasAuth || user == "" || rawPassword == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) return } @@ -120,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) { session := sessions.Default(c) // Check Session - if auth, ok := api.getSession(session); ok == true { + if auth, ok := api.getSession(session); ok { c.Set("Authorization", auth) c.Header("Cache-Control", "private") c.Next() @@ -129,13 +124,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) { c.Redirect(http.StatusFound, "/login") c.Abort() - return } func (api *API) authAdminWebAppMiddleware(c *gin.Context) { if data, _ := c.Get("Authorization"); data != nil { auth := data.(authData) - if auth.IsAdmin == true { + if auth.IsAdmin { c.Next() return } @@ -143,7 +137,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) { appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required") c.Abort() - return } func (api *API) appAuthLogin(c *gin.Context) { @@ -276,7 +269,10 @@ func (api *API) appAuthRegister(c *gin.Context) { func (api *API) appAuthLogout(c *gin.Context) { session := sessions.Default(c) session.Clear() - session.Save() + if err := session.Save(); err != nil { + log.Error("unable to save session") + } + c.Redirect(http.StatusFound, "/login") } @@ -377,7 +373,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) { // Refresh if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { log.Info("Refreshing Session") - api.setSession(session, auth) + if err := api.setSession(session, auth); err != nil { + log.Error("unable to get session") + return + } } // Authorized @@ -422,7 +421,11 @@ func (api *API) rotateAllAuthHashes() error { } // Defer & Start Transaction - defer tx.Rollback() + defer func() { + if err := tx.Rollback(); err != nil { + log.Error("DB Rollback Error:", err) + } + }() qtx := api.db.Queries.WithTx(tx) users, err := qtx.GetUsers(api.db.Ctx) @@ -430,7 +433,8 @@ func (api *API) rotateAllAuthHashes() error { return err } - // Update users + // Update Users + newAuthHashCache := make(map[string]string, 0) for _, user := range users { // Generate Auth Hash rawAuthHash, err := utils.GenerateToken(64) @@ -448,8 +452,8 @@ func (api *API) rotateAllAuthHashes() error { return err } - // Update Cache - api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash) + // Save New Hash Cache + newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash) } // Commit Transaction @@ -458,56 +462,9 @@ func (api *API) rotateAllAuthHashes() error { return err } - return nil -} - -func (api *API) createUser(username string, rawPassword string) error { - password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) - - if username == "" { - return fmt.Errorf("username can't be empty") - } - - if rawPassword == "" { - return fmt.Errorf("password can't be empty") - } - - hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) - if err != nil { - return fmt.Errorf("unable to create hashed password") - } - - // Generate auth hash - rawAuthHash, err := utils.GenerateToken(64) - if err != nil { - return fmt.Errorf("unable to create token for user") - } - - // Get current users - currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx) - if err != nil { - return fmt.Errorf("unable to get current users") - } - - // Determine if we should be admin - isAdmin := false - if len(currentUsers) == 0 { - isAdmin = true - } - - // Create user in DB - authHash := fmt.Sprintf("%x", rawAuthHash) - if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{ - ID: username, - Pass: &hashedPassword, - AuthHash: &authHash, - Admin: isAdmin, - }); err != nil { - log.Error("CreateUser DB Error:", err) - return fmt.Errorf("unable to create user") - } else if rows == 0 { - log.Warn("User Already Exists:", username) - return fmt.Errorf("user already exists") + // Transaction Succeeded -> Update Cache + for user, hash := range newAuthHashCache { + api.userAuthCache[user] = hash } return nil diff --git a/api/common.go b/api/common.go index 6e1f7bc..598ff68 100644 --- a/api/common.go +++ b/api/common.go @@ -2,11 +2,12 @@ package api import ( "fmt" - "github.com/gin-gonic/gin" - log "github.com/sirupsen/logrus" "net/http" "os" "path/filepath" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" "reichard.io/antholume/database" "reichard.io/antholume/metadata" ) @@ -34,8 +35,14 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int, return } + // Derive Basepath + basepath := filepath.Join(api.cfg.DataPath, "documents") + if document.Basepath != nil && *document.Basepath != "" { + basepath = *document.Basepath + } + // Derive Storage Location - filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath) + filePath := filepath.Join(basepath, *document.Filepath) // Validate File Exists _, err = os.Stat(filePath) diff --git a/api/ko-routes.go b/api/ko-routes.go index ff5ccbb..13aacad 100644 --- a/api/ko-routes.go +++ b/api/ko-routes.go @@ -193,7 +193,11 @@ func (api *API) koAddActivities(c *gin.Context) { allDocuments := getKeys(allDocumentsMap) // Defer & Start Transaction - defer tx.Rollback() + defer func() { + if err := tx.Rollback(); err != nil { + log.Error("DB Rollback Error:", err) + } + }() qtx := api.db.Queries.WithTx(tx) // Upsert Documents @@ -316,7 +320,11 @@ func (api *API) koAddDocuments(c *gin.Context) { } // Defer & Start Transaction - defer tx.Rollback() + defer func() { + if err := tx.Rollback(); err != nil { + log.Error("DB Rollback Error:", err) + } + }() qtx := api.db.Queries.WithTx(tx) // Upsert Documents @@ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) { return } - missingDocs := []database.Document{} - deletedDocIDs := []string{} - // Get Missing Documents - missingDocs, err = api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have) + missingDocs, err := api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have) if err != nil { log.Error("GetMissingDocuments DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") @@ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) { } // Get Deleted Documents - deletedDocIDs, err = api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) + deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) if err != nil { log.Error("GetDeletedDocuments DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") diff --git a/api/utils.go b/api/utils.go index 40f6e5f..7cca2ff 100644 --- a/api/utils.go +++ b/api/utils.go @@ -155,3 +155,14 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { fileName := strings.ReplaceAll(newFileName, "/", "") return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type)) } + +func importStatusPriority(status importStatus) int { + switch status { + case importFailed: + return 1 + case importExists: + return 2 + default: + return 3 + } +} diff --git a/assets/style.css b/assets/style.css index 952941d..3998bb5 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1 +1 @@ -/*! tailwindcss v3.3.5 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;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-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}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{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color: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{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::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: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.-bottom-28{bottom:-7rem}.-bottom-5{bottom:-1.25rem}.-top-2{top:-.5rem}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.bottom-7{bottom:1.75rem}.left-0{left:0}.left-1\/2{left:50%}.left-10{left:2.5rem}.left-4{left:1rem}.left-5{left:1.25rem}.right-0{right:0}.right-4{right:1rem}.right-6{right:1.5rem}.top-0{top:0}.top-1{top:.25rem}.top-1\/2{top:50%}.top-10{top:2.5rem}.top-16{top:4rem}.top-3{top:.75rem}.top-6{top:1.5rem}.top-7{top:1.75rem}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.float-right{float:right}.float-left{float:left}.m-4{margin:1rem}.m-auto{margin:auto}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.my-auto{margin-top:auto;margin-bottom:auto}.-ml-6{margin-left:-1.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.ml-6{margin-left:1.5rem}.mr-4{margin-right:1rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.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}.contents{display:contents}.hidden{display:none}.h-0{height:0}.h-0\.5{height:.125rem}.h-16{height:4rem}.h-2{height:.5rem}.h-32{height:8rem}.h-4{height:1rem}.h-48{height:12rem}.h-7{height:1.75rem}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[10em\]{max-height:10em}.max-h-\[95\%\]{max-height:95%}.min-h-\[10em\]{min-height:10em}.w-0{width:0}.w-1\/2{width:50%}.w-12{width:3rem}.w-16{width:4rem}.w-24{width:6rem}.w-32{width:8rem}.w-4{width:1rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-5\/6{width:83.333333%}.w-56{width:14rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-\[90\%\]{width:90%}.w-full{width:100%}.w-max{width:-moz-max-content;width:max-content}.w-screen{width:100vw}.min-w-40{min-width:10rem}.min-w-\[12em\]{min-width:12em}.min-w-\[50\%\]{min-width:50%}.min-w-fit{min-width:-moz-fit-content;min-width:fit-content}.min-w-full{min-width:100%}.max-w-\[50dvw\]{max-width:50dvw}.max-w-screen-sm{max-width:640px}.max-w-screen-xl{max-width:1280px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.origin-top-right{transform-origin:top right}.-translate-x-2\/4{--tw-translate-x:-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))}.-translate-y-1\/2,.-translate-y-2\/4{--tw-translate-y:-50%}.-translate-y-1\/2,.-translate-y-2\/4,.transform{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))}.cursor-pointer{cursor:pointer}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-1{gap:.25rem}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.gap-7{gap:1.75rem}.gap-8{gap:2rem}.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)))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-none{border-radius:0}.rounded-l{border-top-left-radius:.25rem}.rounded-bl,.rounded-l{border-bottom-left-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity))}.border-transparent{border-color:#0000}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity))}.bg-\[\#000\]{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.bg-\[\#1f2937\]{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-\[\#232323\]{--tw-bg-opacity:1;background-color:rgb(35 35 35/var(--tw-bg-opacity))}.bg-\[\#d2b48c\]{--tw-bg-opacity:1;background-color:rgb(210 180 140/var(--tw-bg-opacity))}.bg-\[\#fff\]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-700{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.object-cover{-o-object-fit:cover;object-fit:cover}.object-fill{-o-object-fit:fill;object-fit:fill}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.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}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-12{padding-bottom:3rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pl-0{padding-left:0}.pl-14{padding-left:3.5rem}.pl-6{padding-left:1.5rem}.pr-8{padding-right:2rem}.pt-12{padding-top:3rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-justify{text-align:justify}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-7xl{font-size:4.5rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-light{font-weight:300}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.leading-normal{line-height:1.5}.tracking-tight{letter-spacing:-.025em}.text-\[\#000\]{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-\[\#333\]{--tw-text-opacity:1;color:rgb(51 51 51/var(--tw-text-opacity))}.text-\[\#ccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity))}.text-\[\#fff\]{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.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-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px #00000040;--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.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 #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.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)}.shadow-gray-500{--tw-shadow-color:#6b7280;--tw-shadow:var(--tw-shadow-colored)}.ring-1{--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)}.ring-black{--tw-ring-opacity:1;--tw-ring-color:rgb(0 0 0/var(--tw-ring-opacity))}.ring-opacity-5{--tw-ring-opacity:0.05}.invert{--tw-invert:invert(100%)}.filter,.invert{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:whitespace-pre:hover{white-space:pre}.hover\:bg-blue-800:hover{--tw-bg-opacity:1;background-color:rgb(30 64 175/var(--tw-bg-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\:bg-gray-400:hover{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.hover\:text-black:hover{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.hover\:text-gray-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.hover\:opacity-100:hover{opacity:1}.focus\:border-transparent:focus{border-color:#0000}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2: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(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-2:focus,.focus\:ring-4:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-4: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(4px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-300:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity))}.focus\:ring-purple-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(147 51 234/var(--tw-ring-opacity))}.peer\/All:checked~.peer-checked\/All\:block,.peer\/Month:checked~.peer-checked\/Month\:block,.peer\/Week:checked~.peer-checked\/Week\:block,.peer\/Year:checked~.peer-checked\/Year\:block,.peer\/add:checked~.peer-checked\/add\:block{display:block}@media (prefers-color-scheme:dark){.dark\:border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.dark\:border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.dark\:border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity))}.dark\:bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.dark\:bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.dark\:bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.dark\:bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.dark\:bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.dark\:text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.dark\:text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.dark\:text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:shadow-gray-800{--tw-shadow-color:#1f2937;--tw-shadow:var(--tw-shadow-colored)}.dark\:shadow-gray-900{--tw-shadow-color:#111827;--tw-shadow:var(--tw-shadow-colored)}.dark\:hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:hover\:text-gray-100:hover{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity))}.dark\:hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:focus\:ring-blue-800:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity))}}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:mt-0{margin-top:0}.sm\:grid{display:grid}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-4{gap:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:mr-2{margin-right:.5rem}.md\:block{display:block}.md\:table-cell{display:table-cell}.md\:w-1\/2{width:50%}.md\:w-60{width:15rem}.md\:w-fit{width:-moz-fit-content;width:fit-content}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:justify-start{justify-content:flex-start}.md\:px-24{padding-left:6rem;padding-right:6rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}.md\:pt-0{padding-top:0}.md\:pt-8{padding-top:2rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:1024px){.lg\:mx-48{margin-left:12rem;margin-right:12rem}.lg\:ml-44{margin-left:11rem}.lg\:ml-48{margin-left:12rem}.lg\:hidden{display:none}.lg\:w-48{width:12rem}.lg\:w-80{width:20rem}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-around{justify-content:space-around}.lg\:px-32{padding-left:8rem;padding-right:8rem}.lg\:px-6{padding-left:1.5rem;padding-right:1.5rem}.lg\:py-16{padding-top:4rem;padding-bottom:4rem}.lg\:pr-0{padding-right:0}.lg\:text-9xl{font-size:8rem;line-height:1}} \ No newline at end of file +/*! tailwindcss v3.3.5 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;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-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}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{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color: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{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::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: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.-bottom-1{bottom:-.25rem}.-bottom-1\.5{bottom:-.375rem}.-bottom-28{bottom:-7rem}.-bottom-5{bottom:-1.25rem}.-top-2{top:-.5rem}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.bottom-7{bottom:1.75rem}.left-0{left:0}.left-1\/2{left:50%}.left-10{left:2.5rem}.left-16{left:4rem}.left-4{left:1rem}.left-5{left:1.25rem}.right-0{right:0}.right-4{right:1rem}.right-6{right:1.5rem}.top-0{top:0}.top-1{top:.25rem}.top-1\/2{top:50%}.top-10{top:2.5rem}.top-16{top:4rem}.top-3{top:.75rem}.top-6{top:1.5rem}.top-7{top:1.75rem}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.float-right{float:right}.float-left{float:left}.m-4{margin:1rem}.m-auto{margin:auto}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.my-auto{margin-top:auto;margin-bottom:auto}.-ml-6{margin-left:-1.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.ml-6{margin-left:1.5rem}.mr-4{margin-right:1rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.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}.contents{display:contents}.hidden{display:none}.h-0{height:0}.h-0\.5{height:.125rem}.h-16{height:4rem}.h-2{height:.5rem}.h-32{height:8rem}.h-4{height:1rem}.h-48{height:12rem}.h-7{height:1.75rem}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[10em\]{max-height:10em}.max-h-\[95\%\]{max-height:95%}.min-h-\[10em\]{min-height:10em}.w-0{width:0}.w-1\/2{width:50%}.w-12{width:3rem}.w-16{width:4rem}.w-24{width:6rem}.w-32{width:8rem}.w-4{width:1rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-5\/6{width:83.333333%}.w-56{width:14rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-\[90\%\]{width:90%}.w-full{width:100%}.w-max{width:-moz-max-content;width:max-content}.w-screen{width:100vw}.min-w-40{min-width:10rem}.min-w-\[12em\]{min-width:12em}.min-w-\[50\%\]{min-width:50%}.min-w-fit{min-width:-moz-fit-content;min-width:fit-content}.min-w-full{min-width:100%}.max-w-\[50dvw\]{max-width:50dvw}.max-w-screen-sm{max-width:640px}.max-w-screen-xl{max-width:1280px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.origin-top-right{transform-origin:top right}.-translate-x-2\/4{--tw-translate-x:-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))}.-translate-y-1\/2,.-translate-y-2\/4{--tw-translate-y:-50%}.-translate-y-1\/2,.-translate-y-2\/4,.transform{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))}.cursor-pointer{cursor:pointer}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-1{gap:.25rem}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.gap-7{gap:1.75rem}.gap-8{gap:2rem}.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)))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-none{border-radius:0}.rounded-l{border-top-left-radius:.25rem}.rounded-bl,.rounded-l{border-bottom-left-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity))}.border-transparent{border-color:#0000}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity))}.bg-\[\#000\]{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.bg-\[\#1f2937\]{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-\[\#232323\]{--tw-bg-opacity:1;background-color:rgb(35 35 35/var(--tw-bg-opacity))}.bg-\[\#d2b48c\]{--tw-bg-opacity:1;background-color:rgb(210 180 140/var(--tw-bg-opacity))}.bg-\[\#fff\]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-700{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.object-cover{-o-object-fit:cover;object-fit:cover}.object-fill{-o-object-fit:fill;object-fit:fill}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.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}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-12{padding-bottom:3rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pl-0{padding-left:0}.pl-14{padding-left:3.5rem}.pl-6{padding-left:1.5rem}.pr-8{padding-right:2rem}.pt-12{padding-top:3rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-justify{text-align:justify}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-7xl{font-size:4.5rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-light{font-weight:300}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.leading-normal{line-height:1.5}.tracking-tight{letter-spacing:-.025em}.text-\[\#000\]{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-\[\#333\]{--tw-text-opacity:1;color:rgb(51 51 51/var(--tw-text-opacity))}.text-\[\#ccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity))}.text-\[\#fff\]{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.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-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px #00000040;--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.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 #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.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)}.shadow-gray-500{--tw-shadow-color:#6b7280;--tw-shadow:var(--tw-shadow-colored)}.ring-1{--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)}.ring-black{--tw-ring-opacity:1;--tw-ring-color:rgb(0 0 0/var(--tw-ring-opacity))}.ring-opacity-5{--tw-ring-opacity:0.05}.invert{--tw-invert:invert(100%)}.filter,.invert{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:whitespace-pre:hover{white-space:pre}.hover\:bg-blue-800:hover{--tw-bg-opacity:1;background-color:rgb(30 64 175/var(--tw-bg-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\:bg-gray-400:hover{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.hover\:text-black:hover{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.hover\:text-gray-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.hover\:opacity-100:hover{opacity:1}.focus\:border-transparent:focus{border-color:#0000}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2: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(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-2:focus,.focus\:ring-4:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-4: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(4px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-300:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity))}.focus\:ring-purple-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(147 51 234/var(--tw-ring-opacity))}.peer\/All:checked~.peer-checked\/All\:block,.peer\/Month:checked~.peer-checked\/Month\:block,.peer\/Week:checked~.peer-checked\/Week\:block,.peer\/Year:checked~.peer-checked\/Year\:block,.peer\/add:checked~.peer-checked\/add\:block{display:block}@media (prefers-color-scheme:dark){.dark\:border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.dark\:border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.dark\:border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity))}.dark\:bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.dark\:bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.dark\:bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.dark\:bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.dark\:bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.dark\:text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.dark\:text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.dark\:text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:shadow-gray-800{--tw-shadow-color:#1f2937;--tw-shadow:var(--tw-shadow-colored)}.dark\:shadow-gray-900{--tw-shadow-color:#111827;--tw-shadow:var(--tw-shadow-colored)}.dark\:hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:hover\:text-gray-100:hover{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity))}.dark\:hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:focus\:ring-blue-800:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity))}}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:mt-0{margin-top:0}.sm\:grid{display:grid}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:gap-4{gap:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:mr-2{margin-right:.5rem}.md\:block{display:block}.md\:table-cell{display:table-cell}.md\:w-1\/2{width:50%}.md\:w-60{width:15rem}.md\:w-fit{width:-moz-fit-content;width:fit-content}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:justify-start{justify-content:flex-start}.md\:px-24{padding-left:6rem;padding-right:6rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}.md\:pt-0{padding-top:0}.md\:pt-8{padding-top:2rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:1024px){.lg\:mx-48{margin-left:12rem;margin-right:12rem}.lg\:ml-44{margin-left:11rem}.lg\:ml-48{margin-left:12rem}.lg\:hidden{display:none}.lg\:w-48{width:12rem}.lg\:w-80{width:20rem}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-around{justify-content:space-around}.lg\:px-32{padding-left:8rem;padding-right:8rem}.lg\:px-6{padding-left:1.5rem;padding-right:1.5rem}.lg\:py-16{padding-top:4rem;padding-bottom:4rem}.lg\:pr-0{padding-right:0}.lg\:text-9xl{font-size:8rem;line-height:1}} \ No newline at end of file diff --git a/assets/sw.js b/assets/sw.js index 7cdf047..8eba6c1 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -99,7 +99,7 @@ const PRECACHE_ASSETS = [ // ----------------------- Helpers ----------------------- // // ------------------------------------------------------- // -function purgeCache() { +async function purgeCache() { console.log("[purgeCache] Purging Cache"); return caches.keys().then(function (names) { for (let name of names) caches.delete(name); @@ -136,7 +136,7 @@ async function handleFetch(event) { const directive = ROUTES.find( (item) => (item.route instanceof RegExp && url.match(item.route)) || - url == item.route + url == item.route, ) || { type: CACHE_NEVER }; // Get Fallback @@ -161,11 +161,11 @@ async function handleFetch(event) { ); case CACHE_UPDATE_SYNC: return updateCache(event.request).catch( - (e) => currentCache || fallbackFunc(event) + (e) => currentCache || fallbackFunc(event), ); case CACHE_UPDATE_ASYNC: let newResponse = updateCache(event.request).catch((e) => - fallbackFunc(event) + fallbackFunc(event), ); return currentCache || newResponse; @@ -192,7 +192,7 @@ function handleMessage(event) { .filter( (item) => item.startsWith("/documents/") || - item.startsWith("/reader/progress/") + item.startsWith("/reader/progress/"), ); // Derive Unique IDs @@ -200,8 +200,8 @@ function handleMessage(event) { new Set( docResources .filter((item) => item.startsWith("/documents/")) - .map((item) => item.split("/")[2]) - ) + .map((item) => item.split("/")[2]), + ), ); /** @@ -214,14 +214,14 @@ function handleMessage(event) { .filter( (id) => docResources.includes("/documents/" + id + "/file") && - docResources.includes("/reader/progress/" + id) + docResources.includes("/reader/progress/" + id), ) .map(async (id) => { let url = "/reader/progress/" + id; let currentCache = await caches.match(url); let resp = await updateCache(url).catch((e) => currentCache); return resp.json(); - }) + }), ); event.source.postMessage({ id, data: cachedDocuments }); @@ -233,7 +233,7 @@ function handleMessage(event) { Promise.all([ cache.delete("/documents/" + data.id + "/file"), cache.delete("/reader/progress/" + data.id), - ]) + ]), ) .then(() => event.source.postMessage({ id, data: "SUCCESS" })) .catch(() => event.source.postMessage({ id, data: "FAILURE" })); diff --git a/database/migrations/20240510123707_import_basepath.go b/database/migrations/20240510123707_import_basepath.go new file mode 100644 index 0000000..de87952 --- /dev/null +++ b/database/migrations/20240510123707_import_basepath.go @@ -0,0 +1,38 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upImportBasepath, downImportBasepath) +} + +func upImportBasepath(ctx context.Context, tx *sql.Tx) error { + // Determine if we have a new DB or not + isNew := ctx.Value("isNew").(bool) + if isNew { + return nil + } + + // Add basepath column + _, err := tx.Exec(`ALTER TABLE documents ADD COLUMN basepath TEXT;`) + if err != nil { + return err + } + + // This code is executed when the migration is applied. + return nil +} + +func downImportBasepath(ctx context.Context, tx *sql.Tx) error { + // Drop basepath column + _, err := tx.Exec("ALTER documents DROP COLUMN basepath;") + if err != nil { + return err + } + return nil +} diff --git a/database/models.go b/database/models.go index 30eee1d..884921a 100644 --- a/database/models.go +++ b/database/models.go @@ -30,6 +30,7 @@ type Device struct { type Document struct { ID string `json:"id"` Md5 *string `json:"md5"` + Basepath *string `json:"basepath"` Filepath *string `json:"filepath"` Coverfile *string `json:"coverfile"` Title *string `json:"title"` diff --git a/database/query.sql b/database/query.sql index a1db8af..4659655 100644 --- a/database/query.sql +++ b/database/query.sql @@ -396,6 +396,7 @@ RETURNING *; INSERT INTO documents ( id, md5, + basepath, filepath, coverfile, title, @@ -410,10 +411,11 @@ INSERT INTO documents ( isbn10, isbn13 ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET md5 = COALESCE(excluded.md5, md5), + basepath = COALESCE(excluded.basepath, basepath), filepath = COALESCE(excluded.filepath, filepath), coverfile = COALESCE(excluded.coverfile, coverfile), title = COALESCE(excluded.title, title), diff --git a/database/query.sql.go b/database/query.sql.go index 64b07ef..cf0765b 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -454,7 +454,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo } const getDocument = `-- name: GetDocument :one -SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents +SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents WHERE id = ?1 LIMIT 1 ` @@ -464,6 +464,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document, err := row.Scan( &i.ID, &i.Md5, + &i.Basepath, &i.Filepath, &i.Coverfile, &i.Title, @@ -612,7 +613,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS } const getDocuments = `-- name: GetDocuments :many -SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents +SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents ORDER BY created_at DESC LIMIT ?2 OFFSET ?1 @@ -635,6 +636,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D if err := rows.Scan( &i.ID, &i.Md5, + &i.Basepath, &i.Filepath, &i.Coverfile, &i.Title, @@ -819,7 +821,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams } const getMissingDocuments = `-- name: GetMissingDocuments :many -SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents +SELECT documents.id, documents.md5, documents.basepath, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents WHERE documents.filepath IS NOT NULL AND documents.deleted = false @@ -848,6 +850,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string) if err := rows.Scan( &i.ID, &i.Md5, + &i.Basepath, &i.Filepath, &i.Coverfile, &i.Title, @@ -1325,6 +1328,7 @@ const upsertDocument = `-- name: UpsertDocument :one INSERT INTO documents ( id, md5, + basepath, filepath, coverfile, title, @@ -1339,10 +1343,11 @@ INSERT INTO documents ( isbn10, isbn13 ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET md5 = COALESCE(excluded.md5, md5), + basepath = COALESCE(excluded.basepath, basepath), filepath = COALESCE(excluded.filepath, filepath), coverfile = COALESCE(excluded.coverfile, coverfile), title = COALESCE(excluded.title, title), @@ -1356,12 +1361,13 @@ SET gbid = COALESCE(excluded.gbid, gbid), isbn10 = COALESCE(excluded.isbn10, isbn10), isbn13 = COALESCE(excluded.isbn13, isbn13) -RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at +RETURNING id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at ` type UpsertDocumentParams struct { ID string `json:"id"` Md5 *string `json:"md5"` + Basepath *string `json:"basepath"` Filepath *string `json:"filepath"` Coverfile *string `json:"coverfile"` Title *string `json:"title"` @@ -1381,6 +1387,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams) row := q.db.QueryRowContext(ctx, upsertDocument, arg.ID, arg.Md5, + arg.Basepath, arg.Filepath, arg.Coverfile, arg.Title, @@ -1399,6 +1406,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams) err := row.Scan( &i.ID, &i.Md5, + &i.Basepath, &i.Filepath, &i.Coverfile, &i.Title, diff --git a/database/schema.sql b/database/schema.sql index fb70a39..0faa752 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS documents ( id TEXT NOT NULL PRIMARY KEY, md5 TEXT, + basepath TEXT, filepath TEXT, coverfile TEXT, title TEXT, diff --git a/database/views.sql b/database/views.sql index 3e95fb3..b56178a 100644 --- a/database/views.sql +++ b/database/views.sql @@ -294,5 +294,3 @@ INNER JOIN ON ga.document_id = d.id GROUP BY ga.document_id, ga.user_id ORDER BY total_wpm DESC; - - diff --git a/go.mod b/go.mod index 52e8e5e..cfb4e3c 100644 --- a/go.mod +++ b/go.mod @@ -49,6 +49,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/sqlc.yaml b/sqlc.yaml index f6c39ee..be71fe4 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -19,6 +19,10 @@ sql: go_type: type: "string" pointer: true + - column: "documents.basepath" + go_type: + type: "string" + pointer: true - column: "documents.coverfile" go_type: type: "string" diff --git a/templates/base.tmpl b/templates/base.tmpl index 4d8435e..774c659 100644 --- a/templates/base.tmpl +++ b/templates/base.tmpl @@ -54,6 +54,19 @@ display: none; } + /* ----------------------------- */ + /* -------- CSS Button -------- */ + /* ----------------------------- */ + .css-button:checked+div { + visibility: visible; + opacity: 1; + } + + .css-button+div { + visibility: hidden; + opacity: 0; + } + /* ----------------------------- */ /* ------- User Dropdown ------- */ /* ----------------------------- */ diff --git a/templates/pages/admin-import-results.tmpl b/templates/pages/admin-import-results.tmpl new file mode 100644 index 0000000..00e160c --- /dev/null +++ b/templates/pages/admin-import-results.tmpl @@ -0,0 +1,46 @@ +{{ template "base" . }} +{{ define "title" }}Admin - Import Results{{ end }} +{{ define "header" }}Admin - Import Results{{ end }} +{{ define "content" }} +
+
+ + + + + + + + + + {{ if not .Data }} + + + + {{ end }} + {{ range $result := .Data }} + + + + + + {{ end }} + +
DocumentStatusError
No Results
+ Name: + {{ if (eq $result.ID "") }} + N/A + {{ else }} + {{ $result.Name }} + {{ end }} + File: + {{ $result.Path }} + +

{{ $result.Status }}

+
+

{{ $result.Error }}

+
+
+
+{{ end }} diff --git a/templates/pages/admin-import.tmpl b/templates/pages/admin-import.tmpl index 53f3428..cbe75be 100644 --- a/templates/pages/admin-import.tmpl +++ b/templates/pages/admin-import.tmpl @@ -19,12 +19,12 @@
- - + +
- - + +
diff --git a/templates/pages/admin-users.tmpl b/templates/pages/admin-users.tmpl index 6e62e3d..892fb55 100644 --- a/templates/pages/admin-users.tmpl +++ b/templates/pages/admin-users.tmpl @@ -35,6 +35,7 @@ User + Password Permissions @@ -55,6 +56,33 @@

{{ $user.ID }}

+ + + +
+
+ + + +
+
+ admin user diff --git a/templates/pages/documents.tmpl b/templates/pages/documents.tmpl index b336053..ceaa145 100644 --- a/templates/pages/documents.tmpl +++ b/templates/pages/documents.tmpl @@ -89,7 +89,7 @@ enctype="multipart/form-data" action="./documents" class="flex flex-col gap-2"> - + @@ -102,19 +102,4 @@ - {{ end }}