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},
+ }
+ }),
+ }),
+ )
+}