diff --git a/api/api.go b/api/api.go index fea7a89..d529b5e 100644 --- a/api/api.go +++ b/api/api.go @@ -145,23 +145,23 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) { router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress) // Web App - Templates - router.GET("/", api.authWebAppMiddleware, api.appGetHomeNew) // DONE - router.GET("/activity", api.authWebAppMiddleware, api.appGetActivityNew) // DONE - router.GET("/progress", api.authWebAppMiddleware, api.appGetProgressNew) // DONE - router.GET("/documents", api.authWebAppMiddleware, api.appGetDocumentsNew) // DONE - router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocumentNew) // DONE + router.GET("/", api.authWebAppMiddleware, api.appGetHome) // DONE + router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity) // DONE + router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress) // DONE + router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments) // DONE + router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument) // DONE // Web App - Other Routes router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE - router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout) - router.POST("/login", api.appAuthLogin) // DONE - router.POST("/register", api.appAuthRegister) // DONE + router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout) // DONE + router.POST("/login", api.appAuthLogin) // DONE + router.POST("/register", api.appAuthRegister) // DONE + router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings) // DONE // TODO router.GET("/login", api.appGetLogin) router.GET("/register", api.appGetRegister) - router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings) router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs) router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport) router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport) @@ -182,12 +182,13 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) { router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE - router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // TODO + router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // DONE + } // Search enabled configuration if api.cfg.SearchEnabled { - router.GET("/search", api.authWebAppMiddleware, api.appGetSearchNew) // WIP + router.GET("/search", api.authWebAppMiddleware, api.appGetSearch) // DONE router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO } } @@ -358,13 +359,13 @@ func loggingMiddleware(c *gin.Context) { } // Get username - var auth authData + var auth *authData if data, _ := c.Get("Authorization"); data != nil { - auth = data.(authData) + auth = data.(*authData) } // Log user - if auth.UserName != "" { + if auth != nil && auth.UserName != "" { logData["user"] = auth.UserName } diff --git a/api/app-routes-new.go b/api/app-routes-new.go index 76661f6..7bb85b3 100644 --- a/api/app-routes-new.go +++ b/api/app-routes-new.go @@ -2,6 +2,7 @@ package api import ( "cmp" + "crypto/md5" "fmt" "math" "net/http" @@ -9,6 +10,7 @@ import ( "strings" "time" + argon2 "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "reichard.io/antholume/database" @@ -18,20 +20,19 @@ import ( "reichard.io/antholume/pkg/sliceutils" "reichard.io/antholume/pkg/utils" "reichard.io/antholume/search" - "reichard.io/antholume/web/components/layout" "reichard.io/antholume/web/components/stats" "reichard.io/antholume/web/models" "reichard.io/antholume/web/pages" ) -func (api *API) appGetHomeNew(c *gin.Context) { +func (api *API) appGetHome(c *gin.Context) { _, auth := api.getBaseTemplateVars("home", c) start := time.Now() dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName) if err != nil { - log.Error("GetDailyReadStats DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err)) + log.WithError(err).Error("failed to get daily read stats") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get daily read stats: %s", err)) return } log.Debug("GetDailyReadStats DB Performance: ", time.Since(start)) @@ -39,8 +40,8 @@ func (api *API) appGetHomeNew(c *gin.Context) { start = time.Now() databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName) if err != nil { - log.Error("GetDatabaseInfo DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err)) + log.WithError(err).Error("failed to get database info") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get database info: %s", err)) return } log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start)) @@ -48,8 +49,8 @@ func (api *API) appGetHomeNew(c *gin.Context) { start = time.Now() streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName) if err != nil { - log.Error("GetUserStreaks DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err)) + log.WithError(err).Error("failed to get user streaks") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user streaks: %s", err)) return } log.Debug("GetUserStreaks DB Performance: ", time.Since(start)) @@ -57,35 +58,27 @@ func (api *API) appGetHomeNew(c *gin.Context) { start = time.Now() userStatistics, err := api.db.Queries.GetUserStatistics(c) if err != nil { - log.Error("GetUserStatistics DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err)) + log.WithError(err).Error("failed to get user statistics") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user statistics: %s", err)) return } log.Debug("GetUserStatistics DB Performance: ", time.Since(start)) - err = layout.Layout( - pages.Home{ - Leaderboard: arrangeUserStatisticsNew(userStatistics), - Streaks: streaks, - DailyStats: dailyStats, - RecordInfo: &databaseInfo, - }, - layout.LayoutOptions{ - Username: auth.UserName, - IsAdmin: auth.IsAdmin, - SearchEnabled: api.cfg.SearchEnabled, - Version: api.cfg.Version, - }, - ).Render(c.Writer) - if err != nil { - log.Error("Render Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) - } + api.renderPage(c, &pages.Home{ + Leaderboard: arrangeUserStatistic(userStatistics), + Streaks: streaks, + DailyStats: dailyStats, + RecordInfo: &databaseInfo, + }) } -func (api *API) appGetDocumentsNew(c *gin.Context) { - _, auth := api.getBaseTemplateVars("documents", c) - qParams := bindQueryParams(c, 9) +func (api *API) appGetDocuments(c *gin.Context) { + qParams, err := bindQueryParams(c, 9) + if err != nil { + log.WithError(err).Error("failed to bind query params") + appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err)) + return + } var query *string if qParams.Search != nil && *qParams.Search != "" { @@ -93,6 +86,7 @@ func (api *API) appGetDocumentsNew(c *gin.Context) { query = &search } + _, auth := api.getBaseTemplateVars("documents", c) documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{ UserID: auth.UserName, Query: query, @@ -101,170 +95,114 @@ func (api *API) appGetDocumentsNew(c *gin.Context) { Limit: *qParams.Limit, }) if err != nil { - log.Error("GetDocumentsWithStats DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err)) + log.WithError(err).Error("failed to get documents with stats") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get documents with stats: %s", err)) return } length, err := api.db.Queries.GetDocumentsSize(c, query) if err != nil { - log.Error("GetDocumentsSize DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err)) + log.WithError(err).Error("failed to get document sizes") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document sizes: %s", err)) return } if err = api.getDocumentsWordCount(c, documents); err != nil { - log.Error("Unable to Get Word Counts: ", err) + log.WithError(err).Error("failed to get word counts") } totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit))) nextPage := *qParams.Page + 1 previousPage := *qParams.Page - 1 - err = layout.Layout( - pages.Documents{ - Data: sliceutils.Map(documents, convertDBDocToUI), - Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0), - Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0), - Limit: int(ptr.Deref(qParams.Limit)), - }, - layout.LayoutOptions{ - Username: auth.UserName, - IsAdmin: auth.IsAdmin, - SearchEnabled: api.cfg.SearchEnabled, - Version: api.cfg.Version, - }, - ).Render(c.Writer) - if err != nil { - log.Error("Render Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) - } + api.renderPage(c, pages.Documents{ + Data: sliceutils.Map(documents, convertDBDocToUI), + Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0), + Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0), + Limit: int(ptr.Deref(qParams.Limit)), + }) } -func (api *API) appGetDocumentNew(c *gin.Context) { - _, auth := api.getBaseTemplateVars("document", c) - +func (api *API) appGetDocument(c *gin.Context) { var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { - log.Error("Invalid URI Bind") + log.WithError(err).Error("failed to bind URI") appErrorPage(c, http.StatusNotFound, "Invalid document") return } + _, auth := api.getBaseTemplateVars("document", c) document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName) if err != nil { - log.Error("GetDocument DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err)) + log.WithError(err).Error("failed to get document") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err)) return } - err = layout.Layout( - pages.Document{ - Data: convertDBDocToUI(*document), - }, - layout.LayoutOptions{ - Username: auth.UserName, - IsAdmin: auth.IsAdmin, - SearchEnabled: api.cfg.SearchEnabled, - Version: api.cfg.Version, - }, - ).Render(c.Writer) - if err != nil { - log.Error("Render Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) - } + api.renderPage(c, &pages.Document{Data: convertDBDocToUI(*document)}) } -func (api *API) appGetActivityNew(c *gin.Context) { +func (api *API) appGetActivity(c *gin.Context) { + qParams, err := bindQueryParams(c, 15) + if err != nil { + log.WithError(err).Error("failed to bind query params") + appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err)) + return + } + _, auth := api.getBaseTemplateVars("activity", c) - qParams := bindQueryParams(c, 15) - - activityFilter := database.GetActivityParams{ - UserID: auth.UserName, - Offset: (*qParams.Page - 1) * *qParams.Limit, - Limit: *qParams.Limit, - } - - if qParams.Document != nil { - activityFilter.DocFilter = true - activityFilter.DocumentID = *qParams.Document - } - - activity, err := api.db.Queries.GetActivity(c, activityFilter) + activity, err := api.db.Queries.GetActivity(c, database.GetActivityParams{ + UserID: auth.UserName, + Offset: (*qParams.Page - 1) * *qParams.Limit, + Limit: *qParams.Limit, + DocFilter: qParams.Document != nil, + DocumentID: ptr.Deref(qParams.Document), + }) if err != nil { - log.Error("GetActivity DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err)) + log.WithError(err).Error("failed to get activity") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get activity: %s", err)) return } - err = layout.Layout( - pages.Activity{ - Data: sliceutils.Map(activity, convertDBActivityToUI), - }, - layout.LayoutOptions{ - Username: auth.UserName, - IsAdmin: auth.IsAdmin, - SearchEnabled: api.cfg.SearchEnabled, - Version: api.cfg.Version, - }, - ).Render(c.Writer) - if err != nil { - log.Error("Render Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) - } + api.renderPage(c, &pages.Activity{Data: sliceutils.Map(activity, convertDBActivityToUI)}) } -func (api *API) appGetProgressNew(c *gin.Context) { - _, auth := api.getBaseTemplateVars("progress", c) - - qParams := bindQueryParams(c, 15) - - progressFilter := database.GetProgressParams{ - UserID: auth.UserName, - Offset: (*qParams.Page - 1) * *qParams.Limit, - Limit: *qParams.Limit, - } - - if qParams.Document != nil { - progressFilter.DocFilter = true - progressFilter.DocumentID = *qParams.Document - } - - progress, err := api.db.Queries.GetProgress(c, progressFilter) +func (api *API) appGetProgress(c *gin.Context) { + qParams, err := bindQueryParams(c, 15) if err != nil { - log.Error("GetProgress DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err)) + log.WithError(err).Error("failed to bind query params") + appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err)) return } - err = layout.Layout( - pages.Progress{ - Data: sliceutils.Map(progress, convertDBProgressToUI), - }, - layout.LayoutOptions{ - Username: auth.UserName, - IsAdmin: auth.IsAdmin, - SearchEnabled: api.cfg.SearchEnabled, - Version: api.cfg.Version, - }, - ).Render(c.Writer) + _, auth := api.getBaseTemplateVars("progress", c) + progress, err := api.db.Queries.GetProgress(c, database.GetProgressParams{ + UserID: auth.UserName, + Offset: (*qParams.Page - 1) * *qParams.Limit, + Limit: *qParams.Limit, + DocFilter: qParams.Document != nil, + DocumentID: ptr.Deref(qParams.Document), + }) if err != nil { - log.Error("Render Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) + log.WithError(err).Error("failed to get progress") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get progress: %s", err)) + return } + + api.renderPage(c, &pages.Progress{Data: sliceutils.Map(progress, convertDBProgressToUI)}) } func (api *API) appIdentifyDocumentNew(c *gin.Context) { var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { - log.Error("Invalid URI Bind") + log.WithError(err).Error("failed to bind URI") appErrorPage(c, http.StatusNotFound, "Invalid document") return } var rDocIdentify requestDocumentIdentify if err := c.ShouldBind(&rDocIdentify); err != nil { - log.Error("Invalid Form Bind") + log.WithError(err).Error("failed to bind form") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") return } @@ -282,15 +220,14 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) { // Validate Values if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil { - log.Error("Invalid Form") + log.Error("invalid or missing form values") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") return } - // Get Template Variables - _, auth := api.getBaseTemplateVars("document", c) - // Get Metadata + var searchResult *models.DocumentMetadata + var allNotifications []*models.Notification metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{ Title: rDocIdentify.Title, Author: rDocIdentify.Author, @@ -298,14 +235,12 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) { ISBN13: rDocIdentify.ISBN, }) if err != nil { - log.Error("Search Metadata Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Metadata Error: %v", err)) + log.WithError(err).Error("failed to search metadata") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to search metadata: %s", err)) return - } + } else if firstResult, found := sliceutils.First(metadataResults); found { + searchResult = convertMetaToUI(firstResult) - var errorMsg *string - firstResult, found := sliceutils.First(metadataResults) - if found { // Store First Metadata Result if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{ DocumentID: rDocID.DocumentID, @@ -313,52 +248,42 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) { Author: firstResult.Author, Description: firstResult.Description, Gbid: firstResult.SourceID, - Olid: nil, Isbn10: firstResult.ISBN10, Isbn13: firstResult.ISBN13, }); err != nil { - log.Error("AddMetadata DB Error: ", err) + log.WithError(err).Error("failed to add metadata") } } else { - errorMsg = ptr.Of("No Metadata Found") + allNotifications = append(allNotifications, &models.Notification{ + Type: models.NotificationTypeError, + Content: "No Metadata Found", + }) } + // Get Auth + _, auth := api.getBaseTemplateVars("document", c) document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName) if err != nil { - log.Error("GetDocument DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err)) + log.WithError(err).Error("failed to get document") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err)) return } - err = layout.Layout( - pages.Document{ - Data: convertDBDocToUI(*document), - Search: convertMetaToUI(firstResult, errorMsg), - }, - layout.LayoutOptions{ - Username: auth.UserName, - IsAdmin: auth.IsAdmin, - SearchEnabled: api.cfg.SearchEnabled, - Version: api.cfg.Version, - }, - ).Render(c.Writer) - if err != nil { - log.Error("Render Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) - } + api.renderPage(c, &pages.Document{ + Data: convertDBDocToUI(*document), + Search: searchResult, + }, allNotifications...) } // Tabs: // - General (Import, Backup & Restore, Version (githash?), Stats?) // - Users // - Metadata -func (api *API) appGetSearchNew(c *gin.Context) { - _, auth := api.getBaseTemplateVars("search", c) - +func (api *API) appGetSearch(c *gin.Context) { var sParams searchParams - err := c.BindQuery(&sParams) - if err != nil { - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) + if err := c.BindQuery(&sParams); err != nil { + log.WithError(err).Error("failed to bind form") + appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") return } @@ -368,6 +293,7 @@ func (api *API) appGetSearchNew(c *gin.Context) { if sParams.Query != nil && sParams.Source != nil { results, err := search.SearchBook(*sParams.Query, *sParams.Source) if err != nil { + log.WithError(err).Error("failed to search book") appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err)) return } @@ -376,23 +302,159 @@ func (api *API) appGetSearchNew(c *gin.Context) { searchError = "Invailid Query" } - err = layout.Layout( - pages.Search{ - Results: searchResults, - Source: ptr.Deref(sParams.Source), - Query: ptr.Deref(sParams.Query), - Error: searchError, - }, - layout.LayoutOptions{ - Username: auth.UserName, - IsAdmin: auth.IsAdmin, - SearchEnabled: api.cfg.SearchEnabled, - Version: api.cfg.Version, - }, - ).Render(c.Writer) + api.renderPage(c, &pages.Search{ + Results: searchResults, + Source: ptr.Deref(sParams.Source), + Query: ptr.Deref(sParams.Query), + Error: searchError, + }) +} + +func (api *API) appGetSettings(c *gin.Context) { + _, auth := api.getBaseTemplateVars("settings", c) + + user, err := api.db.Queries.GetUser(c, auth.UserName) if err != nil { - log.Error("Render Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) + log.WithError(err).Error("failed to get user") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err)) + return + } + + devices, err := api.db.Queries.GetDevices(c, auth.UserName) + if err != nil { + log.WithError(err).Error("failed to get devices") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err)) + return + } + + api.renderPage(c, &pages.Settings{ + Timezone: ptr.Deref(user.Timezone), + Devices: sliceutils.Map(devices, convertDBDeviceToUI), + }) +} + +func (api *API) appEditSettings(c *gin.Context) { + var rUserSettings requestSettingsEdit + if err := c.ShouldBind(&rUserSettings); err != nil { + log.WithError(err).Error("failed to bind form") + appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") + return + } + + // Validate Something Exists + if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil { + log.Error("invalid or missing form values") + appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") + return + } + + _, auth := api.getBaseTemplateVars("settings", c) + + newUserSettings := database.UpdateUserParams{ + UserID: auth.UserName, + Admin: auth.IsAdmin, + } + + // Set New Password + var allNotifications []*models.Notification + if rUserSettings.Password != nil && rUserSettings.NewPassword != nil { + password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password))) + if _, err := api.authorizeCredentials(c, auth.UserName, password); err != nil { + allNotifications = append(allNotifications, &models.Notification{ + Type: models.NotificationTypeError, + Content: "Invalid Password", + }) + } else { + password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword))) + hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) + if err != nil { + allNotifications = append(allNotifications, &models.Notification{ + Type: models.NotificationTypeError, + Content: "Unknown Error", + }) + } else { + allNotifications = append(allNotifications, &models.Notification{ + Type: models.NotificationTypeSuccess, + Content: "Password Updated", + }) + newUserSettings.Password = &hashedPassword + } + } + } + + // Set Time Offset + if rUserSettings.Timezone != nil { + allNotifications = append(allNotifications, &models.Notification{ + Type: models.NotificationTypeSuccess, + Content: "Time Offset Updated", + }) + newUserSettings.Timezone = rUserSettings.Timezone + } + + // Update User + _, err := api.db.Queries.UpdateUser(c, newUserSettings) + if err != nil { + log.WithError(err).Error("failed to update user") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to update user: %s", err)) + return + } + + // Get User + user, err := api.db.Queries.GetUser(c, auth.UserName) + if err != nil { + log.WithError(err).Error("failed to get user") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err)) + return + } + + // Get Devices + devices, err := api.db.Queries.GetDevices(c, auth.UserName) + if err != nil { + log.WithError(err).Error("failed to get devices") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err)) + return + } + + api.renderPage(c, &pages.Settings{ + Devices: sliceutils.Map(devices, convertDBDeviceToUI), + Timezone: ptr.Deref(user.Timezone), + }, allNotifications...) +} + +func (api *API) renderPage(c *gin.Context, page pages.Page, notifications ...*models.Notification) { + // Get Authentication Data + auth, err := getAuthData(c) + if err != nil { + log.WithError(err).Error("failed to acquire auth data") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to acquire auth data: %s", err)) + return + } + + // Generate Page + pageNode, err := page.Generate(models.PageContext{ + UserInfo: &models.UserInfo{ + Username: auth.UserName, + IsAdmin: auth.IsAdmin, + }, + ServerInfo: &models.ServerInfo{ + RegistrationEnabled: api.cfg.RegistrationEnabled, + SearchEnabled: api.cfg.SearchEnabled, + Version: api.cfg.Version, + }, + Notifications: notifications, + }) + if err != nil { + log.WithError(err).Error("failed to generate page") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to generate page: %s", err)) + return + } + + // Render Page + err = pageNode.Render(c.Writer) + if err != nil { + log.WithError(err).Error("failed to render page") + appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to render page: %s", err)) + return } } @@ -415,7 +477,7 @@ func sortItem[T cmp.Ordered]( return items } -func arrangeUserStatisticsNew(data []database.GetUserStatisticsRow) []stats.LeaderboardData { +func arrangeUserStatistic(data []database.GetUserStatisticsRow) []stats.LeaderboardData { wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) } return []stats.LeaderboardData{ { diff --git a/api/app-routes.go b/api/app-routes.go index e053a2f..48cb8e7 100644 --- a/api/app-routes.go +++ b/api/app-routes.go @@ -2,7 +2,6 @@ package api import ( "context" - "crypto/md5" "database/sql" "fmt" "io" @@ -13,7 +12,6 @@ import ( "strings" "time" - argon2 "github.com/alexedwards/argon2id" "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" @@ -97,31 +95,6 @@ func (api *API) appDocumentReader(c *gin.Context) { c.FileFromFS("assets/reader/index.htm", http.FS(api.assets)) } -func (api *API) appGetSettings(c *gin.Context) { - templateVars, auth := api.getBaseTemplateVars("settings", c) - - user, err := api.db.Queries.GetUser(c, auth.UserName) - if err != nil { - log.Error("GetUser DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err)) - return - } - - devices, err := api.db.Queries.GetDevices(c, auth.UserName) - if err != nil { - log.Error("GetDevices DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err)) - return - } - - templateVars["Data"] = gin.H{ - "Timezone": *user.Timezone, - "Devices": devices, - } - - c.HTML(http.StatusOK, "page/settings", templateVars) -} - func (api *API) appGetLogin(c *gin.Context) { templateVars, _ := api.getBaseTemplateVars("login", c) templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled @@ -539,84 +512,6 @@ func (api *API) appSaveNewDocument(c *gin.Context) { }) } -func (api *API) appEditSettings(c *gin.Context) { - var rUserSettings requestSettingsEdit - if err := c.ShouldBind(&rUserSettings); err != nil { - log.Error("Invalid Form Bind") - appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") - return - } - - // Validate Something Exists - if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil { - log.Error("Missing Form Values") - appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") - return - } - - templateVars, auth := api.getBaseTemplateVars("settings", c) - - newUserSettings := database.UpdateUserParams{ - UserID: auth.UserName, - Admin: auth.IsAdmin, - } - - // Set New Password - if rUserSettings.Password != nil && rUserSettings.NewPassword != nil { - password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password))) - data := api.authorizeCredentials(c, auth.UserName, password) - if data == nil { - templateVars["PasswordErrorMessage"] = "Invalid Password" - } else { - password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword))) - hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) - if err != nil { - templateVars["PasswordErrorMessage"] = "Unknown Error" - } else { - templateVars["PasswordMessage"] = "Password Updated" - newUserSettings.Password = &hashedPassword - } - } - } - - // Set Time Offset - if rUserSettings.Timezone != nil { - templateVars["TimeOffsetMessage"] = "Time Offset Updated" - newUserSettings.Timezone = rUserSettings.Timezone - } - - // Update User - _, err := api.db.Queries.UpdateUser(c, newUserSettings) - if err != nil { - log.Error("UpdateUser DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err)) - return - } - - // Get User - user, err := api.db.Queries.GetUser(c, auth.UserName) - if err != nil { - log.Error("GetUser DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err)) - return - } - - // Get Devices - devices, err := api.db.Queries.GetDevices(c, auth.UserName) - if err != nil { - log.Error("GetDevices DB Error: ", err) - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err)) - return - } - - templateVars["Data"] = gin.H{ - "Timezone": *user.Timezone, - "Devices": devices, - } - - c.HTML(http.StatusOK, "page/settings", templateVars) -} - func (api *API) appDemoModeError(c *gin.Context) { appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode") } @@ -664,10 +559,10 @@ func (api *API) getDocumentsWordCount(ctx context.Context, documents []database. return nil } -func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, authData) { - var auth authData +func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *authData) { + var auth *authData if data, _ := c.Get("Authorization"); data != nil { - auth = data.(authData) + auth = data.(*authData) } return gin.H{ @@ -681,12 +576,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au }, auth } -func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { +func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) { var qParams queryParams err := c.BindQuery(&qParams) if err != nil { - appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) - return qParams + return nil, err } if qParams.Limit == nil { @@ -701,7 +595,7 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { qParams.Page = &oneValue } - return qParams + return &qParams, nil } func appErrorPage(c *gin.Context, errorCode int, errorMessage string) { diff --git a/api/auth.go b/api/auth.go index 18d6b63..f7e8e8d 100644 --- a/api/auth.go +++ b/api/auth.go @@ -30,31 +30,31 @@ type authKOHeader struct { AuthKey string `header:"x-auth-key"` } -func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) { +func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (*authData, error) { user, err := api.db.Queries.GetUser(ctx, username) if err != nil { - return + return nil, err } if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match { - return + return nil, err } - // Update auth cache + // Update Auth Cache api.userAuthCache[user.ID] = *user.AuthHash return &authData{ UserName: user.ID, IsAdmin: user.Admin, AuthHash: *user.AuthHash, - } + }, nil } func (api *API) authKOMiddleware(c *gin.Context) { session := sessions.Default(c) // Check Session First - if auth, ok := api.getSession(c, session); ok { + if auth, ok := api.authorizeSession(c, session); ok { c.Set("Authorization", auth) c.Header("Cache-Control", "private") c.Next() @@ -65,21 +65,25 @@ func (api *API) authKOMiddleware(c *gin.Context) { var rHeader authKOHeader if err := c.ShouldBindHeader(&rHeader); err != nil { + log.WithError(err).Error("failed to bind auth headers") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"}) return } if rHeader.AuthUser == "" || rHeader.AuthKey == "" { + log.Error("invalid authentication headers") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) return } - authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey) - if authData == nil { + authData, err := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey) + if err != nil { + log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to authorize credentials") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } - if err := api.setSession(session, *authData); err != nil { + if err := api.setSession(session, authData); err != nil { + log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to set session") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } @@ -96,14 +100,16 @@ func (api *API) authOPDSMiddleware(c *gin.Context) { // Validate Auth Fields if !hasAuth || user == "" || rawPassword == "" { + log.Error("invalid authorization headers") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) return } // Validate Auth password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) - authData := api.authorizeCredentials(c, user, password) - if authData == nil { + authData, err := api.authorizeCredentials(c, user, password) + if err != nil { + log.WithField("user", user).WithError(err).Error("failed to authorize credentials") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } @@ -117,7 +123,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) { session := sessions.Default(c) // Check Session - if auth, ok := api.getSession(c, session); ok { + if auth, ok := api.authorizeSession(c, session); ok { c.Set("Authorization", auth) c.Header("Cache-Control", "private") c.Next() @@ -130,7 +136,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) { func (api *API) authAdminWebAppMiddleware(c *gin.Context) { if data, _ := c.Get("Authorization"); data != nil { - auth := data.(authData) + auth := data.(*authData) if auth.IsAdmin { c.Next() return @@ -155,8 +161,9 @@ func (api *API) appAuthLogin(c *gin.Context) { // MD5 - KOSync Compatiblity password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) - authData := api.authorizeCredentials(c, username, password) - if authData == nil { + authData, err := api.authorizeCredentials(c, username, password) + if err != nil { + log.WithField("user", username).WithError(err).Error("failed to authorize credentials") templateVars["Error"] = "Invalid Credentials" c.HTML(http.StatusUnauthorized, "page/login", templateVars) return @@ -164,7 +171,7 @@ func (api *API) appAuthLogin(c *gin.Context) { // Set Session session := sessions.Default(c) - if err := api.setSession(session, *authData); err != nil { + if err := api.setSession(session, authData); err != nil { templateVars["Error"] = "Invalid Credentials" c.HTML(http.StatusUnauthorized, "page/login", templateVars) return @@ -253,7 +260,7 @@ func (api *API) appAuthRegister(c *gin.Context) { } // Set session - auth := authData{ + auth := &authData{ UserName: user.ID, IsAdmin: user.Admin, AuthHash: *user.AuthHash, @@ -349,35 +356,40 @@ func (api *API) koAuthRegister(c *gin.Context) { }) } -func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) { +func (api *API) authorizeSession(ctx context.Context, session sessions.Session) (*authData, bool) { // Get Session authorizedUser := session.Get("authorizedUser") isAdmin := session.Get("isAdmin") expiresAt := session.Get("expiresAt") authHash := session.Get("authHash") if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil { - return + return nil, false } // Create Auth Object - auth = authData{ + auth := &authData{ UserName: authorizedUser.(string), IsAdmin: isAdmin.(bool), AuthHash: authHash.(string), } + logger := log.WithField("user", auth.UserName) // Validate Auth Hash correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName) - if err != nil || correctAuthHash != auth.AuthHash { - return + if err != nil { + logger.WithError(err).Error("failed to get auth hash") + return nil, false + } else if correctAuthHash != auth.AuthHash { + logger.Warn("user auth hash mismatch") + return nil, false } // Refresh if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { - log.Info("Refreshing Session") + logger.Info("refreshing session") if err := api.setSession(session, auth); err != nil { - log.Error("unable to get session") - return + logger.WithError(err).Error("failed to refresh session") + return nil, false } } @@ -385,7 +397,7 @@ func (api *API) getSession(ctx context.Context, session sessions.Session) (auth return auth, true } -func (api *API) setSession(session sessions.Session, auth authData) error { +func (api *API) setSession(session sessions.Session, auth *authData) error { // Set Session Cookie session.Set("authorizedUser", auth.UserName) session.Set("isAdmin", auth.IsAdmin) diff --git a/api/convert.go b/api/convert.go index 0367f44..af98b46 100644 --- a/api/convert.go +++ b/api/convert.go @@ -28,7 +28,7 @@ func convertDBDocToUI(r database.GetDocumentsWithStatsRow) models.Document { } } -func convertMetaToUI(m metadata.MetadataInfo, errorMsg *string) *models.DocumentMetadata { +func convertMetaToUI(m metadata.MetadataInfo) *models.DocumentMetadata { return &models.DocumentMetadata{ SourceID: ptr.Deref(m.SourceID), ISBN10: ptr.Deref(m.ISBN10), @@ -37,7 +37,6 @@ func convertMetaToUI(m metadata.MetadataInfo, errorMsg *string) *models.Document Author: ptr.Deref(m.Author), Description: ptr.Deref(m.Description), Source: m.Source, - Error: errorMsg, } } @@ -63,6 +62,14 @@ func convertDBProgressToUI(r database.GetProgressRow) models.Progress { } } +func convertDBDeviceToUI(r database.GetDevicesRow) models.Device { + return models.Device{ + DeviceName: r.DeviceName, + LastSynced: r.LastSynced, + CreatedAt: r.CreatedAt, + } +} + func convertSearchToUI(r search.SearchItem) models.SearchResult { return models.SearchResult{ ID: r.ID, diff --git a/api/opds-routes.go b/api/opds-routes.go index 17cceea..2644587 100644 --- a/api/opds-routes.go +++ b/api/opds-routes.go @@ -62,13 +62,19 @@ func (api *API) opdsEntry(c *gin.Context) { } func (api *API) opdsDocuments(c *gin.Context) { - var auth authData - if data, _ := c.Get("Authorization"); data != nil { - auth = data.(authData) + auth, err := getAuthData(c) + if err != nil { + log.WithError(err).Error("failed to acquire auth data") + c.AbortWithStatus(http.StatusInternalServerError) } // Potential URL Parameters (Default Pagination - 100) - qParams := bindQueryParams(c, 100) + qParams, err := bindQueryParams(c, 100) + if err != nil { + log.WithError(err).Error("failed to bind query params") + c.AbortWithStatus(http.StatusBadRequest) + return + } // Possible Query var query *string @@ -86,7 +92,7 @@ func (api *API) opdsDocuments(c *gin.Context) { Limit: *qParams.Limit, }) if err != nil { - log.Error("GetDocumentsWithStats DB Error:", err) + log.WithError(err).Error("failed to get documents with stats") c.AbortWithStatus(http.StatusBadRequest) return } diff --git a/api/utils.go b/api/utils.go index 3fb3e4a..72a8e92 100644 --- a/api/utils.go +++ b/api/utils.go @@ -8,11 +8,22 @@ import ( "reflect" "strings" + "github.com/gin-gonic/gin" "reichard.io/antholume/database" "reichard.io/antholume/graph" "reichard.io/antholume/metadata" ) +func getAuthData(ctx *gin.Context) (*authData, error) { + if data, ok := ctx.Get("Authorization"); ok { + var auth *authData + if auth, ok = data.(*authData); ok { + return auth, nil + } + } + return nil, errors.New("could not acquire auth data") +} + // getTimeZones returns a string slice of IANA timezones. func getTimeZones() []string { return []string{ diff --git a/assets/tailwind.css b/assets/tailwind.css index dfab08c..ab4fd59 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -1 +1 @@ -*,::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: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}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-feature-settings:normal;font-size:1em;font-variation-settings:normal}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{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-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]:where(:not([hidden=until-found])){display:none}.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}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.-bottom-1{bottom:-.25rem}.-bottom-28{bottom:-7rem}.-bottom-5{bottom:-1.25rem}.-top-1{top:-.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\.5{top:.375rem}.top-1\/2{top:50%}.top-10{top:2.5rem}.top-16{top:4rem}.top-3{top:.75rem}.top-6{top:1.5rem}.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-bottom:.5rem;margin-top:.5rem}.my-6{margin-bottom:1.5rem;margin-top:1.5rem}.my-auto{margin-bottom:auto;margin-top: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-2{margin-left:.5rem}.ml-6{margin-left:1.5rem}.mr-4{margin-right:1rem}.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\.5{height:.125rem}.h-10{height:2.5rem}.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-\[50\%\]{max-height:50%}.max-h-\[75vh\]{max-height:75vh}.max-h-\[95\%\]{max-height:95%}.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-\[19rem\]{width:19rem}.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-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-bottom{transform-origin:bottom}.origin-bottom-left{transform-origin:bottom left}.origin-bottom-right{transform-origin:bottom right}.origin-center{transform-origin:center}.origin-left{transform-origin:left}.origin-right{transform-origin:right}.origin-top{transform-origin:top}.origin-top-left{transform-origin:top left}.origin-top-right{transform-origin:top right}.-translate-x-1\/2,.-translate-x-2\/4{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-x-2\/4,.-translate-x-full{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-x-full{--tw-translate-x:-100%}.-translate-y-1\/2,.-translate-y-2\/4{--tw-translate-y:-50%}.-translate-y-1\/2,.-translate-y-2\/4,.-translate-y-full{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-full{--tw-translate-y:-100%}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-full{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-full{--tw-translate-y:100%}.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-default{cursor:default}.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}.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-3{gap:.75rem}.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-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*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}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-nowrap{text-wrap:nowrap}.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-xl{border-radius:.75rem}.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,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-transparent{border-color:#0000}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.bg-\[\#000\]{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-\[\#1f2937\]{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-\[\#232323\]{--tw-bg-opacity:1;background-color:rgb(35 35 35/var(--tw-bg-opacity,1))}.bg-\[\#d2b48c\]{--tw-bg-opacity:1;background-color:rgb(210 180 140/var(--tw-bg-opacity,1))}.bg-\[\#fff\]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-700{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.object-cover{-o-object-fit:cover;object-fit:cover}.object-fill{-o-object-fit:fill;object-fit:fill}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.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-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-12{padding-bottom:3rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.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-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,1))}.text-\[\#333\]{--tw-text-opacity:1;color:rgb(51 51 51/var(--tw-text-opacity,1))}.text-\[\#ccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#fff\]{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.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,1))}.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-duration:.15s;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-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-100{transition-duration:.1s}.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,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-400:hover{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-black:hover{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.hover\:text-gray-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.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,1))}.focus\:ring-purple-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(147 51 234/var(--tw-ring-opacity,1))}.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\/\"\+key\+\":checked~.peer-checked\/\"\+key\+\"\:block,.peer\/add:checked~.peer-checked\/add\:block{display:block}@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\:block{display:block}.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}}@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-60{width:15rem}.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-bottom:4rem;padding-top:4rem}.lg\:pr-0{padding-right:0}.lg\:text-9xl{font-size:8rem;line-height:1}}@media (prefers-color-scheme:dark){.dark\:border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity,1))}.dark\:border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.dark\:border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.dark\:bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.dark\:bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.dark\:bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.dark\:bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.dark\:text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.dark\:text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.dark\:text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.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,1))}.dark\:hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:hover\:text-gray-100:hover{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:focus\:ring-blue-800:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity,1))}} \ No newline at end of file +*,::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: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}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-feature-settings:normal;font-size:1em;font-variation-settings:normal}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{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-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]:where(:not([hidden=until-found])){display:none}.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}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.-bottom-1{bottom:-.25rem}.-bottom-28{bottom:-7rem}.-bottom-5{bottom:-1.25rem}.-top-1{top:-.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\.5{top:.375rem}.top-1\/2{top:50%}.top-10{top:2.5rem}.top-16{top:4rem}.top-3{top:.75rem}.top-6{top:1.5rem}.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-bottom:.5rem;margin-top:.5rem}.my-6{margin-bottom:1.5rem;margin-top:1.5rem}.my-auto{margin-bottom:auto;margin-top: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-2{margin-left:.5rem}.ml-6{margin-left:1.5rem}.mr-4{margin-right:1rem}.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\.5{height:.125rem}.h-10{height:2.5rem}.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-\[50\%\]{max-height:50%}.max-h-\[75vh\]{max-height:75vh}.max-h-\[95\%\]{max-height:95%}.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-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-\[19rem\]{width:19rem}.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-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-bottom{transform-origin:bottom}.origin-bottom-left{transform-origin:bottom left}.origin-bottom-right{transform-origin:bottom right}.origin-center{transform-origin:center}.origin-left{transform-origin:left}.origin-right{transform-origin:right}.origin-top{transform-origin:top}.origin-top-left{transform-origin:top left}.origin-top-right{transform-origin:top right}.-translate-x-1\/2,.-translate-x-2\/4{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-x-2\/4,.-translate-x-full{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-x-full{--tw-translate-x:-100%}.-translate-y-1\/2,.-translate-y-2\/4{--tw-translate-y:-50%}.-translate-y-1\/2,.-translate-y-2\/4,.-translate-y-full{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-full{--tw-translate-y:-100%}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-full{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-full{--tw-translate-y:100%}.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))}@keyframes slideIn{0%{transform:translateX(100%)}to{transform:translateX(0)}}@keyframes slideOut{0%{transform:translateX(0)}to{transform:translateX(100%)}}.animate-notification{animation:slideIn .25s ease-out forwards,slideOut .25s ease-out 4.5s forwards}.cursor-default{cursor:default}.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}.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-3{gap:.75rem}.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-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*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}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-nowrap{text-wrap:nowrap}.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-xl{border-radius:.75rem}.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,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-transparent{border-color:#0000}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.bg-\[\#000\]{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-\[\#1f2937\]{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-\[\#232323\]{--tw-bg-opacity:1;background-color:rgb(35 35 35/var(--tw-bg-opacity,1))}.bg-\[\#d2b48c\]{--tw-bg-opacity:1;background-color:rgb(210 180 140/var(--tw-bg-opacity,1))}.bg-\[\#fff\]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-700{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.object-cover{-o-object-fit:cover;object-fit:cover}.object-fill{-o-object-fit:fill;object-fit:fill}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.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-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-12{padding-bottom:3rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.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-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,1))}.text-\[\#333\]{--tw-text-opacity:1;color:rgb(51 51 51/var(--tw-text-opacity,1))}.text-\[\#ccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#fff\]{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.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,1))}.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-duration:.15s;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-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-100{transition-duration:.1s}.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,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-400:hover{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-black:hover{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.hover\:text-gray-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.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,1))}.focus\:ring-purple-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(147 51 234/var(--tw-ring-opacity,1))}.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\/\"\+key\+\":checked~.peer-checked\/\"\+key\+\"\:block,.peer\/add:checked~.peer-checked\/add\:block{display:block}@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\:p-4{padding:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:block{display:block}.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}}@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-60{width:15rem}.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-bottom:4rem;padding-top:4rem}.lg\:pr-0{padding-right:0}.lg\:text-9xl{font-size:8rem;line-height:1}}@media (prefers-color-scheme:dark){.dark\:border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity,1))}.dark\:border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.dark\:border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.dark\:bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.dark\:bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.dark\:bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.dark\:bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.dark\:bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.dark\:text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.dark\:text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.dark\:text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.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,1))}.dark\:hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:hover\:text-gray-100:hover{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:focus\:ring-blue-800:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity,1))}} \ No newline at end of file diff --git a/database/query.sql b/database/query.sql index 66456a0..ff9ce88 100644 --- a/database/query.sql +++ b/database/query.sql @@ -138,8 +138,8 @@ WHERE id = $device_id LIMIT 1; SELECT devices.id, devices.device_name, - LOCAL_TIME(devices.created_at, users.timezone) AS created_at, - LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced + CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at, + CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced FROM devices JOIN users ON users.id = devices.user_id WHERE users.id = $user_id diff --git a/database/query.sql.go b/database/query.sql.go index 99150ec..fc3d971 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -422,8 +422,8 @@ const getDevices = `-- name: GetDevices :many SELECT devices.id, devices.device_name, - LOCAL_TIME(devices.created_at, users.timezone) AS created_at, - LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced + CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at, + CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced FROM devices JOIN users ON users.id = devices.user_id WHERE users.id = ?1 @@ -431,10 +431,10 @@ ORDER BY devices.last_synced DESC ` type GetDevicesRow struct { - ID string `json:"id"` - DeviceName string `json:"device_name"` - CreatedAt interface{} `json:"created_at"` - LastSynced interface{} `json:"last_synced"` + ID string `json:"id"` + DeviceName string `json:"device_name"` + CreatedAt string `json:"created_at"` + LastSynced string `json:"last_synced"` } func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) { diff --git a/search/search.go b/search/search.go index c29e3a6..b522cf9 100644 --- a/search/search.go +++ b/search/search.go @@ -58,7 +58,6 @@ func SearchBook(query string, source Source) ([]SearchItem, error) { if !found { return nil, fmt.Errorf("invalid source: %s", source) } - log.Debug("Source: ", source) return searchFunc(query) } diff --git a/tailwind.config.js b/tailwind.config.js index 36d990f..b1b2ef6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -17,6 +17,20 @@ module.exports = { minWidth: { 40: "10rem", }, + animation: { + notification: + "slideIn 0.25s ease-out forwards, slideOut 0.25s ease-out 4.5s forwards", + }, + keyframes: { + slideIn: { + "0%": { transform: "translateX(100%)" }, + "100%": { transform: "translateX(0)" }, + }, + slideOut: { + "0%": { transform: "translateX(0)" }, + "100%": { transform: "translateX(100%)" }, + }, + }, }, }, plugins: [], diff --git a/web/assets/svgs/password.svg b/web/assets/svgs/password.svg index cd25754..512817d 100644 --- a/web/assets/svgs/password.svg +++ b/web/assets/svgs/password.svg @@ -1 +1 @@ - + diff --git a/web/components/document/identify_popover.go b/web/components/document/identify.go similarity index 90% rename from web/components/document/identify_popover.go rename to web/components/document/identify.go index 093371d..1d0758e 100644 --- a/web/components/document/identify_popover.go +++ b/web/components/document/identify.go @@ -15,21 +15,6 @@ func IdentifyPopover(docID string, m *models.DocumentMetadata) g.Node { return nil } - if m.Error != nil { - return ui.Popover(h.Div( - h.Class("flex flex-col gap-2"), - h.H3( - h.Class("text-lg font-bold text-center"), - g.Text("Error"), - ), - h.Div( - h.Class("bg-gray-100 dark:bg-gray-900 p-2"), - h.P(g.Text(*m.Error)), - ), - ui.LinkButton(g.Text("Back to Document"), fmt.Sprintf("/documents/%s", docID)), - )) - } - return ui.Popover(h.Div( h.Class("flex flex-col gap-2"), h.H3( diff --git a/web/components/ui/notification.go b/web/components/ui/notification.go new file mode 100644 index 0000000..3d3308d --- /dev/null +++ b/web/components/ui/notification.go @@ -0,0 +1,25 @@ +package ui + +import ( + g "maragu.dev/gomponents" + h "maragu.dev/gomponents/html" + "reichard.io/antholume/pkg/sliceutils" + "reichard.io/antholume/web/models" +) + +func Notifications(notifications []*models.Notification) g.Node { + if len(notifications) == 0 { + return nil + } + return h.Div( + h.Class("fixed flex flex-col gap-2 bottom-0 right-0 p-2 sm:p-4 text-white dark:text-black"), + g.Group(sliceutils.Map(notifications, notificationNode)), + ) +} + +func notificationNode(n *models.Notification) g.Node { + return h.Div( + h.Class("bg-gray-600 dark:bg-gray-400 px-4 py-2 rounded-lg shadow-lg w-64 animate-notification"), + h.P(g.Text(n.Content)), + ) +} diff --git a/web/models/device.go b/web/models/device.go new file mode 100644 index 0000000..97d06f6 --- /dev/null +++ b/web/models/device.go @@ -0,0 +1,7 @@ +package models + +type Device struct { + DeviceName string + LastSynced string + CreatedAt string +} diff --git a/web/models/document.go b/web/models/document.go index 9f5ffb9..2302e1a 100644 --- a/web/models/document.go +++ b/web/models/document.go @@ -29,5 +29,4 @@ type DocumentMetadata struct { Author string Description string Source metadata.Source - Error *string } diff --git a/web/models/info.go b/web/models/info.go new file mode 100644 index 0000000..c567802 --- /dev/null +++ b/web/models/info.go @@ -0,0 +1,12 @@ +package models + +type UserInfo struct { + Username string + IsAdmin bool +} + +type ServerInfo struct { + RegistrationEnabled bool + SearchEnabled bool + Version string +} diff --git a/web/models/notification.go b/web/models/notification.go new file mode 100644 index 0000000..01e1797 --- /dev/null +++ b/web/models/notification.go @@ -0,0 +1,13 @@ +package models + +type NotificationType int + +const ( + NotificationTypeSuccess NotificationType = iota + NotificationTypeError +) + +type Notification struct { + Content string + Type NotificationType +} diff --git a/web/models/page.go b/web/models/page.go new file mode 100644 index 0000000..a6cd7e4 --- /dev/null +++ b/web/models/page.go @@ -0,0 +1,52 @@ +package models + +type PageContext struct { + Route PageRoute + UserInfo *UserInfo + ServerInfo *ServerInfo + Notifications []*Notification +} + +func (ctx PageContext) WithRoute(route PageRoute) PageContext { + ctx.Route = route + return ctx +} + +type PageRoute string + +const ( + HomePage PageRoute = "home" + DocumentPage PageRoute = "document" + DocumentsPage PageRoute = "documents" + ProgressPage PageRoute = "progress" + ActivityPage PageRoute = "activity" + SearchPage PageRoute = "search" + SettingsPage PageRoute = "settings" + AdminGeneralPage PageRoute = "admin-general" + AdminImportPage PageRoute = "admin-import" + AdminUsersPage PageRoute = "admin-users" + AdminLogsPage PageRoute = "admin-logs" +) + +var pageTitleMap = map[PageRoute]string{ + HomePage: "Home", + DocumentPage: "Document", + DocumentsPage: "Documents", + ProgressPage: "Progress", + ActivityPage: "Activity", + SearchPage: "Search", + SettingsPage: "Settings", + AdminGeneralPage: "Admin - General", + AdminImportPage: "Admin - Import", + AdminUsersPage: "Admin - Users", + AdminLogsPage: "Admin - Logs", +} + +func (p PageRoute) Title() string { + return pageTitleMap[p] +} + +func (p PageRoute) Valid() bool { + _, ok := pageTitleMap[p] + return ok +} diff --git a/web/pages/activity.go b/web/pages/activity.go index 39a06f1..158a905 100644 --- a/web/pages/activity.go +++ b/web/pages/activity.go @@ -9,6 +9,7 @@ import ( "reichard.io/antholume/pkg/sliceutils" "reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/models" + "reichard.io/antholume/web/pages/layout" ) var _ Page = (*Activity)(nil) @@ -17,14 +18,15 @@ type Activity struct { Data []models.Activity } -func (Activity) Route() PageRoute { return ActivityPage } - -func (p Activity) Render() g.Node { - return h.Div( - h.Class("overflow-x-auto"), +func (p *Activity) Generate(ctx models.PageContext) (g.Node, error) { + return layout.Layout( + ctx.WithRoute(models.ActivityPage), h.Div( - h.Class("inline-block min-w-full overflow-hidden rounded shadow"), - ui.Table(p.buildTableConfig()), + h.Class("overflow-x-auto"), + h.Div( + h.Class("inline-block min-w-full overflow-hidden rounded shadow"), + ui.Table(p.buildTableConfig()), + ), ), ) } diff --git a/web/pages/document.go b/web/pages/document.go index df983c8..0ee2684 100644 --- a/web/pages/document.go +++ b/web/pages/document.go @@ -13,6 +13,7 @@ import ( "reichard.io/antholume/web/components/document" "reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/models" + "reichard.io/antholume/web/pages/layout" ) var _ Page = (*Document)(nil) @@ -22,9 +23,14 @@ type Document struct { Search *models.DocumentMetadata } -func (Document) Route() PageRoute { return DocumentPage } +func (p *Document) Generate(ctx models.PageContext) (g.Node, error) { + return layout.Layout( + ctx.WithRoute(models.DocumentPage), + p.content(), + ) +} -func (p Document) Render() g.Node { +func (p *Document) content() g.Node { return h.Div( h.Class("h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"), document.Actions(p.Data), diff --git a/web/pages/documents.go b/web/pages/documents.go index 923864f..4ab66d2 100644 --- a/web/pages/documents.go +++ b/web/pages/documents.go @@ -9,6 +9,7 @@ import ( "reichard.io/antholume/web/components/document" "reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/models" + "reichard.io/antholume/web/pages/layout" ) var _ Page = (*Documents)(nil) @@ -20,15 +21,13 @@ type Documents struct { Limit int } -func (Documents) Route() PageRoute { return DocumentsPage } - -func (p Documents) Render() g.Node { - return g.Group([]g.Node{ +func (p Documents) Generate(ctx models.PageContext) (g.Node, error) { + return layout.Layout(ctx.WithRoute(models.DocumentsPage), searchBar(), documentGrid(p.Data), pagination(p.Previous, p.Next, p.Limit), uploadFAB(), - }) + ) } func searchBar() g.Node { diff --git a/web/pages/home.go b/web/pages/home.go index 486472e..7a9ebf6 100644 --- a/web/pages/home.go +++ b/web/pages/home.go @@ -5,6 +5,8 @@ import ( h "maragu.dev/gomponents/html" "reichard.io/antholume/database" "reichard.io/antholume/web/components/stats" + "reichard.io/antholume/web/models" + "reichard.io/antholume/web/pages/layout" ) var _ Page = (*Home)(nil) @@ -16,9 +18,11 @@ type Home struct { RecordInfo *database.GetDatabaseInfoRow } -func (Home) Route() PageRoute { return HomePage } +func (p *Home) Generate(ctx models.PageContext) (g.Node, error) { + return layout.Layout(ctx.WithRoute(models.HomePage), p.content()) +} -func (p Home) Render() g.Node { +func (p *Home) content() g.Node { return h.Div( g.Attr("class", "flex flex-col gap-4"), h.Div( diff --git a/web/components/layout/layout.go b/web/pages/layout/layout.go similarity index 70% rename from web/components/layout/layout.go rename to web/pages/layout/layout.go index ca375db..4da1b53 100644 --- a/web/components/layout/layout.go +++ b/web/pages/layout/layout.go @@ -1,35 +1,41 @@ package layout import ( + "errors" + "fmt" + g "maragu.dev/gomponents" h "maragu.dev/gomponents/html" - "reichard.io/antholume/web/pages" + "reichard.io/antholume/web/components/ui" + "reichard.io/antholume/web/models" ) -type LayoutOptions struct { - SearchEnabled bool - IsAdmin bool - Username string - Version string -} +func Layout(ctx models.PageContext, children ...g.Node) (g.Node, error) { + if ctx.UserInfo == nil { + return nil, errors.New("no user info") + } else if ctx.ServerInfo == nil { + return nil, errors.New("no server info") + } else if !ctx.Route.Valid() { + return nil, fmt.Errorf("invalid route: %s", ctx.Route) + } -func Layout(p pages.Page, opts LayoutOptions) g.Node { return h.Doctype( h.HTML( g.Attr("lang", "en"), - Head(p.Route().Title()), + Head(ctx.Route.Title()), h.Body( g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"), - Navigation(p.Route(), &opts), - Base(p.Render()), + Navigation(ctx), + Base(children), + ui.Notifications(ctx.Notifications), ), ), - ) + ), nil } func Head(routeTitle string) g.Node { return h.Head( - h.Title("AnthoLume - "+routeTitle), + g.El("title", g.Text("AnthoLume - "+routeTitle)), h.Meta(g.Attr("charset", "utf-8")), h.Meta(g.Attr("name", "viewport"), g.Attr("content", "width=device-width, initial-scale=0.9, user-scalable=no, viewport-fit=cover")), h.Meta(g.Attr("name", "apple-mobile-web-app-capable"), g.Attr("content", "yes")), @@ -45,13 +51,13 @@ func Head(routeTitle string) g.Node { ) } -func Base(body g.Node) g.Node { +func Base(body []g.Node) g.Node { return h.Main( g.Attr("class", "relative overflow-hidden"), h.Div( g.Attr("id", "container"), g.Attr("class", "h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"), - body, + g.Group(body), ), ) } diff --git a/web/components/layout/navigation.go b/web/pages/layout/navigation.go similarity index 76% rename from web/components/layout/navigation.go rename to web/pages/layout/navigation.go index a2bc6e0..ff60e62 100644 --- a/web/components/layout/navigation.go +++ b/web/pages/layout/navigation.go @@ -6,7 +6,7 @@ import ( g "maragu.dev/gomponents" h "maragu.dev/gomponents/html" "reichard.io/antholume/web/assets" - "reichard.io/antholume/web/pages" + "reichard.io/antholume/web/models" ) const ( @@ -14,29 +14,28 @@ const ( inactive = "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" ) -func Navigation(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node { +func Navigation(ctx models.PageContext) g.Node { return h.Div( g.Attr("class", "flex items-center justify-between w-full h-16"), - Sidebar(currentRoute, opts), - h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(currentRoute.Title())), - Dropdown(opts.Username), + Sidebar(ctx), + h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(ctx.Route.Title())), + Dropdown(ctx.UserInfo.Username), ) } -func Sidebar(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node { +func Sidebar(ctx models.PageContext) g.Node { links := []g.Node{ - navLink(currentRoute, pages.HomePage, "/", "home"), - navLink(currentRoute, pages.DocumentsPage, "/documents", "documents"), - navLink(currentRoute, pages.ProgressPage, "/progress", "activity"), - navLink(currentRoute, pages.ActivityPage, "/activity", "activity"), + navLink(ctx.Route, models.HomePage, "/", "home"), + navLink(ctx.Route, models.DocumentsPage, "/documents", "documents"), + navLink(ctx.Route, models.ProgressPage, "/progress", "activity"), + navLink(ctx.Route, models.ActivityPage, "/activity", "activity"), } - if opts.SearchEnabled { - links = append(links, navLink(currentRoute, pages.SearchPage, "/search", "search")) + if ctx.ServerInfo.SearchEnabled { + links = append(links, navLink(ctx.Route, models.SearchPage, "/search", "search")) } - if opts.IsAdmin { - links = append(links, adminLinks(currentRoute)) + if ctx.UserInfo.IsAdmin { + links = append(links, adminLinks(ctx.Route)) } - return h.Div( g.Attr("id", "mobile-nav-button"), g.Attr("class", "flex flex-col z-40 relative ml-6"), @@ -54,13 +53,13 @@ func Sidebar(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node { g.Attr("target", "_blank"), g.Attr("class", "flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white"), assets.Icon("gitea", 20), - h.Span(g.Attr("class", "text-xs"), g.Text(opts.Version)), + h.Span(g.Attr("class", "text-xs"), g.Text(ctx.ServerInfo.Version)), ), ), ) } -func navLink(currentRoute, linkRoute pages.PageRoute, path, icon string) g.Node { +func navLink(currentRoute, linkRoute models.PageRoute, path, icon string) g.Node { class := inactive if currentRoute == linkRoute { class = active @@ -73,7 +72,7 @@ func navLink(currentRoute, linkRoute pages.PageRoute, path, icon string) g.Node ) } -func adminLinks(currentRoute pages.PageRoute) g.Node { +func adminLinks(currentRoute models.PageRoute) g.Node { routeID := string(currentRoute) class := inactive @@ -83,10 +82,10 @@ func adminLinks(currentRoute pages.PageRoute) g.Node { children := g.If(strings.HasPrefix(routeID, "admin"), g.Group([]g.Node{ - subNavLink(currentRoute, pages.AdminGeneralPage, "/admin"), - subNavLink(currentRoute, pages.AdminImportPage, "/admin/import"), - subNavLink(currentRoute, pages.AdminUsersPage, "/admin/users"), - subNavLink(currentRoute, pages.AdminLogsPage, "/admin/logs"), + subNavLink(currentRoute, models.AdminGeneralPage, "/admin"), + subNavLink(currentRoute, models.AdminImportPage, "/admin/import"), + subNavLink(currentRoute, models.AdminUsersPage, "/admin/users"), + subNavLink(currentRoute, models.AdminLogsPage, "/admin/logs"), }), ) @@ -102,7 +101,7 @@ func adminLinks(currentRoute pages.PageRoute) g.Node { ) } -func subNavLink(currentRoute, linkRoute pages.PageRoute, path string) g.Node { +func subNavLink(currentRoute, linkRoute models.PageRoute, path string) g.Node { class := inactive if currentRoute == linkRoute { class = active diff --git a/web/pages/layout/route.go b/web/pages/layout/route.go new file mode 100644 index 0000000..f53a55d --- /dev/null +++ b/web/pages/layout/route.go @@ -0,0 +1,35 @@ +package layout + +type Route string + +const ( + HomePage Route = "home" + DocumentPage Route = "document" + DocumentsPage Route = "documents" + ProgressPage Route = "progress" + ActivityPage Route = "activity" + SearchPage Route = "search" + SettingsPage Route = "settings" + AdminGeneralPage Route = "admin-general" + AdminImportPage Route = "admin-import" + AdminUsersPage Route = "admin-users" + AdminLogsPage Route = "admin-logs" +) + +var pageTitleMap = map[Route]string{ + HomePage: "Home", + DocumentPage: "Document", + DocumentsPage: "Documents", + ProgressPage: "Progress", + ActivityPage: "Activity", + SearchPage: "Search", + SettingsPage: "Settings", + AdminGeneralPage: "Admin - General", + AdminImportPage: "Admin - Import", + AdminUsersPage: "Admin - Users", + AdminLogsPage: "Admin - Logs", +} + +func (p Route) Title() string { + return pageTitleMap[p] +} diff --git a/web/pages/page.go b/web/pages/page.go index f75a79f..dd70db8 100644 --- a/web/pages/page.go +++ b/web/pages/page.go @@ -2,41 +2,9 @@ package pages import ( g "maragu.dev/gomponents" + "reichard.io/antholume/web/models" ) -type PageRoute string - -const ( - HomePage PageRoute = "home" - DocumentPage PageRoute = "document" - DocumentsPage PageRoute = "documents" - ProgressPage PageRoute = "progress" - ActivityPage PageRoute = "activity" - SearchPage PageRoute = "search" - AdminGeneralPage PageRoute = "admin-general" - AdminImportPage PageRoute = "admin-import" - AdminUsersPage PageRoute = "admin-users" - AdminLogsPage PageRoute = "admin-logs" -) - -var pageTitleMap = map[PageRoute]string{ - HomePage: "Home", - DocumentPage: "Document", - DocumentsPage: "Documents", - ProgressPage: "Progress", - ActivityPage: "Activity", - SearchPage: "Search", - AdminGeneralPage: "Admin - General", - AdminImportPage: "Admin - Import", - AdminUsersPage: "Admin - Users", - AdminLogsPage: "Admin - Logs", -} - -func (p PageRoute) Title() string { - return pageTitleMap[p] -} - type Page interface { - Route() PageRoute - Render() g.Node + Generate(ctx models.PageContext) (g.Node, error) } diff --git a/web/pages/progress.go b/web/pages/progress.go index ec4d6d3..41a7c3e 100644 --- a/web/pages/progress.go +++ b/web/pages/progress.go @@ -8,6 +8,7 @@ import ( "reichard.io/antholume/pkg/sliceutils" "reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/models" + "reichard.io/antholume/web/pages/layout" ) var _ Page = (*Progress)(nil) @@ -16,14 +17,15 @@ type Progress struct { Data []models.Progress } -func (Progress) Route() PageRoute { return ProgressPage } - -func (p Progress) Render() g.Node { - return h.Div( - h.Class("overflow-x-auto"), +func (p *Progress) Generate(ctx models.PageContext) (g.Node, error) { + return layout.Layout( + ctx.WithRoute(models.ProgressPage), h.Div( - h.Class("inline-block min-w-full overflow-hidden rounded shadow"), - ui.Table(p.buildTableConfig()), + h.Class("overflow-x-auto"), + h.Div( + h.Class("inline-block min-w-full overflow-hidden rounded shadow"), + ui.Table(p.buildTableConfig()), + ), ), ) } diff --git a/web/pages/search.go b/web/pages/search.go index 3340eac..40fa353 100644 --- a/web/pages/search.go +++ b/web/pages/search.go @@ -12,6 +12,7 @@ import ( "reichard.io/antholume/web/assets" "reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/models" + "reichard.io/antholume/web/pages/layout" ) var _ Page = (*Search)(nil) @@ -23,9 +24,14 @@ type Search struct { Error string } -func (Search) Route() PageRoute { return SearchPage } +func (p Search) Generate(ctx models.PageContext) (g.Node, error) { + return layout.Layout( + ctx.WithRoute(models.SearchPage), + p.content(), + ) +} -func (p Search) Render() g.Node { +func (p *Search) content() g.Node { return h.Div( h.Class("flex flex-col gap-4"), h.Div( @@ -96,7 +102,7 @@ func (p Search) Render() g.Node { ) } -func (p Search) tableRows() []ui.TableRow { +func (p *Search) tableRows() []ui.TableRow { return sliceutils.Map(p.Results, func(r models.SearchResult) ui.TableRow { return ui.TableRow{ "": ui.TableCell{ diff --git a/web/pages/settings.go b/web/pages/settings.go new file mode 100644 index 0000000..43a0f71 --- /dev/null +++ b/web/pages/settings.go @@ -0,0 +1,184 @@ +package pages + +import ( + g "maragu.dev/gomponents" + h "maragu.dev/gomponents/html" + "reichard.io/antholume/pkg/sliceutils" + "reichard.io/antholume/web/assets" + "reichard.io/antholume/web/components/ui" + "reichard.io/antholume/web/models" + "reichard.io/antholume/web/pages/layout" +) + +var _ Page = (*Settings)(nil) + +type Settings struct { + Timezone string + Devices []models.Device +} + +func (p *Settings) Generate(ctx models.PageContext) (g.Node, error) { + return layout.Layout( + ctx.WithRoute(models.SettingsPage), + h.Div( + h.Class("flex flex-col md:flex-row gap-4"), + h.Div( + h.Div( + h.Class("flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"), + assets.Icon("user", 60), + h.P(h.Class("text-lg"), g.Text(ctx.UserInfo.Username)), + ), + ), + h.Div( + h.Class("flex flex-col gap-4 grow"), + p.passwordForm(), + p.timezoneForm(), + p.devicesTable(), + ), + ), + ) +} + +func (p Settings) passwordForm() g.Node { + return h.Div( + h.Class("flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"), + h.P(h.Class("text-lg font-semibold"), g.Text("Change Password")), + h.Form( + h.Class("flex gap-4 flex-col lg:flex-row"), + h.Action("./settings"), + h.Method("POST"), + // Current Password + h.Div( + h.Class("flex grow"), + h.Span( + h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"), + assets.Icon("password", 15), + ), + h.Input( + h.Type("password"), + h.ID("password"), + h.Name("password"), + h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"), + h.Placeholder("Password"), + ), + ), + // New Password + h.Div( + h.Class("flex grow"), + h.Span( + h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"), + assets.Icon("password", 15), + ), + h.Input( + h.Type("password"), + h.ID("new_password"), + h.Name("new_password"), + h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"), + h.Placeholder("New Password"), + ), + ), + // Submit Button + h.Div( + h.Class("lg:w-60"), + ui.FormButton( + g.Text("Submit"), + "", + ui.ButtonConfig{Variant: ui.ButtonVariantSecondary}, + ), + ), + ), + ) +} + +func (p Settings) timezoneForm() g.Node { + tzs := []string{ + "Africa/Cairo", + "Africa/Johannesburg", + "Africa/Lagos", + "Africa/Nairobi", + "America/Adak", + "America/Anchorage", + "America/Buenos_Aires", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/Mexico_City", + "America/New_York", + "America/Nuuk", + "America/Phoenix", + "America/Puerto_Rico", + "America/Sao_Paulo", + "America/St_Johns", + "America/Toronto", + "Asia/Dubai", + "Asia/Hong_Kong", + "Asia/Kolkata", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Tokyo", + "Atlantic/Azores", + "Australia/Melbourne", + "Australia/Sydney", + "Europe/Berlin", + "Europe/London", + "Europe/Moscow", + "Europe/Paris", + "Pacific/Auckland", + "Pacific/Honolulu", + } + + return h.Div( + h.Class("flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"), + h.P(h.Class("text-lg font-semibold"), g.Text("Change Timezone")), + h.Form( + h.Class("flex gap-4 flex-col lg:flex-row"), + h.Action("./settings"), + h.Method("POST"), + h.Div( + h.Class("flex grow"), + h.Span( + h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"), + assets.Icon("clock", 15), + ), + h.Select( + h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"), + h.ID("timezone"), + h.Name("timezone"), + g.Group(g.Map(tzs, func(tz string) g.Node { + return h.Option( + h.Value(tz), + g.If(tz == p.Timezone, h.Selected()), + g.Text(tz), + ) + })), + ), + ), + h.Div( + h.Class("lg:w-60"), + ui.FormButton( + g.Text("Submit"), + "", + ui.ButtonConfig{Variant: ui.ButtonVariantSecondary}, + ), + ), + ), + ) +} + +func (p Settings) devicesTable() g.Node { + return h.Div( + h.Class("flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"), + h.P(h.Class("text-lg font-semibold"), g.Text("Devices")), + ui.Table(ui.TableConfig{ + Columns: []string{"Name", "Last Sync", "Created"}, + Rows: sliceutils.Map(p.Devices, func(d models.Device) ui.TableRow { + return ui.TableRow{ + "Name": ui.TableCell{String: d.DeviceName}, + "Last Sync": ui.TableCell{String: d.LastSynced}, + "Created": ui.TableCell{String: d.CreatedAt}, + } + }), + }), + ) +}