Compare commits
2 Commits
gocomponen
...
0833c9d23e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0833c9d23e | |||
| 21e281c978 |
@@ -1,6 +0,0 @@
|
|||||||
#:schema https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
|
||||||
version = "2"
|
|
||||||
|
|
||||||
[[linters.exclusions.rules]]
|
|
||||||
linters = [ "errcheck" ]
|
|
||||||
source = "^\\s*defer\\s+"
|
|
||||||
39
api/api.go
39
api/api.go
@@ -145,34 +145,30 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
|||||||
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
||||||
|
|
||||||
// Web App - Templates
|
// Web App - Templates
|
||||||
router.GET("/", api.authWebAppMiddleware, api.appGetHome) // DONE
|
router.GET("/", api.authWebAppMiddleware, api.appGetHomeNew) // DONE
|
||||||
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity) // DONE
|
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivityNew) // DONE
|
||||||
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress) // DONE
|
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgressNew) // DONE
|
||||||
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments) // DONE
|
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocumentsNew) // DONE
|
||||||
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument) // DONE
|
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocumentNew) // DONE
|
||||||
|
|
||||||
// Web App - Other Routes
|
// Web App - Other Routes
|
||||||
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE
|
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE
|
||||||
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE
|
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE
|
||||||
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout) // DONE
|
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
|
||||||
router.POST("/login", api.appAuthLogin) // DONE
|
router.POST("/login", api.appAuthLogin) // DONE
|
||||||
router.POST("/register", api.appAuthRegister) // DONE
|
router.POST("/register", api.appAuthRegister) // DONE
|
||||||
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings) // DONE
|
|
||||||
|
|
||||||
// TODO
|
// TODO
|
||||||
router.GET("/login", api.appGetLogin)
|
router.GET("/login", api.appGetLogin)
|
||||||
router.GET("/register", api.appGetRegister)
|
router.GET("/register", api.appGetRegister)
|
||||||
|
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
|
||||||
// DONE
|
|
||||||
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
|
||||||
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
|
||||||
|
|
||||||
// TODO - WIP
|
|
||||||
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
||||||
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
|
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
|
||||||
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
|
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
|
||||||
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
|
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
|
||||||
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
|
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
|
||||||
|
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
||||||
|
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
||||||
|
|
||||||
// Demo mode enabled configuration
|
// Demo mode enabled configuration
|
||||||
if api.cfg.DemoMode {
|
if api.cfg.DemoMode {
|
||||||
@@ -186,13 +182,14 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
|||||||
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE
|
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE
|
||||||
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE
|
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE
|
||||||
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE
|
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE
|
||||||
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // DONE
|
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // TODO
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search enabled configuration
|
// Search enabled configuration
|
||||||
if api.cfg.SearchEnabled {
|
if api.cfg.SearchEnabled {
|
||||||
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch) // DONE
|
router.GET("/search", api.authWebAppMiddleware, api.appGetSearchNew) // WIP
|
||||||
|
|
||||||
|
router.GET("/search-old", api.authWebAppMiddleware, api.appGetSearch) // TODO
|
||||||
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO
|
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,13 +360,13 @@ func loggingMiddleware(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get username
|
// Get username
|
||||||
var auth *authData
|
var auth authData
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth = data.(*authData)
|
auth = data.(authData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log user
|
// Log user
|
||||||
if auth != nil && auth.UserName != "" {
|
if auth.UserName != "" {
|
||||||
logData["user"] = auth.UserName
|
logData["user"] = auth.UserName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import (
|
|||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
"reichard.io/antholume/utils"
|
"reichard.io/antholume/utils"
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
"reichard.io/antholume/web/pages"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminAction string
|
type adminAction string
|
||||||
@@ -98,31 +96,21 @@ type importResult struct {
|
|||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetAdmin(c *gin.Context) {
|
|
||||||
api.renderPage(c, &pages.AdminGeneral{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appPerformAdminAction(c *gin.Context) {
|
func (api *API) appPerformAdminAction(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
|
||||||
var rAdminAction requestAdminAction
|
var rAdminAction requestAdminAction
|
||||||
if err := c.ShouldBind(&rAdminAction); err != nil {
|
if err := c.ShouldBind(&rAdminAction); err != nil {
|
||||||
log.Error("invalid or missing form values")
|
log.Error("Invalid Form Bind: ", err)
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var allNotifications []*models.Notification
|
|
||||||
switch rAdminAction.Action {
|
switch rAdminAction.Action {
|
||||||
case adminRestore:
|
|
||||||
api.processRestoreFile(rAdminAction, c)
|
|
||||||
return
|
|
||||||
case adminBackup:
|
|
||||||
api.processBackup(c, rAdminAction.BackupTypes)
|
|
||||||
return
|
|
||||||
case adminMetadataMatch:
|
case adminMetadataMatch:
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
// TODO
|
||||||
Type: models.NotificationTypeError,
|
// 1. Documents xref most recent metadata table?
|
||||||
Content: "Metadata match not implemented",
|
// 2. Select all / deselect?
|
||||||
})
|
|
||||||
case adminCacheTables:
|
case adminCacheTables:
|
||||||
go func() {
|
go func() {
|
||||||
err := api.db.CacheTempTables(c)
|
err := api.db.CacheTempTables(c)
|
||||||
@@ -130,14 +118,50 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
|
|||||||
log.Error("Unable to cache temp tables: ", err)
|
log.Error("Unable to cache temp tables: ", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
case adminRestore:
|
||||||
|
api.processRestoreFile(rAdminAction, c)
|
||||||
|
return
|
||||||
|
case adminBackup:
|
||||||
|
// Vacuum
|
||||||
|
_, err := api.db.DB.ExecContext(c, "VACUUM;")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to vacuum DB: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
// Set Headers
|
||||||
Type: models.NotificationTypeSuccess,
|
c.Header("Content-type", "application/octet-stream")
|
||||||
Content: "Initiated table cache",
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
|
||||||
|
|
||||||
|
// Stream Backup ZIP Archive
|
||||||
|
c.Stream(func(w io.Writer) bool {
|
||||||
|
var directories []string
|
||||||
|
for _, item := range rAdminAction.BackupTypes {
|
||||||
|
switch item {
|
||||||
|
case backupCovers:
|
||||||
|
directories = append(directories, "covers")
|
||||||
|
case backupDocuments:
|
||||||
|
directories = append(directories, "documents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := api.createBackup(c, w, directories)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Backup Error: ", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.renderPage(c, &pages.AdminGeneral{}, allNotifications...)
|
c.HTML(http.StatusOK, "page/admin", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdmin(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
c.HTML(http.StatusOK, "page/admin", templateVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetAdminLogs(c *gin.Context) {
|
func (api *API) appGetAdminLogs(c *gin.Context) {
|
||||||
@@ -510,40 +534,6 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
|
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) processBackup(c *gin.Context, backupTypes []backupType) {
|
|
||||||
// Vacuum
|
|
||||||
_, err := api.db.DB.ExecContext(c, "VACUUM;")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to vacuum DB: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Headers
|
|
||||||
c.Header("Content-type", "application/octet-stream")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
|
|
||||||
|
|
||||||
// Stream Backup ZIP Archive
|
|
||||||
c.Stream(func(w io.Writer) bool {
|
|
||||||
var directories []string
|
|
||||||
for _, item := range backupTypes {
|
|
||||||
switch item {
|
|
||||||
case backupCovers:
|
|
||||||
directories = append(directories, "covers")
|
|
||||||
case backupDocuments:
|
|
||||||
directories = append(directories, "documents")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := api.createBackup(c, w, directories)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Backup Error: ", err)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
||||||
// Validate Type & Derive Extension on MIME
|
// Validate Type & Derive Extension on MIME
|
||||||
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
||||||
@@ -800,7 +790,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = ar.Close()
|
ar.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"crypto/md5"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,7 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
argon2 "github.com/alexedwards/argon2id"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
@@ -20,19 +18,20 @@ import (
|
|||||||
"reichard.io/antholume/pkg/sliceutils"
|
"reichard.io/antholume/pkg/sliceutils"
|
||||||
"reichard.io/antholume/pkg/utils"
|
"reichard.io/antholume/pkg/utils"
|
||||||
"reichard.io/antholume/search"
|
"reichard.io/antholume/search"
|
||||||
|
"reichard.io/antholume/web/components/layout"
|
||||||
"reichard.io/antholume/web/components/stats"
|
"reichard.io/antholume/web/components/stats"
|
||||||
"reichard.io/antholume/web/models"
|
"reichard.io/antholume/web/models"
|
||||||
"reichard.io/antholume/web/pages"
|
"reichard.io/antholume/web/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (api *API) appGetHome(c *gin.Context) {
|
func (api *API) appGetHomeNew(c *gin.Context) {
|
||||||
_, auth := api.getBaseTemplateVars("home", c)
|
_, auth := api.getBaseTemplateVars("home", c)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
|
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get daily read stats")
|
log.Error("GetDailyReadStats DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get daily read stats: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
|
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
|
||||||
@@ -40,8 +39,8 @@ func (api *API) appGetHome(c *gin.Context) {
|
|||||||
start = time.Now()
|
start = time.Now()
|
||||||
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
|
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get database info")
|
log.Error("GetDatabaseInfo DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get database info: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
|
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
|
||||||
@@ -49,8 +48,8 @@ func (api *API) appGetHome(c *gin.Context) {
|
|||||||
start = time.Now()
|
start = time.Now()
|
||||||
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
|
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get user streaks")
|
log.Error("GetUserStreaks DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user streaks: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
||||||
@@ -58,27 +57,35 @@ func (api *API) appGetHome(c *gin.Context) {
|
|||||||
start = time.Now()
|
start = time.Now()
|
||||||
userStatistics, err := api.db.Queries.GetUserStatistics(c)
|
userStatistics, err := api.db.Queries.GetUserStatistics(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get user statistics")
|
log.Error("GetUserStatistics DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user statistics: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
api.renderPage(c, &pages.Home{
|
err = layout.Layout(
|
||||||
Leaderboard: arrangeUserStatistic(userStatistics),
|
pages.Home{
|
||||||
Streaks: streaks,
|
Leaderboard: arrangeUserStatisticsNew(userStatistics),
|
||||||
DailyStats: dailyStats,
|
Streaks: streaks,
|
||||||
RecordInfo: &databaseInfo,
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetDocuments(c *gin.Context) {
|
func (api *API) appGetDocumentsNew(c *gin.Context) {
|
||||||
qParams, err := bindQueryParams(c, 9)
|
_, auth := api.getBaseTemplateVars("documents", c)
|
||||||
if err != nil {
|
qParams := bindQueryParams(c, 9)
|
||||||
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
|
var query *string
|
||||||
if qParams.Search != nil && *qParams.Search != "" {
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
@@ -86,7 +93,6 @@ func (api *API) appGetDocuments(c *gin.Context) {
|
|||||||
query = &search
|
query = &search
|
||||||
}
|
}
|
||||||
|
|
||||||
_, auth := api.getBaseTemplateVars("documents", c)
|
|
||||||
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
|
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
|
||||||
UserID: auth.UserName,
|
UserID: auth.UserName,
|
||||||
Query: query,
|
Query: query,
|
||||||
@@ -95,114 +101,170 @@ func (api *API) appGetDocuments(c *gin.Context) {
|
|||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get documents with stats")
|
log.Error("GetDocumentsWithStats DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get documents with stats: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
length, err := api.db.Queries.GetDocumentsSize(c, query)
|
length, err := api.db.Queries.GetDocumentsSize(c, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get document sizes")
|
log.Error("GetDocumentsSize DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document sizes: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = api.getDocumentsWordCount(c, documents); err != nil {
|
if err = api.getDocumentsWordCount(c, documents); err != nil {
|
||||||
log.WithError(err).Error("failed to get word counts")
|
log.Error("Unable to Get Word Counts: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
||||||
nextPage := *qParams.Page + 1
|
nextPage := *qParams.Page + 1
|
||||||
previousPage := *qParams.Page - 1
|
previousPage := *qParams.Page - 1
|
||||||
|
|
||||||
api.renderPage(c, pages.Documents{
|
err = layout.Layout(
|
||||||
Data: sliceutils.Map(documents, convertDBDocToUI),
|
pages.Documents{
|
||||||
Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0),
|
Data: sliceutils.Map(documents, convertDBDocToUI),
|
||||||
Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0),
|
Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0),
|
||||||
Limit: int(ptr.Deref(qParams.Limit)),
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetDocument(c *gin.Context) {
|
func (api *API) appGetDocumentNew(c *gin.Context) {
|
||||||
|
_, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
|
||||||
var rDocID requestDocumentID
|
var rDocID requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
log.WithError(err).Error("failed to bind URI")
|
log.Error("Invalid URI Bind")
|
||||||
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, auth := api.getBaseTemplateVars("document", c)
|
|
||||||
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get document")
|
log.Error("GetDocument DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.renderPage(c, &pages.Document{Data: convertDBDocToUI(*document)})
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetActivity(c *gin.Context) {
|
func (api *API) appGetActivityNew(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)
|
_, auth := api.getBaseTemplateVars("activity", c)
|
||||||
activity, err := api.db.Queries.GetActivity(c, database.GetActivityParams{
|
qParams := bindQueryParams(c, 15)
|
||||||
UserID: auth.UserName,
|
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
activityFilter := database.GetActivityParams{
|
||||||
Limit: *qParams.Limit,
|
UserID: auth.UserName,
|
||||||
DocFilter: qParams.Document != nil,
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
DocumentID: ptr.Deref(qParams.Document),
|
Limit: *qParams.Limit,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if qParams.Document != nil {
|
||||||
|
activityFilter.DocFilter = true
|
||||||
|
activityFilter.DocumentID = *qParams.Document
|
||||||
|
}
|
||||||
|
|
||||||
|
activity, err := api.db.Queries.GetActivity(c, activityFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get activity")
|
log.Error("GetActivity DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get activity: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.renderPage(c, &pages.Activity{Data: sliceutils.Map(activity, convertDBActivityToUI)})
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetProgress(c *gin.Context) {
|
func (api *API) appGetProgressNew(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("progress", c)
|
_, auth := api.getBaseTemplateVars("progress", c)
|
||||||
progress, err := api.db.Queries.GetProgress(c, database.GetProgressParams{
|
|
||||||
UserID: auth.UserName,
|
qParams := bindQueryParams(c, 15)
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
|
||||||
Limit: *qParams.Limit,
|
progressFilter := database.GetProgressParams{
|
||||||
DocFilter: qParams.Document != nil,
|
UserID: auth.UserName,
|
||||||
DocumentID: ptr.Deref(qParams.Document),
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get progress")
|
log.Error("GetProgress DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get progress: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.renderPage(c, &pages.Progress{Data: sliceutils.Map(progress, convertDBProgressToUI)})
|
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)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Render Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appIdentifyDocumentNew(c *gin.Context) {
|
func (api *API) appIdentifyDocumentNew(c *gin.Context) {
|
||||||
var rDocID requestDocumentID
|
var rDocID requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
log.WithError(err).Error("failed to bind URI")
|
log.Error("Invalid URI Bind")
|
||||||
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var rDocIdentify requestDocumentIdentify
|
var rDocIdentify requestDocumentIdentify
|
||||||
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
||||||
log.WithError(err).Error("failed to bind form")
|
log.Error("Invalid Form Bind")
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,14 +282,15 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate Values
|
// Validate Values
|
||||||
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
|
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
|
||||||
log.Error("invalid or missing form values")
|
log.Error("Invalid Form")
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Template Variables
|
||||||
|
_, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
|
||||||
// Get Metadata
|
// Get Metadata
|
||||||
var searchResult *models.DocumentMetadata
|
|
||||||
var allNotifications []*models.Notification
|
|
||||||
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
||||||
Title: rDocIdentify.Title,
|
Title: rDocIdentify.Title,
|
||||||
Author: rDocIdentify.Author,
|
Author: rDocIdentify.Author,
|
||||||
@@ -235,12 +298,14 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) {
|
|||||||
ISBN13: rDocIdentify.ISBN,
|
ISBN13: rDocIdentify.ISBN,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to search metadata")
|
log.Error("Search Metadata Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to search metadata: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Metadata Error: %v", err))
|
||||||
return
|
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
|
// Store First Metadata Result
|
||||||
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
|
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
|
||||||
DocumentID: rDocID.DocumentID,
|
DocumentID: rDocID.DocumentID,
|
||||||
@@ -248,42 +313,52 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) {
|
|||||||
Author: firstResult.Author,
|
Author: firstResult.Author,
|
||||||
Description: firstResult.Description,
|
Description: firstResult.Description,
|
||||||
Gbid: firstResult.SourceID,
|
Gbid: firstResult.SourceID,
|
||||||
|
Olid: nil,
|
||||||
Isbn10: firstResult.ISBN10,
|
Isbn10: firstResult.ISBN10,
|
||||||
Isbn13: firstResult.ISBN13,
|
Isbn13: firstResult.ISBN13,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.WithError(err).Error("failed to add metadata")
|
log.Error("AddMetadata DB Error: ", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
allNotifications = append(allNotifications, &models.Notification{
|
errorMsg = ptr.Of("No Metadata Found")
|
||||||
Type: models.NotificationTypeError,
|
|
||||||
Content: "No Metadata Found",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Auth
|
|
||||||
_, auth := api.getBaseTemplateVars("document", c)
|
|
||||||
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get document")
|
log.Error("GetDocument DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
api.renderPage(c, &pages.Document{
|
err = layout.Layout(
|
||||||
Data: convertDBDocToUI(*document),
|
pages.Document{
|
||||||
Search: searchResult,
|
Data: convertDBDocToUI(*document),
|
||||||
}, allNotifications...)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tabs:
|
// Tabs:
|
||||||
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
||||||
// - Users
|
// - Users
|
||||||
// - Metadata
|
// - Metadata
|
||||||
func (api *API) appGetSearch(c *gin.Context) {
|
func (api *API) appGetSearchNew(c *gin.Context) {
|
||||||
|
_, auth := api.getBaseTemplateVars("search", c)
|
||||||
|
|
||||||
var sParams searchParams
|
var sParams searchParams
|
||||||
if err := c.BindQuery(&sParams); err != nil {
|
err := c.BindQuery(&sParams)
|
||||||
log.WithError(err).Error("failed to bind form")
|
if err != nil {
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +368,6 @@ func (api *API) appGetSearch(c *gin.Context) {
|
|||||||
if sParams.Query != nil && sParams.Source != nil {
|
if sParams.Query != nil && sParams.Source != nil {
|
||||||
results, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
results, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to search book")
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -302,159 +376,23 @@ func (api *API) appGetSearch(c *gin.Context) {
|
|||||||
searchError = "Invailid Query"
|
searchError = "Invailid Query"
|
||||||
}
|
}
|
||||||
|
|
||||||
api.renderPage(c, &pages.Search{
|
err = layout.Layout(
|
||||||
Results: searchResults,
|
pages.Search{
|
||||||
Source: ptr.Deref(sParams.Source),
|
Results: searchResults,
|
||||||
Query: ptr.Deref(sParams.Query),
|
Source: ptr.Deref(sParams.Source),
|
||||||
Error: searchError,
|
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.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{
|
layout.LayoutOptions{
|
||||||
RegistrationEnabled: api.cfg.RegistrationEnabled,
|
Username: auth.UserName,
|
||||||
SearchEnabled: api.cfg.SearchEnabled,
|
IsAdmin: auth.IsAdmin,
|
||||||
Version: api.cfg.Version,
|
SearchEnabled: api.cfg.SearchEnabled,
|
||||||
|
Version: api.cfg.Version,
|
||||||
},
|
},
|
||||||
Notifications: notifications,
|
).Render(c.Writer)
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to generate page")
|
log.Error("Render Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to generate page: %s", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +415,7 @@ func sortItem[T cmp.Ordered](
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func arrangeUserStatistic(data []database.GetUserStatisticsRow) []stats.LeaderboardData {
|
func arrangeUserStatisticsNew(data []database.GetUserStatisticsRow) []stats.LeaderboardData {
|
||||||
wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) }
|
wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) }
|
||||||
return []stats.LeaderboardData{
|
return []stats.LeaderboardData{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,22 +2,28 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
|
"reichard.io/antholume/pkg/ptr"
|
||||||
"reichard.io/antholume/search"
|
"reichard.io/antholume/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,6 +101,242 @@ func (api *API) appDocumentReader(c *gin.Context) {
|
|||||||
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
|
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetDocuments(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("documents", c)
|
||||||
|
qParams := bindQueryParams(c, 9)
|
||||||
|
|
||||||
|
var query *string
|
||||||
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
|
search := "%" + *qParams.Search + "%"
|
||||||
|
query = &search
|
||||||
|
}
|
||||||
|
|
||||||
|
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Query: query,
|
||||||
|
Deleted: ptr.Of(false),
|
||||||
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
|
Limit: *qParams.Limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocumentsWithStats DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", 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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = api.getDocumentsWordCount(c, documents); err != nil {
|
||||||
|
log.Error("Unable to Get Word Counts: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
||||||
|
nextPage := *qParams.Page + 1
|
||||||
|
previousPage := *qParams.Page - 1
|
||||||
|
|
||||||
|
if nextPage <= totalPages {
|
||||||
|
templateVars["NextPage"] = nextPage
|
||||||
|
}
|
||||||
|
|
||||||
|
if previousPage >= 0 {
|
||||||
|
templateVars["PreviousPage"] = previousPage
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["PageLimit"] = *qParams.Limit
|
||||||
|
templateVars["Data"] = documents
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/documents", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetDocument(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
|
||||||
|
var rDocID requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = document
|
||||||
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/document", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetProgress(c *gin.Context) {
|
||||||
|
templateVars, 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)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetProgress DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = progress
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/progress", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetActivity(c *gin.Context) {
|
||||||
|
templateVars, 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)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetActivity DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = activity
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/activity", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetHome(c *gin.Context) {
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("home", c)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
graphData, 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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
templateVars["Data"] = gin.H{
|
||||||
|
"Streaks": streaks,
|
||||||
|
"GraphData": graphData,
|
||||||
|
"DatabaseInfo": databaseInfo,
|
||||||
|
"UserStatistics": arrangeUserStatistics(userStatistics),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/home", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs:
|
||||||
|
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
||||||
|
// - Users
|
||||||
|
// - Metadata
|
||||||
|
func (api *API) appGetSearch(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("search", c)
|
||||||
|
|
||||||
|
var sParams searchParams
|
||||||
|
err := c.BindQuery(&sParams)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Handle Query
|
||||||
|
if sParams.Query != nil && sParams.Source != nil {
|
||||||
|
// Search
|
||||||
|
searchResults, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = searchResults
|
||||||
|
templateVars["Source"] = *sParams.Source
|
||||||
|
} else if sParams.Query != nil || sParams.Source != nil {
|
||||||
|
templateVars["SearchErrorMessage"] = "Invalid Query"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/search", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) appGetLogin(c *gin.Context) {
|
func (api *API) appGetLogin(c *gin.Context) {
|
||||||
templateVars, _ := api.getBaseTemplateVars("login", c)
|
templateVars, _ := api.getBaseTemplateVars("login", c)
|
||||||
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
|
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
|
||||||
@@ -375,6 +617,85 @@ func (api *API) appDeleteDocument(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "../")
|
c.Redirect(http.StatusFound, "../")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) appIdentifyDocument(c *gin.Context) {
|
||||||
|
var rDocID requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rDocIdentify requestDocumentIdentify
|
||||||
|
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
||||||
|
log.Error("Invalid Form Bind")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow Empty Strings
|
||||||
|
if rDocIdentify.Title != nil && strings.TrimSpace(*rDocIdentify.Title) == "" {
|
||||||
|
rDocIdentify.Title = nil
|
||||||
|
}
|
||||||
|
if rDocIdentify.Author != nil && strings.TrimSpace(*rDocIdentify.Author) == "" {
|
||||||
|
rDocIdentify.Author = nil
|
||||||
|
}
|
||||||
|
if rDocIdentify.ISBN != nil && strings.TrimSpace(*rDocIdentify.ISBN) == "" {
|
||||||
|
rDocIdentify.ISBN = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Values
|
||||||
|
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
|
||||||
|
log.Error("Invalid Form")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Template Variables
|
||||||
|
templateVars, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
|
||||||
|
// Get Metadata
|
||||||
|
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
||||||
|
Title: rDocIdentify.Title,
|
||||||
|
Author: rDocIdentify.Author,
|
||||||
|
ISBN10: rDocIdentify.ISBN,
|
||||||
|
ISBN13: rDocIdentify.ISBN,
|
||||||
|
})
|
||||||
|
if err == nil && len(metadataResults) > 0 {
|
||||||
|
firstResult := metadataResults[0]
|
||||||
|
|
||||||
|
// Store First Metadata Result
|
||||||
|
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
|
||||||
|
DocumentID: rDocID.DocumentID,
|
||||||
|
Title: firstResult.Title,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Metadata"] = firstResult
|
||||||
|
} else {
|
||||||
|
log.Warn("Metadata Error")
|
||||||
|
templateVars["MetadataError"] = "No Metadata Found"
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = document
|
||||||
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/document", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) appSaveNewDocument(c *gin.Context) {
|
func (api *API) appSaveNewDocument(c *gin.Context) {
|
||||||
var rDocAdd requestDocumentAdd
|
var rDocAdd requestDocumentAdd
|
||||||
if err := c.ShouldBind(&rDocAdd); err != nil {
|
if err := c.ShouldBind(&rDocAdd); err != nil {
|
||||||
@@ -512,6 +833,84 @@ 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) {
|
func (api *API) appDemoModeError(c *gin.Context) {
|
||||||
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
||||||
}
|
}
|
||||||
@@ -559,10 +958,10 @@ func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *authData) {
|
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, authData) {
|
||||||
var auth *authData
|
var auth authData
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth = data.(*authData)
|
auth = data.(authData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
@@ -576,11 +975,12 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *a
|
|||||||
}, auth
|
}, auth
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) {
|
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
|
||||||
var qParams queryParams
|
var qParams queryParams
|
||||||
err := c.BindQuery(&qParams)
|
err := c.BindQuery(&qParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
||||||
|
return qParams
|
||||||
}
|
}
|
||||||
|
|
||||||
if qParams.Limit == nil {
|
if qParams.Limit == nil {
|
||||||
@@ -595,7 +995,7 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) {
|
|||||||
qParams.Page = &oneValue
|
qParams.Page = &oneValue
|
||||||
}
|
}
|
||||||
|
|
||||||
return &qParams, nil
|
return qParams
|
||||||
}
|
}
|
||||||
|
|
||||||
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
||||||
@@ -618,3 +1018,80 @@ func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
|||||||
"Message": errorMessage,
|
"Message": errorMessage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H {
|
||||||
|
// Item Sorter
|
||||||
|
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]any {
|
||||||
|
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
|
||||||
|
sort.SliceStable(sortedData, less)
|
||||||
|
|
||||||
|
newData := make([]map[string]any, 0)
|
||||||
|
for _, item := range sortedData {
|
||||||
|
v := reflect.Indirect(reflect.ValueOf(item))
|
||||||
|
|
||||||
|
var value string
|
||||||
|
if strings.Contains(key, "Wpm") {
|
||||||
|
rawVal := v.FieldByName(key).Float()
|
||||||
|
value = fmt.Sprintf("%.2f WPM", rawVal)
|
||||||
|
} else if strings.Contains(key, "Seconds") {
|
||||||
|
rawVal := v.FieldByName(key).Int()
|
||||||
|
value = niceSeconds(rawVal)
|
||||||
|
} else if strings.Contains(key, "Words") {
|
||||||
|
rawVal := v.FieldByName(key).Int()
|
||||||
|
value = niceNumbers(rawVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
newData = append(newData, map[string]any{
|
||||||
|
"UserID": item.UserID,
|
||||||
|
"Value": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return newData
|
||||||
|
}
|
||||||
|
|
||||||
|
return gin.H{
|
||||||
|
"WPM": gin.H{
|
||||||
|
"All": sortItem(userStatistics, "TotalWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].TotalWpm > userStatistics[j].TotalWpm
|
||||||
|
}),
|
||||||
|
"Year": sortItem(userStatistics, "YearlyWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].YearlyWpm > userStatistics[j].YearlyWpm
|
||||||
|
}),
|
||||||
|
"Month": sortItem(userStatistics, "MonthlyWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].MonthlyWpm > userStatistics[j].MonthlyWpm
|
||||||
|
}),
|
||||||
|
"Week": sortItem(userStatistics, "WeeklyWpm", func(i, j int) bool {
|
||||||
|
return userStatistics[i].WeeklyWpm > userStatistics[j].WeeklyWpm
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"Duration": gin.H{
|
||||||
|
"All": sortItem(userStatistics, "TotalSeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].TotalSeconds > userStatistics[j].TotalSeconds
|
||||||
|
}),
|
||||||
|
"Year": sortItem(userStatistics, "YearlySeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].YearlySeconds > userStatistics[j].YearlySeconds
|
||||||
|
}),
|
||||||
|
"Month": sortItem(userStatistics, "MonthlySeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].MonthlySeconds > userStatistics[j].MonthlySeconds
|
||||||
|
}),
|
||||||
|
"Week": sortItem(userStatistics, "WeeklySeconds", func(i, j int) bool {
|
||||||
|
return userStatistics[i].WeeklySeconds > userStatistics[j].WeeklySeconds
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"Words": gin.H{
|
||||||
|
"All": sortItem(userStatistics, "TotalWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].TotalWordsRead > userStatistics[j].TotalWordsRead
|
||||||
|
}),
|
||||||
|
"Year": sortItem(userStatistics, "YearlyWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].YearlyWordsRead > userStatistics[j].YearlyWordsRead
|
||||||
|
}),
|
||||||
|
"Month": sortItem(userStatistics, "MonthlyWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].MonthlyWordsRead > userStatistics[j].MonthlyWordsRead
|
||||||
|
}),
|
||||||
|
"Week": sortItem(userStatistics, "WeeklyWordsRead", func(i, j int) bool {
|
||||||
|
return userStatistics[i].WeeklyWordsRead > userStatistics[j].WeeklyWordsRead
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
64
api/auth.go
64
api/auth.go
@@ -30,31 +30,31 @@ type authKOHeader struct {
|
|||||||
AuthKey string `header:"x-auth-key"`
|
AuthKey string `header:"x-auth-key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (*authData, error) {
|
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) {
|
||||||
user, err := api.db.Queries.GetUser(ctx, username)
|
user, err := api.db.Queries.GetUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
||||||
return nil, err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Auth Cache
|
// Update auth cache
|
||||||
api.userAuthCache[user.ID] = *user.AuthHash
|
api.userAuthCache[user.ID] = *user.AuthHash
|
||||||
|
|
||||||
return &authData{
|
return &authData{
|
||||||
UserName: user.ID,
|
UserName: user.ID,
|
||||||
IsAdmin: user.Admin,
|
IsAdmin: user.Admin,
|
||||||
AuthHash: *user.AuthHash,
|
AuthHash: *user.AuthHash,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authKOMiddleware(c *gin.Context) {
|
func (api *API) authKOMiddleware(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session First
|
// Check Session First
|
||||||
if auth, ok := api.authorizeSession(c, session); ok {
|
if auth, ok := api.getSession(c, session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -65,25 +65,21 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
var rHeader authKOHeader
|
var rHeader authKOHeader
|
||||||
if err := c.ShouldBindHeader(&rHeader); err != nil {
|
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"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
|
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
|
||||||
log.Error("invalid authentication headers")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authData, err := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
|
authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
|
||||||
if err != nil {
|
if authData == nil {
|
||||||
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to authorize credentials")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
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"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,16 +96,14 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate Auth Fields
|
// Validate Auth Fields
|
||||||
if !hasAuth || user == "" || rawPassword == "" {
|
if !hasAuth || user == "" || rawPassword == "" {
|
||||||
log.Error("invalid authorization headers")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Auth
|
// Validate Auth
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
authData, err := api.authorizeCredentials(c, user, password)
|
authData := api.authorizeCredentials(c, user, password)
|
||||||
if err != nil {
|
if authData == nil {
|
||||||
log.WithField("user", user).WithError(err).Error("failed to authorize credentials")
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -123,7 +117,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session
|
// Check Session
|
||||||
if auth, ok := api.authorizeSession(c, session); ok {
|
if auth, ok := api.getSession(c, session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -136,7 +130,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth := data.(*authData)
|
auth := data.(authData)
|
||||||
if auth.IsAdmin {
|
if auth.IsAdmin {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -161,9 +155,8 @@ func (api *API) appAuthLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// MD5 - KOSync Compatiblity
|
// MD5 - KOSync Compatiblity
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
authData, err := api.authorizeCredentials(c, username, password)
|
authData := api.authorizeCredentials(c, username, password)
|
||||||
if err != nil {
|
if authData == nil {
|
||||||
log.WithField("user", username).WithError(err).Error("failed to authorize credentials")
|
|
||||||
templateVars["Error"] = "Invalid Credentials"
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
return
|
return
|
||||||
@@ -171,7 +164,7 @@ func (api *API) appAuthLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// Set Session
|
// Set Session
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
if err := api.setSession(session, authData); err != nil {
|
if err := api.setSession(session, *authData); err != nil {
|
||||||
templateVars["Error"] = "Invalid Credentials"
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
return
|
return
|
||||||
@@ -260,7 +253,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set session
|
// Set session
|
||||||
auth := &authData{
|
auth := authData{
|
||||||
UserName: user.ID,
|
UserName: user.ID,
|
||||||
IsAdmin: user.Admin,
|
IsAdmin: user.Admin,
|
||||||
AuthHash: *user.AuthHash,
|
AuthHash: *user.AuthHash,
|
||||||
@@ -356,40 +349,35 @@ func (api *API) koAuthRegister(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authorizeSession(ctx context.Context, session sessions.Session) (*authData, bool) {
|
func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) {
|
||||||
// Get Session
|
// Get Session
|
||||||
authorizedUser := session.Get("authorizedUser")
|
authorizedUser := session.Get("authorizedUser")
|
||||||
isAdmin := session.Get("isAdmin")
|
isAdmin := session.Get("isAdmin")
|
||||||
expiresAt := session.Get("expiresAt")
|
expiresAt := session.Get("expiresAt")
|
||||||
authHash := session.Get("authHash")
|
authHash := session.Get("authHash")
|
||||||
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
||||||
return nil, false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Auth Object
|
// Create Auth Object
|
||||||
auth := &authData{
|
auth = authData{
|
||||||
UserName: authorizedUser.(string),
|
UserName: authorizedUser.(string),
|
||||||
IsAdmin: isAdmin.(bool),
|
IsAdmin: isAdmin.(bool),
|
||||||
AuthHash: authHash.(string),
|
AuthHash: authHash.(string),
|
||||||
}
|
}
|
||||||
logger := log.WithField("user", auth.UserName)
|
|
||||||
|
|
||||||
// Validate Auth Hash
|
// Validate Auth Hash
|
||||||
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
|
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
|
||||||
if err != nil {
|
if err != nil || correctAuthHash != auth.AuthHash {
|
||||||
logger.WithError(err).Error("failed to get auth hash")
|
return
|
||||||
return nil, false
|
|
||||||
} else if correctAuthHash != auth.AuthHash {
|
|
||||||
logger.Warn("user auth hash mismatch")
|
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
||||||
logger.Info("refreshing session")
|
log.Info("Refreshing Session")
|
||||||
if err := api.setSession(session, auth); err != nil {
|
if err := api.setSession(session, auth); err != nil {
|
||||||
logger.WithError(err).Error("failed to refresh session")
|
log.Error("unable to get session")
|
||||||
return nil, false
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +385,7 @@ func (api *API) authorizeSession(ctx context.Context, session sessions.Session)
|
|||||||
return auth, true
|
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
|
// Set Session Cookie
|
||||||
session.Set("authorizedUser", auth.UserName)
|
session.Set("authorizedUser", auth.UserName)
|
||||||
session.Set("isAdmin", auth.IsAdmin)
|
session.Set("isAdmin", auth.IsAdmin)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func convertDBDocToUI(r database.GetDocumentsWithStatsRow) models.Document {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertMetaToUI(m metadata.MetadataInfo) *models.DocumentMetadata {
|
func convertMetaToUI(m metadata.MetadataInfo, errorMsg *string) *models.DocumentMetadata {
|
||||||
return &models.DocumentMetadata{
|
return &models.DocumentMetadata{
|
||||||
SourceID: ptr.Deref(m.SourceID),
|
SourceID: ptr.Deref(m.SourceID),
|
||||||
ISBN10: ptr.Deref(m.ISBN10),
|
ISBN10: ptr.Deref(m.ISBN10),
|
||||||
@@ -37,6 +37,7 @@ func convertMetaToUI(m metadata.MetadataInfo) *models.DocumentMetadata {
|
|||||||
Author: ptr.Deref(m.Author),
|
Author: ptr.Deref(m.Author),
|
||||||
Description: ptr.Deref(m.Description),
|
Description: ptr.Deref(m.Description),
|
||||||
Source: m.Source,
|
Source: m.Source,
|
||||||
|
Error: errorMsg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,14 +63,6 @@ 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 {
|
func convertSearchToUI(r search.SearchItem) models.SearchResult {
|
||||||
return models.SearchResult{
|
return models.SearchResult{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
|
|||||||
@@ -62,19 +62,13 @@ func (api *API) opdsEntry(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) opdsDocuments(c *gin.Context) {
|
func (api *API) opdsDocuments(c *gin.Context) {
|
||||||
auth, err := getAuthData(c)
|
var auth authData
|
||||||
if err != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
log.WithError(err).Error("failed to acquire auth data")
|
auth = data.(authData)
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Potential URL Parameters (Default Pagination - 100)
|
// Potential URL Parameters (Default Pagination - 100)
|
||||||
qParams, err := bindQueryParams(c, 100)
|
qParams := bindQueryParams(c, 100)
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("failed to bind query params")
|
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Possible Query
|
// Possible Query
|
||||||
var query *string
|
var query *string
|
||||||
@@ -92,7 +86,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("failed to get documents with stats")
|
log.Error("GetDocumentsWithStats DB Error:", err)
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
11
api/utils.go
11
api/utils.go
@@ -8,22 +8,11 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/graph"
|
"reichard.io/antholume/graph"
|
||||||
"reichard.io/antholume/metadata"
|
"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.
|
// getTimeZones returns a string slice of IANA timezones.
|
||||||
func getTimeZones() []string {
|
func getTimeZones() []string {
|
||||||
return []string{
|
return []string{
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -138,8 +138,8 @@ WHERE id = $device_id LIMIT 1;
|
|||||||
SELECT
|
SELECT
|
||||||
devices.id,
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
|
LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
|
||||||
CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
|
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
|
||||||
FROM devices
|
FROM devices
|
||||||
JOIN users ON users.id = devices.user_id
|
JOIN users ON users.id = devices.user_id
|
||||||
WHERE users.id = $user_id
|
WHERE users.id = $user_id
|
||||||
|
|||||||
@@ -422,8 +422,8 @@ const getDevices = `-- name: GetDevices :many
|
|||||||
SELECT
|
SELECT
|
||||||
devices.id,
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
|
LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
|
||||||
CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
|
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
|
||||||
FROM devices
|
FROM devices
|
||||||
JOIN users ON users.id = devices.user_id
|
JOIN users ON users.id = devices.user_id
|
||||||
WHERE users.id = ?1
|
WHERE users.id = ?1
|
||||||
@@ -431,10 +431,10 @@ ORDER BY devices.last_synced DESC
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetDevicesRow struct {
|
type GetDevicesRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt interface{} `json:"created_at"`
|
||||||
LastSynced string `json:"last_synced"`
|
LastSynced interface{} `json:"last_synced"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
|
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
|
||||||
|
|||||||
@@ -53,12 +53,10 @@ func countEPUBWords(filepath string) (int64, error) {
|
|||||||
rf := rc.Rootfiles[0]
|
rf := rc.Rootfiles[0]
|
||||||
|
|
||||||
var completeCount int64
|
var completeCount int64
|
||||||
for _, item := range rf.Itemrefs {
|
for _, item := range rf.Spine.Itemrefs {
|
||||||
f, _ := item.Open()
|
f, _ := item.Open()
|
||||||
doc, _ := goquery.NewDocumentFromReader(f)
|
doc, _ := goquery.NewDocumentFromReader(f)
|
||||||
doc.Find("script, style, noscript, iframe").Remove()
|
completeCount = completeCount + int64(len(strings.Fields(doc.Text())))
|
||||||
words := len(strings.Fields(doc.Text()))
|
|
||||||
completeCount = completeCount + int64(words)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return completeCount, nil
|
return completeCount, nil
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func GetWordCount(filepath string) (*int64, error) {
|
|||||||
}
|
}
|
||||||
return &totalWords, nil
|
return &totalWords, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("invalid extension: %s", fileExtension)
|
return nil, fmt.Errorf("Invalid extension: %s", fileExtension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func SearchBook(query string, source Source) ([]SearchItem, error) {
|
|||||||
if !found {
|
if !found {
|
||||||
return nil, fmt.Errorf("invalid source: %s", source)
|
return nil, fmt.Errorf("invalid source: %s", source)
|
||||||
}
|
}
|
||||||
|
log.Debug("Source: ", source)
|
||||||
return searchFunc(query)
|
return searchFunc(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,6 @@ module.exports = {
|
|||||||
minWidth: {
|
minWidth: {
|
||||||
40: "10rem",
|
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: [],
|
plugins: [],
|
||||||
|
|||||||
28
templates/components/activity.tmpl
Normal file
28
templates/components/activity.tmpl
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Activity{{ end }}
|
||||||
|
{{ define "header" }}<a href="./activity">Activity</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<!-- Table Component - Utilizes Template "table-cell" -->
|
||||||
|
{{ template "component/table" (dict
|
||||||
|
"Columns" (slice "Document" "Time" "Duration" "Percent")
|
||||||
|
"Keys" (slice "Document" "StartTime" "Duration" "EndPercentage")
|
||||||
|
"Rows" .Data
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<!-- Table Cell Definition -->
|
||||||
|
{{ define "table-cell" }}
|
||||||
|
{{ if eq .Name "Document" }}
|
||||||
|
<a href="./documents/{{ .Data.DocumentID }}"
|
||||||
|
>{{ .Data.Author }} - {{ .Data.Title }}</a
|
||||||
|
>
|
||||||
|
{{ else if eq .Name "EndPercentage" }}
|
||||||
|
{{ index (fields .Data) .Name }}%
|
||||||
|
{{ else }}
|
||||||
|
{{ index (fields .Data) .Name }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
50
templates/components/document-card.tmpl
Normal file
50
templates/components/document-card.tmpl
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<div class="w-full relative">
|
||||||
|
<div
|
||||||
|
class="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<div class="min-w-fit my-auto h-48 relative">
|
||||||
|
<a href="./documents/{{ .ID }}">
|
||||||
|
<img
|
||||||
|
class="rounded object-cover h-full"
|
||||||
|
src="./documents/{{ .ID }}/cover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Title</p>
|
||||||
|
<p class="font-medium">{{ or .Title "Unknown" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Author</p>
|
||||||
|
<p class="font-medium">{{ or .Author "Unknown" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Progress</p>
|
||||||
|
<p class="font-medium">{{ .Percentage }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Time Read</p>
|
||||||
|
<p class="font-medium">{{ niceSeconds .TotalTimeSeconds }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<a href="./activity?document={{ .ID }}">{{ template "svg/activity" }}</a>
|
||||||
|
{{ if .Filepath }}
|
||||||
|
<a href="./documents/{{ .ID }}/file">{{ template "svg/download" }}</a>
|
||||||
|
{{ else }}
|
||||||
|
{{ template "svg/download" (dict "Disabled" true) }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
12
templates/components/info-card.tmpl
Normal file
12
templates/components/info-card.tmpl
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{{ if .Link }}<a href="{{ .Link }}" {{ else }} <div {{ end }}class="w-full">
|
||||||
|
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">{{ .Size }}</p>
|
||||||
|
<p class="text-sm text-gray-400">{{ .Title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if .Link }}
|
||||||
|
</a>
|
||||||
|
{{ else }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
32
templates/components/key-val-edit.tmpl
Normal file
32
templates/components/key-val-edit.tmpl
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<div class="relative">
|
||||||
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>{{ .Title }}</p>
|
||||||
|
<label class="my-auto cursor-pointer" for="edit-{{ .FormValue }}-button">
|
||||||
|
{{ template "svg/edit" (dict "Size" 18) }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="edit-{{ .FormValue }}-button"
|
||||||
|
class="hidden css-button"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ .URL }}"
|
||||||
|
class="flex flex-col gap-2 text-black dark:text-white text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="{{ .FormValue }}"
|
||||||
|
name="{{ .FormValue }}"
|
||||||
|
value="{{ or .Value "N/A" }}"
|
||||||
|
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
{{ template "component/button" (dict "Title" "Save") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="font-medium text-lg">{{ or .Value "N/A" }}</p>
|
||||||
|
</div>
|
||||||
64
templates/components/leaderboard-card.tmpl
Normal file
64
templates/components/leaderboard-card.tmpl
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<div class="w-full">
|
||||||
|
<div class="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||||
|
{{ .Name }} Leaderboard
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 text-xs text-gray-400 items-center">
|
||||||
|
<label for="all-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">all</label>
|
||||||
|
<label for="year-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">year</label>
|
||||||
|
<label for="month-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">month</label>
|
||||||
|
<label for="week-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">week</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="all-{{ .Name }}"
|
||||||
|
class="hidden peer/All"
|
||||||
|
checked />
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="year-{{ .Name }}"
|
||||||
|
class="hidden peer/Year" />
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="month-{{ .Name }}"
|
||||||
|
class="hidden peer/Month" />
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="week-{{ .Name }}"
|
||||||
|
class="hidden peer/Week" />
|
||||||
|
{{ range $key, $data := .Data }}
|
||||||
|
<div class="flex items-end my-6 space-x-2 hidden peer-checked/{{ $key }}:block">
|
||||||
|
{{ $length := len $data }}
|
||||||
|
{{ if eq $length 0 }}
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">N/A</p>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">{{ (index $data 0).UserID }}</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="hidden dark:text-white peer-checked/{{ $key }}:block">
|
||||||
|
{{ range $index, $item := $data }}
|
||||||
|
{{ if lt $index 3 }}
|
||||||
|
{{ if eq $index 0 }}
|
||||||
|
<div class="flex items-center justify-between pt-2 pb-2 text-sm">
|
||||||
|
{{ else }}
|
||||||
|
<div class="flex items-center justify-between pt-2 pb-2 text-sm border-t border-gray-200">
|
||||||
|
{{ end }}
|
||||||
|
<div>
|
||||||
|
<p>{{ $item.UserID }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ $item.Value }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
147
templates/components/metadata.tmpl
Normal file
147
templates/components/metadata.tmpl
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{{ if .Error }}
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full z-50">
|
||||||
|
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col gap-4 p-4 max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">
|
||||||
|
No Metadata Results Found
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{{ template "component/button" (dict
|
||||||
|
"Title" "Back to Document"
|
||||||
|
"Type" "Link"
|
||||||
|
"URL" (printf "/documents/%s" .ID)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Metadata }}
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full z-50">
|
||||||
|
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
||||||
|
<div
|
||||||
|
class="relative max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded"
|
||||||
|
>
|
||||||
|
<div class="py-5 text-center">
|
||||||
|
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">
|
||||||
|
Metadata Results
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
id="metadata-save"
|
||||||
|
method="POST"
|
||||||
|
action="/documents/{{ .ID }}/edit"
|
||||||
|
class="text-black dark:text-white border-b dark:border-black"
|
||||||
|
>
|
||||||
|
<dl>
|
||||||
|
<div
|
||||||
|
class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"
|
||||||
|
>
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Cover</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
<img
|
||||||
|
class="rounded object-fill h-32"
|
||||||
|
src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.SourceID }}?fife=w480-h690"
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"
|
||||||
|
>
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Title</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.Title "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"
|
||||||
|
>
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Author</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.Author "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"
|
||||||
|
>
|
||||||
|
<dt class="my-auto font-medium text-gray-500">ISBN 10</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.ISBN10 "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"
|
||||||
|
>
|
||||||
|
<dt class="my-auto font-medium text-gray-500">ISBN 13</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.ISBN13 "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6"
|
||||||
|
>
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Description</dt>
|
||||||
|
<dd class="max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.Description "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="hidden">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value="{{ .Metadata.Title }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
name="author"
|
||||||
|
value="{{ .Metadata.Author }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value="{{ .Metadata.Description }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="isbn_10"
|
||||||
|
name="isbn_10"
|
||||||
|
value="{{ .Metadata.ISBN10 }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="isbn_13"
|
||||||
|
name="isbn_13"
|
||||||
|
value="{{ .Metadata.ISBN13 }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="cover_gbid"
|
||||||
|
name="cover_gbid"
|
||||||
|
value="{{ .Metadata.SourceID }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="flex gap-4 m-4 w-48">
|
||||||
|
{{ template "component/button" (dict
|
||||||
|
"Title" "Cancel"
|
||||||
|
"Type" "Link"
|
||||||
|
"URL" (printf "/documents/%s" .ID)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ template "component/button" (dict
|
||||||
|
"Title" "Save"
|
||||||
|
"FormName" "metadata-save"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
54
templates/components/streak-card.tmpl
Normal file
54
templates/components/streak-card.tmpl
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
|
>
|
||||||
|
{{ if eq .Window "WEEK" }}
|
||||||
|
Weekly Read Streak
|
||||||
|
{{ else }}
|
||||||
|
Daily Read Streak
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end my-6 space-x-2">
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">
|
||||||
|
{{ .CurrentStreak }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="dark:text-white">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{{ if eq .Window "WEEK" }}
|
||||||
|
Current Weekly Streak
|
||||||
|
{{ else }}
|
||||||
|
Current Daily Streak
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end text-sm text-gray-400">
|
||||||
|
{{ .CurrentStreakStartDate }} ➞ {{ .CurrentStreakEndDate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ .CurrentStreak }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{{ if eq .Window "WEEK" }}
|
||||||
|
Best Weekly Streak
|
||||||
|
{{ else }}
|
||||||
|
Best Daily Streak
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end text-sm text-gray-400">
|
||||||
|
{{ .MaxStreakStartDate }} ➞ {{ .MaxStreakEndDate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ .MaxStreak }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
32
templates/components/table.tmpl
Normal file
32
templates/components/table.tmpl
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{{ $rows := .Rows }}
|
||||||
|
{{ $cols := .Columns }}
|
||||||
|
{{ $keys := .Keys }}
|
||||||
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
{{ range $col := $cols }}
|
||||||
|
<th
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{{ $col }}
|
||||||
|
</th>
|
||||||
|
{{ end }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not $rows }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="4">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $row := $rows }}
|
||||||
|
<tr>
|
||||||
|
{{ range $key := $keys }}
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
{{ template "table-cell" (dict "Data" $row "Name" $key ) }}
|
||||||
|
</td>
|
||||||
|
{{ end }}
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
28
templates/pages/activity.tmpl
Normal file
28
templates/pages/activity.tmpl
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Activity{{ end }}
|
||||||
|
{{ define "header" }}<a href="./activity">Activity</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<!-- Table Component - Utilizes Template "table-cell" -->
|
||||||
|
{{ template "component/table" (dict
|
||||||
|
"Columns" (slice "Document" "Time" "Duration" "Percent")
|
||||||
|
"Keys" (slice "Document" "StartTime" "Duration" "EndPercentage")
|
||||||
|
"Rows" .Data
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<!-- Table Cell Definition -->
|
||||||
|
{{ define "table-cell" }}
|
||||||
|
{{ if eq .Name "Document" }}
|
||||||
|
<a href="./documents/{{ .Data.DocumentID }}"
|
||||||
|
>{{ .Data.Author }} - {{ .Data.Title }}</a
|
||||||
|
>
|
||||||
|
{{ else if eq .Name "EndPercentage" }}
|
||||||
|
{{ index (fields .Data) .Name }}%
|
||||||
|
{{ else }}
|
||||||
|
{{ index (fields .Data) .Name }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
254
templates/pages/document.tmpl
Normal file
254
templates/pages/document.tmpl
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Documents{{ end }}
|
||||||
|
{{ define "header" }}<a href="/documents">Documents</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="h-full w-full relative">
|
||||||
|
<!-- Document Info -->
|
||||||
|
<div
|
||||||
|
class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"
|
||||||
|
>
|
||||||
|
<label class="z-10 cursor-pointer" for="edit-cover-button">
|
||||||
|
<img
|
||||||
|
class="rounded object-fill w-full"
|
||||||
|
src="/documents/{{ .Data.ID }}/cover"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{{ if .Data.Filepath }}
|
||||||
|
<a
|
||||||
|
href="/reader#id={{ .Data.ID }}&type=REMOTE"
|
||||||
|
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>Read</a
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
<div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative">
|
||||||
|
<div class="min-w-[50%] md:mr-2">
|
||||||
|
<div class="flex gap-1 text-sm">
|
||||||
|
<p class="text-gray-500">ISBN-10:</p>
|
||||||
|
<p class="font-medium">{{ or .Data.Isbn10 "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 text-sm">
|
||||||
|
<p class="text-gray-500">ISBN-13:</p>
|
||||||
|
<p class="font-medium">{{ or .Data.Isbn13 "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex grow justify-between my-auto text-gray-500 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="edit-cover-button"
|
||||||
|
class="hidden css-button"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute z-30 flex flex-col gap-2 top-0 left-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
|
||||||
|
>
|
||||||
|
<input type="file" id="cover_file" name="cover_file" />
|
||||||
|
{{ template "component/button" (dict "Title" "Upload Cover") }}
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
id="remove_cover"
|
||||||
|
name="remove_cover"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
{{ template "component/button" (dict "Title" "Remove Cover") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<label for="delete-button" class="cursor-pointer"
|
||||||
|
>{{ template "svg/delete" (dict "Size" 28) }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="delete-button"
|
||||||
|
class="hidden css-button"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="./{{ .Data.ID }}/delete"
|
||||||
|
class="text-black dark:text-white text-sm w-24"
|
||||||
|
>
|
||||||
|
{{ template "component/button" (dict "Title" "Delete") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="../activity?document={{ .Data.ID }}"
|
||||||
|
>{{ template "svg/activity" (dict "Size" 28) }}</a
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<label for="search-button"
|
||||||
|
>{{ template "svg/search" (dict "Size" 28) }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="search-button"
|
||||||
|
class="hidden css-button"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="./{{ .Data.ID }}/identify"
|
||||||
|
class="flex flex-col gap-2 text-black dark:text-white text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
placeholder="Title"
|
||||||
|
value="{{ or .Data.Title nil }}"
|
||||||
|
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
name="author"
|
||||||
|
placeholder="Author"
|
||||||
|
value="{{ or .Data.Author nil }}"
|
||||||
|
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="isbn"
|
||||||
|
name="isbn"
|
||||||
|
placeholder="ISBN 10 / ISBN 13"
|
||||||
|
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
|
||||||
|
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
{{ template "component/button" (dict "Title" "Identify") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if .Data.Filepath }}
|
||||||
|
<a href="./{{ .Data.ID }}/file"
|
||||||
|
>{{ template "svg/download" (dict "Size" 28) }}</a
|
||||||
|
>
|
||||||
|
{{ else }}
|
||||||
|
{{ template "svg/download" (dict "Size" 28 "Disabled" true) }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
|
||||||
|
{{ template "component/key-val-edit" (dict
|
||||||
|
"Title" "Title"
|
||||||
|
"Value" .Data.Title
|
||||||
|
"URL" (printf "./%s/edit" .Data.ID)
|
||||||
|
"FormValue" "title"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ template "component/key-val-edit" (dict
|
||||||
|
"Title" "Author"
|
||||||
|
"Value" .Data.Author
|
||||||
|
"URL" (printf "./%s/edit" .Data.ID)
|
||||||
|
"FormValue" "author"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<div class="relative">
|
||||||
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>Time Read</p>
|
||||||
|
<label class="my-auto" for="progress-info-button"
|
||||||
|
>{{ template "svg/info" (dict "Size" 18) }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="progress-info-button"
|
||||||
|
class="hidden css-button"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
|
||||||
|
>
|
||||||
|
<div class="text-xs flex">
|
||||||
|
<p class="text-gray-400 w-32">Seconds / Percent</p>
|
||||||
|
<p class="font-medium dark:text-white">
|
||||||
|
{{ .Data.SecondsPerPercent }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs flex">
|
||||||
|
<p class="text-gray-400 w-32">Words / Minute</p>
|
||||||
|
<p class="font-medium dark:text-white">{{ .Data.Wpm }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs flex">
|
||||||
|
<p class="text-gray-400 w-32">Est. Time Left</p>
|
||||||
|
<p class="font-medium dark:text-white whitespace-nowrap">
|
||||||
|
{{ niceSeconds .TotalTimeLeftSeconds }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="font-medium text-lg">
|
||||||
|
{{ niceSeconds .Data.TotalTimeSeconds }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Progress</p>
|
||||||
|
<p class="font-medium text-lg">{{ .Data.Percentage }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>Description</p>
|
||||||
|
<label class="my-auto" for="edit-description-button"
|
||||||
|
>{{ template "svg/edit" (dict "Size" 18) }}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative font-medium text-justify hyphens-auto">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="edit-description-button"
|
||||||
|
class="hidden css-button"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute h-full w-full min-h-[10em] z-30 top-1 right-0 gap-4 flex transition-all duration-200"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="hidden md:block invisible rounded w-44 md:w-60 lg:w-80 object-fill"
|
||||||
|
src="/documents/{{ .Data.ID }}/cover"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 w-full text-black bg-gray-200 rounded shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
type="text"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{{ or .Data.Description "N/A" }}</textarea
|
||||||
|
>
|
||||||
|
{{ template "component/button" (dict "Title" "Save") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p>{{ or .Data.Description "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ template "component/metadata" (dict
|
||||||
|
"ID" .Data.ID
|
||||||
|
"Metadata" .Metadata
|
||||||
|
"Error" .MetadataError
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
99
templates/pages/documents.tmpl
Normal file
99
templates/pages/documents.tmpl
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Documents{{ end }}
|
||||||
|
{{ define "header" }}<a href="./documents">Documents</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex gap-4 flex-col lg:flex-row"
|
||||||
|
action="./documents"
|
||||||
|
method="GET"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col w-full grow">
|
||||||
|
<div class="flex relative">
|
||||||
|
<span
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{{ template "svg/search2" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search"
|
||||||
|
name="search"
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 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"
|
||||||
|
placeholder="Search Author / Title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg:w-60">
|
||||||
|
{{ template "component/button" (dict
|
||||||
|
"Title" "Search"
|
||||||
|
"Variant" "Secondary"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{{ range $doc := .Data }}
|
||||||
|
{{ template "component/document-card" $doc }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
||||||
|
{{ if .PreviousPage }}
|
||||||
|
<a
|
||||||
|
href="./documents?page={{ .PreviousPage }}&limit={{ .PageLimit }}"
|
||||||
|
class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
|
||||||
|
>◄</a
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .NextPage }}
|
||||||
|
<a
|
||||||
|
href="./documents?page={{ .NextPage }}&limit={{ .PageLimit }}"
|
||||||
|
class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
|
||||||
|
>►</a
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<input type="checkbox" id="upload-file-button" class="hidden css-button" />
|
||||||
|
<div
|
||||||
|
class="absolute right-0 z-10 bottom-0 rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
action="./documents"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".epub"
|
||||||
|
id="document_file"
|
||||||
|
name="document_file"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<label for="upload-file-button">
|
||||||
|
<div
|
||||||
|
class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Cancel Upload
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
||||||
|
for="upload-file-button"
|
||||||
|
>{{ template "svg/upload" (dict "Size" 34) }}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
109
templates/pages/home.tmpl
Normal file
109
templates/pages/home.tmpl
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Home{{ end }}
|
||||||
|
{{ define "header" }}<a href="./">Home</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="relative w-full bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<p
|
||||||
|
class="absolute top-3 left-5 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
|
>
|
||||||
|
Daily Read Totals
|
||||||
|
</p>
|
||||||
|
{{ $data := (getSVGGraphData .Data.GraphData 800 70 ) }}
|
||||||
|
<div class="relative">
|
||||||
|
<svg
|
||||||
|
viewBox="26 0 755 {{ $data.Height }}"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
width="100%"
|
||||||
|
height="6em"
|
||||||
|
>
|
||||||
|
<!-- Bezier Line Graph -->
|
||||||
|
<path
|
||||||
|
fill="#316BBE"
|
||||||
|
fill-opacity="0.5"
|
||||||
|
stroke="none"
|
||||||
|
d="{{ $data.BezierPath }} {{ $data.BezierFill }}"
|
||||||
|
/>
|
||||||
|
<path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="flex absolute w-full h-full top-0"
|
||||||
|
style="width: calc(100%*31/30);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
left: 50%"
|
||||||
|
>
|
||||||
|
{{ range $index, $item := $data.LinePoints }}
|
||||||
|
<!-- Required for iOS "Hover" Events (onclick) -->
|
||||||
|
<div
|
||||||
|
onclick
|
||||||
|
class="opacity-0 hover:opacity-100 w-full"
|
||||||
|
style="background: linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center p-2 rounded absolute top-3 dark:text-white text-xs pointer-events-none"
|
||||||
|
style="transform: translateX(-50%);
|
||||||
|
background-color: rgba(128, 128, 128, 0.2);
|
||||||
|
left: 50%"
|
||||||
|
>
|
||||||
|
<span>{{ (index $.Data.GraphData $index).Date }}</span>
|
||||||
|
<span
|
||||||
|
>{{ (index $.Data.GraphData $index).MinutesRead }}
|
||||||
|
minutes</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{{ template "component/info-card" (dict
|
||||||
|
"Title" "Documents"
|
||||||
|
"Size" .Data.DatabaseInfo.DocumentsSize
|
||||||
|
"Link" "./documents"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ template "component/info-card" (dict
|
||||||
|
"Title" "Activity Records"
|
||||||
|
"Size" .Data.DatabaseInfo.ActivitySize
|
||||||
|
"Link" "./activity"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ template "component/info-card" (dict
|
||||||
|
"Title" "Progress Records"
|
||||||
|
"Size" .Data.DatabaseInfo.ProgressSize
|
||||||
|
"Link" "./progress"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ template "component/info-card" (dict
|
||||||
|
"Title" "Devices"
|
||||||
|
"Size" .Data.DatabaseInfo.DevicesSize
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{{ range $item := .Data.Streaks }}
|
||||||
|
{{ template "component/streak-card" $item }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{{ template "component/leaderboard-card" (dict
|
||||||
|
"Name" "WPM"
|
||||||
|
"Data" .Data.UserStatistics.WPM
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ template "component/leaderboard-card" (dict
|
||||||
|
"Name" "Duration"
|
||||||
|
"Data" .Data.UserStatistics.Duration
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ template "component/leaderboard-card" (dict
|
||||||
|
"Name" "Words"
|
||||||
|
"Data" .Data.UserStatistics.Words
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
28
templates/pages/progress.tmpl
Normal file
28
templates/pages/progress.tmpl
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Progress{{ end }}
|
||||||
|
{{ define "header" }}<a href="./progress">Progress</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<!-- Table Component - Utilizes Template "table-cell" -->
|
||||||
|
{{ template "component/table" (dict
|
||||||
|
"Columns" (slice "Document" "Device Name" "Percentage" "Created At")
|
||||||
|
"Keys" (slice "Document" "DeviceName" "Percentage" "CreatedAt")
|
||||||
|
"Rows" .Data
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<!-- Table Cell Definition -->
|
||||||
|
{{ define "table-cell" }}
|
||||||
|
{{ if eq .Name "Document" }}
|
||||||
|
<a href="./documents/{{ .Data.DocumentID }}"
|
||||||
|
>{{ .Data.Author }} - {{ .Data.Title }}</a
|
||||||
|
>
|
||||||
|
{{ else if eq .Name "Percentage" }}
|
||||||
|
{{ index (fields .Data) .Name }}%
|
||||||
|
{{ else }}
|
||||||
|
{{ index (fields .Data) .Name }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
157
templates/pages/search.tmpl
Normal file
157
templates/pages/search.tmpl
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Search{{ end }}
|
||||||
|
{{ define "header" }}<a href="./search">Search</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="w-full flex flex-col md:flex-row gap-4">
|
||||||
|
<div class="flex flex-col gap-4 grow">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<form class="flex gap-4 flex-col lg:flex-row" action="./search">
|
||||||
|
<div class="flex flex-col w-full grow">
|
||||||
|
<div class="flex relative">
|
||||||
|
<span
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{{ template "svg/search2" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="query"
|
||||||
|
name="query"
|
||||||
|
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"
|
||||||
|
placeholder="Query"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex relative min-w-[12em]">
|
||||||
|
<span
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{{ template "svg/documents" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
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"
|
||||||
|
id="source"
|
||||||
|
name="source"
|
||||||
|
>
|
||||||
|
<option value="LibGen">Library Genesis</option>
|
||||||
|
<option value="Annas Archive">Annas Archive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lg:w-60">
|
||||||
|
{{ template "component/button" (dict
|
||||||
|
"Title" "Search"
|
||||||
|
"Variant" "Secondary"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{ if .SearchErrorMessage }}
|
||||||
|
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table
|
||||||
|
class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"
|
||||||
|
>
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
></th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Document
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Series
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="6">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $item := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
class="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
<form action="./search" method="POST">
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="text"
|
||||||
|
id="source"
|
||||||
|
name="source"
|
||||||
|
value="{{ $.Source }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value="{{ $item.Title }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
name="author"
|
||||||
|
value="{{ $item.Author }}"
|
||||||
|
/>
|
||||||
|
<button name="id" value="{{ $item.ID }}">
|
||||||
|
{{ template "svg/download" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
{{ $item.Author }} -
|
||||||
|
{{ $item.Title }}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.Series "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.FileType "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.FileSize "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.UploadDate "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -1 +1 @@
|
|||||||
<path fill-rule="nonzero" fill-opacity="1" d="M 18.429688 10.285156 C 18.785156 10.285156 19.089844 10.410156 19.339844 10.660156 C 19.589844 10.910156 19.714844 11.214844 19.714844 11.570312 L 19.714844 19.285156 C 19.714844 19.644531 19.589844 19.945312 19.339844 20.195312 C 19.089844 20.445312 18.785156 20.570312 18.429688 20.570312 L 5.570312 20.570312 C 5.214844 20.570312 4.910156 20.445312 4.660156 20.195312 C 4.410156 19.945312 4.285156 19.644531 4.285156 19.285156 L 4.285156 11.570312 C 4.285156 11.214844 4.410156 10.910156 4.660156 10.660156 C 4.910156 10.410156 5.214844 10.285156 5.570312 10.285156 L 6 10.285156 L 6 6 C 6 4.347656 6.585938 2.933594 7.761719 1.761719 C 8.933594 0.585938 10.347656 0 12 0 C 13.652344 0 15.066406 0.585938 16.238281 1.761719 C 17.414062 2.933594 18 4.347656 18 6 C 18 6.230469 17.914062 6.433594 17.746094 6.601562 C 17.574219 6.773438 17.375 6.855469 17.144531 6.855469 L 16.285156 6.855469 C 16.054688 6.855469 15.851562 6.773438 15.683594 6.601562 C 15.511719 6.433594 15.429688 6.230469 15.429688 6 C 15.429688 5.054688 15.09375 4.246094 14.425781 3.574219 C 13.753906 2.90625 12.945312 2.570312 12 2.570312 C 11.054688 2.570312 10.246094 2.90625 9.574219 3.574219 C 8.90625 4.246094 8.570312 5.054688 8.570312 6 L 8.570312 10.285156 Z M 18.429688 10.285156 "/>
|
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 18.429688 10.285156 C 18.785156 10.285156 19.089844 10.410156 19.339844 10.660156 C 19.589844 10.910156 19.714844 11.214844 19.714844 11.570312 L 19.714844 19.285156 C 19.714844 19.644531 19.589844 19.945312 19.339844 20.195312 C 19.089844 20.445312 18.785156 20.570312 18.429688 20.570312 L 5.570312 20.570312 C 5.214844 20.570312 4.910156 20.445312 4.660156 20.195312 C 4.410156 19.945312 4.285156 19.644531 4.285156 19.285156 L 4.285156 11.570312 C 4.285156 11.214844 4.410156 10.910156 4.660156 10.660156 C 4.910156 10.410156 5.214844 10.285156 5.570312 10.285156 L 6 10.285156 L 6 6 C 6 4.347656 6.585938 2.933594 7.761719 1.761719 C 8.933594 0.585938 10.347656 0 12 0 C 13.652344 0 15.066406 0.585938 16.238281 1.761719 C 17.414062 2.933594 18 4.347656 18 6 C 18 6.230469 17.914062 6.433594 17.746094 6.601562 C 17.574219 6.773438 17.375 6.855469 17.144531 6.855469 L 16.285156 6.855469 C 16.054688 6.855469 15.851562 6.773438 15.683594 6.601562 C 15.511719 6.433594 15.429688 6.230469 15.429688 6 C 15.429688 5.054688 15.09375 4.246094 14.425781 3.574219 C 13.753906 2.90625 12.945312 2.570312 12 2.570312 C 11.054688 2.570312 10.246094 2.90625 9.574219 3.574219 C 8.90625 4.246094 8.570312 5.054688 8.570312 6 L 8.570312 10.285156 Z M 18.429688 10.285156 "/>
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ func IdentifyPopover(docID string, m *models.DocumentMetadata) g.Node {
|
|||||||
return nil
|
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(
|
return ui.Popover(h.Div(
|
||||||
h.Class("flex flex-col gap-2"),
|
h.Class("flex flex-col gap-2"),
|
||||||
h.H3(
|
h.H3(
|
||||||
@@ -1,41 +1,35 @@
|
|||||||
package layout
|
package layout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
g "maragu.dev/gomponents"
|
g "maragu.dev/gomponents"
|
||||||
h "maragu.dev/gomponents/html"
|
h "maragu.dev/gomponents/html"
|
||||||
"reichard.io/antholume/web/components/ui"
|
"reichard.io/antholume/web/pages"
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Layout(ctx models.PageContext, children ...g.Node) (g.Node, error) {
|
type LayoutOptions struct {
|
||||||
if ctx.UserInfo == nil {
|
SearchEnabled bool
|
||||||
return nil, errors.New("no user info")
|
IsAdmin bool
|
||||||
} else if ctx.ServerInfo == nil {
|
Username string
|
||||||
return nil, errors.New("no server info")
|
Version string
|
||||||
} 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(
|
return h.Doctype(
|
||||||
h.HTML(
|
h.HTML(
|
||||||
g.Attr("lang", "en"),
|
g.Attr("lang", "en"),
|
||||||
Head(ctx.Route.Title()),
|
Head(p.Route().Title()),
|
||||||
h.Body(
|
h.Body(
|
||||||
g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"),
|
g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"),
|
||||||
Navigation(ctx),
|
Navigation(p.Route(), &opts),
|
||||||
Base(children),
|
Base(p.Render()),
|
||||||
ui.Notifications(ctx.Notifications),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
), nil
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Head(routeTitle string) g.Node {
|
func Head(routeTitle string) g.Node {
|
||||||
return h.Head(
|
return h.Head(
|
||||||
g.El("title", g.Text("AnthoLume - "+routeTitle)),
|
h.Title("AnthoLume - "+routeTitle),
|
||||||
h.Meta(g.Attr("charset", "utf-8")),
|
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", "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")),
|
h.Meta(g.Attr("name", "apple-mobile-web-app-capable"), g.Attr("content", "yes")),
|
||||||
@@ -51,13 +45,13 @@ func Head(routeTitle string) g.Node {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Base(body []g.Node) g.Node {
|
func Base(body g.Node) g.Node {
|
||||||
return h.Main(
|
return h.Main(
|
||||||
g.Attr("class", "relative overflow-hidden"),
|
g.Attr("class", "relative overflow-hidden"),
|
||||||
h.Div(
|
h.Div(
|
||||||
g.Attr("id", "container"),
|
g.Attr("id", "container"),
|
||||||
g.Attr("class", "h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"),
|
g.Attr("class", "h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"),
|
||||||
g.Group(body),
|
body,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
g "maragu.dev/gomponents"
|
g "maragu.dev/gomponents"
|
||||||
h "maragu.dev/gomponents/html"
|
h "maragu.dev/gomponents/html"
|
||||||
"reichard.io/antholume/web/assets"
|
"reichard.io/antholume/web/assets"
|
||||||
"reichard.io/antholume/web/models"
|
"reichard.io/antholume/web/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,28 +14,29 @@ const (
|
|||||||
inactive = "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
|
inactive = "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Navigation(ctx models.PageContext) g.Node {
|
func Navigation(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node {
|
||||||
return h.Div(
|
return h.Div(
|
||||||
g.Attr("class", "flex items-center justify-between w-full h-16"),
|
g.Attr("class", "flex items-center justify-between w-full h-16"),
|
||||||
Sidebar(ctx),
|
Sidebar(currentRoute, opts),
|
||||||
h.H1(g.Attr("class", "text-xl font-bold whitespace-nowrap px-6 lg:ml-44"), g.Text(ctx.Route.Title())),
|
h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(currentRoute.Title())),
|
||||||
Dropdown(ctx.UserInfo.Username),
|
Dropdown(opts.Username),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Sidebar(ctx models.PageContext) g.Node {
|
func Sidebar(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node {
|
||||||
links := []g.Node{
|
links := []g.Node{
|
||||||
navLink(ctx.Route, models.HomePage, "/", "home"),
|
navLink(currentRoute, pages.HomePage, "/", "home"),
|
||||||
navLink(ctx.Route, models.DocumentsPage, "/documents", "documents"),
|
navLink(currentRoute, pages.DocumentsPage, "/documents", "documents"),
|
||||||
navLink(ctx.Route, models.ProgressPage, "/progress", "activity"),
|
navLink(currentRoute, pages.ProgressPage, "/progress", "activity"),
|
||||||
navLink(ctx.Route, models.ActivityPage, "/activity", "activity"),
|
navLink(currentRoute, pages.ActivityPage, "/activity", "activity"),
|
||||||
}
|
}
|
||||||
if ctx.ServerInfo.SearchEnabled {
|
if opts.SearchEnabled {
|
||||||
links = append(links, navLink(ctx.Route, models.SearchPage, "/search", "search"))
|
links = append(links, navLink(currentRoute, pages.SearchPage, "/search", "search"))
|
||||||
}
|
}
|
||||||
if ctx.UserInfo.IsAdmin {
|
if opts.IsAdmin {
|
||||||
links = append(links, adminLinks(ctx.Route))
|
links = append(links, adminLinks(currentRoute))
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.Div(
|
return h.Div(
|
||||||
g.Attr("id", "mobile-nav-button"),
|
g.Attr("id", "mobile-nav-button"),
|
||||||
g.Attr("class", "flex flex-col z-40 relative ml-6"),
|
g.Attr("class", "flex flex-col z-40 relative ml-6"),
|
||||||
@@ -53,13 +54,13 @@ func Sidebar(ctx models.PageContext) g.Node {
|
|||||||
g.Attr("target", "_blank"),
|
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"),
|
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),
|
assets.Icon("gitea", 20),
|
||||||
h.Span(g.Attr("class", "text-xs"), g.Text(ctx.ServerInfo.Version)),
|
h.Span(g.Attr("class", "text-xs"), g.Text(opts.Version)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func navLink(currentRoute, linkRoute models.PageRoute, path, icon string) g.Node {
|
func navLink(currentRoute, linkRoute pages.PageRoute, path, icon string) g.Node {
|
||||||
class := inactive
|
class := inactive
|
||||||
if currentRoute == linkRoute {
|
if currentRoute == linkRoute {
|
||||||
class = active
|
class = active
|
||||||
@@ -72,7 +73,7 @@ func navLink(currentRoute, linkRoute models.PageRoute, path, icon string) g.Node
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminLinks(currentRoute models.PageRoute) g.Node {
|
func adminLinks(currentRoute pages.PageRoute) g.Node {
|
||||||
routeID := string(currentRoute)
|
routeID := string(currentRoute)
|
||||||
|
|
||||||
class := inactive
|
class := inactive
|
||||||
@@ -82,10 +83,10 @@ func adminLinks(currentRoute models.PageRoute) g.Node {
|
|||||||
|
|
||||||
children := g.If(strings.HasPrefix(routeID, "admin"),
|
children := g.If(strings.HasPrefix(routeID, "admin"),
|
||||||
g.Group([]g.Node{
|
g.Group([]g.Node{
|
||||||
subNavLink(currentRoute, models.AdminGeneralPage, "/admin"),
|
subNavLink(currentRoute, pages.AdminGeneralPage, "/admin"),
|
||||||
subNavLink(currentRoute, models.AdminImportPage, "/admin/import"),
|
subNavLink(currentRoute, pages.AdminImportPage, "/admin/import"),
|
||||||
subNavLink(currentRoute, models.AdminUsersPage, "/admin/users"),
|
subNavLink(currentRoute, pages.AdminUsersPage, "/admin/users"),
|
||||||
subNavLink(currentRoute, models.AdminLogsPage, "/admin/logs"),
|
subNavLink(currentRoute, pages.AdminLogsPage, "/admin/logs"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ func adminLinks(currentRoute models.PageRoute) g.Node {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subNavLink(currentRoute, linkRoute models.PageRoute, path string) g.Node {
|
func subNavLink(currentRoute, linkRoute pages.PageRoute, path string) g.Node {
|
||||||
class := inactive
|
class := inactive
|
||||||
if currentRoute == linkRoute {
|
if currentRoute == linkRoute {
|
||||||
class = active
|
class = active
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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 text-white dark:text-black"),
|
|
||||||
g.Group(sliceutils.Map(notifications, notificationNode)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func notificationNode(n *models.Notification) g.Node {
|
|
||||||
return h.Div(
|
|
||||||
h.Class("p-2 sm:p-4 animate-notification"),
|
|
||||||
h.Div(
|
|
||||||
h.Class("bg-gray-600 dark:bg-gray-400 px-4 py-2 rounded-lg shadow-lg w-64"),
|
|
||||||
g.Text(n.Content),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
type Device struct {
|
|
||||||
DeviceName string
|
|
||||||
LastSynced string
|
|
||||||
CreatedAt string
|
|
||||||
}
|
|
||||||
@@ -29,4 +29,5 @@ type DocumentMetadata struct {
|
|||||||
Author string
|
Author string
|
||||||
Description string
|
Description string
|
||||||
Source metadata.Source
|
Source metadata.Source
|
||||||
|
Error *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
type UserInfo struct {
|
|
||||||
Username string
|
|
||||||
IsAdmin bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerInfo struct {
|
|
||||||
RegistrationEnabled bool
|
|
||||||
SearchEnabled bool
|
|
||||||
Version string
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
type NotificationType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
NotificationTypeSuccess NotificationType = iota
|
|
||||||
NotificationTypeError
|
|
||||||
)
|
|
||||||
|
|
||||||
type Notification struct {
|
|
||||||
Content string
|
|
||||||
Type NotificationType
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"reichard.io/antholume/pkg/sliceutils"
|
"reichard.io/antholume/pkg/sliceutils"
|
||||||
"reichard.io/antholume/web/components/ui"
|
"reichard.io/antholume/web/components/ui"
|
||||||
"reichard.io/antholume/web/models"
|
"reichard.io/antholume/web/models"
|
||||||
"reichard.io/antholume/web/pages/layout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ Page = (*Activity)(nil)
|
var _ Page = (*Activity)(nil)
|
||||||
@@ -18,15 +17,14 @@ type Activity struct {
|
|||||||
Data []models.Activity
|
Data []models.Activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Activity) Generate(ctx models.PageContext) (g.Node, error) {
|
func (Activity) Route() PageRoute { return ActivityPage }
|
||||||
return layout.Layout(
|
|
||||||
ctx.WithRoute(models.ActivityPage),
|
func (p Activity) Render() g.Node {
|
||||||
|
return h.Div(
|
||||||
|
h.Class("overflow-x-auto"),
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("overflow-x-auto"),
|
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
|
||||||
h.Div(
|
ui.Table(p.buildTableConfig()),
|
||||||
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
|
|
||||||
ui.Table(p.buildTableConfig()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
g "maragu.dev/gomponents"
|
|
||||||
h "maragu.dev/gomponents/html"
|
|
||||||
|
|
||||||
"reichard.io/antholume/web/components/ui"
|
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
"reichard.io/antholume/web/pages/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ Page = (*AdminGeneral)(nil)
|
|
||||||
|
|
||||||
type AdminGeneral struct{}
|
|
||||||
|
|
||||||
func (p *AdminGeneral) Generate(ctx models.PageContext) (g.Node, error) {
|
|
||||||
return layout.Layout(
|
|
||||||
ctx.WithRoute(models.AdminGeneralPage),
|
|
||||||
h.Div(
|
|
||||||
h.Class("w-full flex flex-col gap-4 grow"),
|
|
||||||
backupAndRestoreSection(),
|
|
||||||
tasksSection(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func backupAndRestoreSection() g.Node {
|
|
||||||
return h.Div(
|
|
||||||
h.Class("flex flex-col gap-2 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 mb-2"),
|
|
||||||
g.Text("Backup & Restore"),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("flex flex-col gap-4"),
|
|
||||||
backupForm(),
|
|
||||||
restoreForm(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func backupForm() g.Node {
|
|
||||||
return h.Form(
|
|
||||||
h.Class("flex justify-between"),
|
|
||||||
h.Action("./admin"),
|
|
||||||
h.Method("POST"),
|
|
||||||
h.Input(
|
|
||||||
h.Type("text"),
|
|
||||||
h.Name("action"),
|
|
||||||
h.Value("BACKUP"),
|
|
||||||
h.Class("hidden"),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("flex gap-8"),
|
|
||||||
h.Div(
|
|
||||||
h.Class("flex gap-2 items-center"),
|
|
||||||
h.Input(
|
|
||||||
h.Type("checkbox"),
|
|
||||||
h.ID("backup_covers"),
|
|
||||||
h.Name("backup_types"),
|
|
||||||
h.Value("COVERS"),
|
|
||||||
),
|
|
||||||
h.Label(
|
|
||||||
h.For("backup_covers"),
|
|
||||||
g.Text("Covers"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("flex gap-2 items-center"),
|
|
||||||
h.Input(
|
|
||||||
h.Type("checkbox"),
|
|
||||||
h.ID("backup_documents"),
|
|
||||||
h.Name("backup_types"),
|
|
||||||
h.Value("DOCUMENTS"),
|
|
||||||
),
|
|
||||||
h.Label(
|
|
||||||
h.For("backup_documents"),
|
|
||||||
g.Text("Documents"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("h-10 w-40"),
|
|
||||||
ui.FormButton(g.Text("Backup"), "", ui.ButtonConfig{Variant: ui.ButtonVariantSecondary}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreForm() g.Node {
|
|
||||||
return h.Form(
|
|
||||||
h.Class("flex justify-between"),
|
|
||||||
h.Action("./admin"),
|
|
||||||
h.Method("POST"),
|
|
||||||
g.Attr("enctype", "multipart/form-data"),
|
|
||||||
h.Input(
|
|
||||||
h.Type("text"),
|
|
||||||
h.Name("action"),
|
|
||||||
h.Value("RESTORE"),
|
|
||||||
h.Class("hidden"),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("flex items-center"),
|
|
||||||
h.Input(
|
|
||||||
h.Type("file"),
|
|
||||||
h.Accept(".zip"),
|
|
||||||
h.Name("restore_file"),
|
|
||||||
h.Class("w-full"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("h-10 w-40"),
|
|
||||||
ui.FormButton(g.Text("Restore"), "", ui.ButtonConfig{Variant: ui.ButtonVariantSecondary}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tasksSection() 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 mb-4"),
|
|
||||||
g.Text("Tasks"),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("grid grid-cols-[1fr_auto] gap-x-4 gap-y-3 items-center"),
|
|
||||||
g.Group(taskItem("Metadata Matching", "METADATA_MATCH")),
|
|
||||||
g.Group(taskItem("Cache Tables", "CACHE_TABLES")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func taskItem(name, action string) []g.Node {
|
|
||||||
return []g.Node{
|
|
||||||
h.P(
|
|
||||||
h.Class("text-black dark:text-white"),
|
|
||||||
g.Text(name),
|
|
||||||
),
|
|
||||||
h.Form(
|
|
||||||
h.Action("./admin"),
|
|
||||||
h.Method("POST"),
|
|
||||||
h.Input(
|
|
||||||
h.Type("text"),
|
|
||||||
h.Name("action"),
|
|
||||||
h.Value(action),
|
|
||||||
h.Class("hidden"),
|
|
||||||
),
|
|
||||||
h.Div(
|
|
||||||
h.Class("h-10 w-40"),
|
|
||||||
ui.FormButton(g.Text("Run"), "", ui.ButtonConfig{Variant: ui.ButtonVariantSecondary}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"reichard.io/antholume/web/components/document"
|
"reichard.io/antholume/web/components/document"
|
||||||
"reichard.io/antholume/web/components/ui"
|
"reichard.io/antholume/web/components/ui"
|
||||||
"reichard.io/antholume/web/models"
|
"reichard.io/antholume/web/models"
|
||||||
"reichard.io/antholume/web/pages/layout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ Page = (*Document)(nil)
|
var _ Page = (*Document)(nil)
|
||||||
@@ -23,14 +22,9 @@ type Document struct {
|
|||||||
Search *models.DocumentMetadata
|
Search *models.DocumentMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Document) Generate(ctx models.PageContext) (g.Node, error) {
|
func (Document) Route() PageRoute { return DocumentPage }
|
||||||
return layout.Layout(
|
|
||||||
ctx.WithRoute(models.DocumentPage),
|
|
||||||
p.content(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Document) content() g.Node {
|
func (p Document) Render() g.Node {
|
||||||
return h.Div(
|
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"),
|
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),
|
document.Actions(p.Data),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"reichard.io/antholume/web/components/document"
|
"reichard.io/antholume/web/components/document"
|
||||||
"reichard.io/antholume/web/components/ui"
|
"reichard.io/antholume/web/components/ui"
|
||||||
"reichard.io/antholume/web/models"
|
"reichard.io/antholume/web/models"
|
||||||
"reichard.io/antholume/web/pages/layout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ Page = (*Documents)(nil)
|
var _ Page = (*Documents)(nil)
|
||||||
@@ -21,13 +20,15 @@ type Documents struct {
|
|||||||
Limit int
|
Limit int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p Documents) Generate(ctx models.PageContext) (g.Node, error) {
|
func (Documents) Route() PageRoute { return DocumentsPage }
|
||||||
return layout.Layout(ctx.WithRoute(models.DocumentsPage),
|
|
||||||
|
func (p Documents) Render() g.Node {
|
||||||
|
return g.Group([]g.Node{
|
||||||
searchBar(),
|
searchBar(),
|
||||||
documentGrid(p.Data),
|
documentGrid(p.Data),
|
||||||
pagination(p.Previous, p.Next, p.Limit),
|
pagination(p.Previous, p.Next, p.Limit),
|
||||||
uploadFAB(),
|
uploadFAB(),
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchBar() g.Node {
|
func searchBar() g.Node {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
h "maragu.dev/gomponents/html"
|
h "maragu.dev/gomponents/html"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/web/components/stats"
|
"reichard.io/antholume/web/components/stats"
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
"reichard.io/antholume/web/pages/layout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ Page = (*Home)(nil)
|
var _ Page = (*Home)(nil)
|
||||||
@@ -18,11 +16,9 @@ type Home struct {
|
|||||||
RecordInfo *database.GetDatabaseInfoRow
|
RecordInfo *database.GetDatabaseInfoRow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Home) Generate(ctx models.PageContext) (g.Node, error) {
|
func (Home) Route() PageRoute { return HomePage }
|
||||||
return layout.Layout(ctx.WithRoute(models.HomePage), p.content())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Home) content() g.Node {
|
func (p Home) Render() g.Node {
|
||||||
return h.Div(
|
return h.Div(
|
||||||
g.Attr("class", "flex flex-col gap-4"),
|
g.Attr("class", "flex flex-col gap-4"),
|
||||||
h.Div(
|
h.Div(
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
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]
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,41 @@ package pages
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
g "maragu.dev/gomponents"
|
g "maragu.dev/gomponents"
|
||||||
"reichard.io/antholume/web/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Page interface {
|
type PageRoute string
|
||||||
Generate(ctx models.PageContext) (g.Node, error)
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"reichard.io/antholume/pkg/sliceutils"
|
"reichard.io/antholume/pkg/sliceutils"
|
||||||
"reichard.io/antholume/web/components/ui"
|
"reichard.io/antholume/web/components/ui"
|
||||||
"reichard.io/antholume/web/models"
|
"reichard.io/antholume/web/models"
|
||||||
"reichard.io/antholume/web/pages/layout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ Page = (*Progress)(nil)
|
var _ Page = (*Progress)(nil)
|
||||||
@@ -17,15 +16,14 @@ type Progress struct {
|
|||||||
Data []models.Progress
|
Data []models.Progress
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Progress) Generate(ctx models.PageContext) (g.Node, error) {
|
func (Progress) Route() PageRoute { return ProgressPage }
|
||||||
return layout.Layout(
|
|
||||||
ctx.WithRoute(models.ProgressPage),
|
func (p Progress) Render() g.Node {
|
||||||
|
return h.Div(
|
||||||
|
h.Class("overflow-x-auto"),
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("overflow-x-auto"),
|
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
|
||||||
h.Div(
|
ui.Table(p.buildTableConfig()),
|
||||||
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
|
|
||||||
ui.Table(p.buildTableConfig()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"reichard.io/antholume/web/assets"
|
"reichard.io/antholume/web/assets"
|
||||||
"reichard.io/antholume/web/components/ui"
|
"reichard.io/antholume/web/components/ui"
|
||||||
"reichard.io/antholume/web/models"
|
"reichard.io/antholume/web/models"
|
||||||
"reichard.io/antholume/web/pages/layout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ Page = (*Search)(nil)
|
var _ Page = (*Search)(nil)
|
||||||
@@ -24,14 +23,9 @@ type Search struct {
|
|||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p Search) Generate(ctx models.PageContext) (g.Node, error) {
|
func (Search) Route() PageRoute { return SearchPage }
|
||||||
return layout.Layout(
|
|
||||||
ctx.WithRoute(models.SearchPage),
|
|
||||||
p.content(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Search) content() g.Node {
|
func (p Search) Render() g.Node {
|
||||||
return h.Div(
|
return h.Div(
|
||||||
h.Class("flex flex-col gap-4"),
|
h.Class("flex flex-col gap-4"),
|
||||||
h.Div(
|
h.Div(
|
||||||
@@ -102,7 +96,7 @@ func (p *Search) content() 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 sliceutils.Map(p.Results, func(r models.SearchResult) ui.TableRow {
|
||||||
return ui.TableRow{
|
return ui.TableRow{
|
||||||
"": ui.TableCell{
|
"": ui.TableCell{
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
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},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user