[refactor] app routes, [add] progress table
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2023-12-31 23:12:46 -05:00
parent a69f20d5a9
commit d3d89b36f6
14 changed files with 807 additions and 579 deletions

View File

@ -83,51 +83,52 @@ func (api *API) registerWebAppRoutes() {
api.Router.HTMLRender = *api.generateTemplates()
// Static Assets (Required @ Root)
api.Router.GET("/manifest.json", api.webManifest)
api.Router.GET("/favicon.ico", api.faviconIcon)
api.Router.GET("/sw.js", api.serviceWorker)
api.Router.GET("/manifest.json", api.appWebManifest)
api.Router.GET("/favicon.ico", api.appFaviconIcon)
api.Router.GET("/sw.js", api.appServiceWorker)
// Local / Offline Static Pages (No Template, No Auth)
api.Router.GET("/local", api.localDocuments)
api.Router.GET("/local", api.appLocalDocuments)
// Reader (Reader Page, Document Progress, Devices)
api.Router.GET("/reader", api.documentReader)
api.Router.GET("/reader/devices", api.authWebAppMiddleware, api.getDevices)
api.Router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.getDocumentProgress)
api.Router.GET("/reader", api.appDocumentReader)
api.Router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
api.Router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
// Web App
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.GET("/", api.authWebAppMiddleware, api.appGetHome)
api.Router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity)
api.Router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress)
api.Router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments)
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument)
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
api.Router.GET("/login", api.createAppResourcesRoute("login"))
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings"))
api.Router.POST("/login", api.authFormLogin)
api.Router.POST("/register", api.authFormRegister)
api.Router.GET("/login", api.appGetLogin)
api.Router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
api.Router.GET("/register", api.appGetRegister)
api.Router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
api.Router.POST("/login", api.appAuthFormLogin)
api.Router.POST("/register", api.appAuthFormRegister)
// Demo Mode Enabled Configuration
if api.Config.DemoMode {
api.Router.POST("/documents", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/settings", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/documents", api.authWebAppMiddleware, api.appDemoModeError)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDemoModeError)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appDemoModeError)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
api.Router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
} else {
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
api.Router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocument)
api.Router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings)
}
// Search Enabled Configuration
if api.Config.SearchEnabled {
api.Router.GET("/search", api.authWebAppMiddleware, api.createAppResourcesRoute("search"))
api.Router.POST("/search", api.authWebAppMiddleware, api.saveNewDocument)
api.Router.GET("/search", api.authWebAppMiddleware, api.appGetSearch)
api.Router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument)
}
}
@ -136,22 +137,22 @@ func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
// KO Sync Routes (WebApp Uses - Progress & Activity)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocument)
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.getProgress)
koGroup.GET("/users/auth", api.authKOMiddleware, api.authorizeUser)
koGroup.POST("/activity", api.authKOMiddleware, api.addActivities)
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync)
koGroup.POST("/users/create", api.createUser)
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.setProgress)
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.koGetProgress)
koGroup.GET("/users/auth", api.authKOMiddleware, api.koAuthorizeUser)
koGroup.POST("/activity", api.authKOMiddleware, api.koAddActivities)
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.koCheckActivitySync)
koGroup.POST("/users/create", api.koCreateUser)
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.koSetProgress)
// Demo Mode Enabled Configuration
if api.Config.DemoMode {
koGroup.POST("/documents", api.authKOMiddleware, api.demoModeJSONError)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.demoModeJSONError)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.demoModeJSONError)
koGroup.POST("/documents", api.authKOMiddleware, api.koDemoModeJSONError)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.koDemoModeJSONError)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.koDemoModeJSONError)
} else {
koGroup.POST("/documents", api.authKOMiddleware, api.addDocuments)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.checkDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadExistingDocument)
koGroup.POST("/documents", api.authKOMiddleware, api.koAddDocuments)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.koCheckDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.koUploadExistingDocument)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -115,35 +115,31 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
return
}
func (api *API) authFormLogin(c *gin.Context) {
func (api *API) appAuthFormLogin(c *gin.Context) {
templateVars := api.getBaseTemplateVars("login", c)
username := strings.TrimSpace(c.PostForm("username"))
rawPassword := strings.TrimSpace(c.PostForm("password"))
if username == "" || rawPassword == "" {
c.HTML(http.StatusUnauthorized, "page/login", gin.H{
"RegistrationEnabled": api.Config.RegistrationEnabled,
"Error": "Invalid Credentials",
})
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
}
// MD5 - KOSync Compatiblity
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if authorized := api.authorizeCredentials(username, password); authorized != true {
c.HTML(http.StatusUnauthorized, "page/login", gin.H{
"RegistrationEnabled": api.Config.RegistrationEnabled,
"Error": "Invalid Credentials",
})
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
}
// Set Session
session := sessions.Default(c)
if err := setSession(session, username); err != nil {
c.HTML(http.StatusUnauthorized, "page/login", gin.H{
"RegistrationEnabled": api.Config.RegistrationEnabled,
"Error": "Unknown Error",
})
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
}
@ -151,30 +147,29 @@ func (api *API) authFormLogin(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
}
func (api *API) authFormRegister(c *gin.Context) {
func (api *API) appAuthFormRegister(c *gin.Context) {
if !api.Config.RegistrationEnabled {
errorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
return
}
templateVars := api.getBaseTemplateVars("login", c)
templateVars["Register"] = true
username := strings.TrimSpace(c.PostForm("username"))
rawPassword := strings.TrimSpace(c.PostForm("password"))
if username == "" || rawPassword == "" {
c.HTML(http.StatusBadRequest, "page/login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
templateVars["Error"] = "Invalid User or Password"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
c.HTML(http.StatusBadRequest, "page/login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
@ -185,19 +180,17 @@ func (api *API) authFormRegister(c *gin.Context) {
// SQL Error
if err != nil {
c.HTML(http.StatusBadRequest, "page/login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
log.Error("[appAuthFormRegister] CreateUser DB Error:", err)
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
// User Already Exists
if rows == 0 {
c.HTML(http.StatusBadRequest, "page/login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
log.Warn("[appAuthFormRegister] User Already Exists:", username)
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
@ -212,21 +205,13 @@ func (api *API) authFormRegister(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
}
func (api *API) authLogout(c *gin.Context) {
func (api *API) appAuthLogout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
c.Redirect(http.StatusFound, "/login")
}
func (api *API) demoModeAppError(c *gin.Context) {
errorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
}
func (api *API) demoModeJSONError(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not Allowed in Demo Mode"})
}
func getSession(session sessions.Session) (user string, ok bool) {
// Check Session
authorizedUser := session.Get("authorizedUser")

140
api/common.go Normal file
View File

@ -0,0 +1,140 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"net/http"
"os"
"path/filepath"
"reichard.io/bbank/database"
"reichard.io/bbank/metadata"
)
func (api *API) downloadDocument(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[downloadDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get Document
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("[downloadDocument] GetDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return
}
if document.Filepath == nil {
log.Error("[downloadDocument] Document Doesn't Have File:", rDoc.DocumentID)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"})
return
}
// Derive Storage Location
filePath := filepath.Join(api.Config.DataPath, "documents", *document.Filepath)
// Validate File Exists
_, err = os.Stat(filePath)
if os.IsNotExist(err) {
log.Error("[downloadDocument] File Doesn't Exist:", rDoc.DocumentID)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"})
return
}
// Force Download (Security)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(*document.Filepath)))
c.File(filePath)
}
func (api *API) getDocumentCover(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[getDocumentCover] Invalid URI Bind")
errorPage(c, http.StatusNotFound, "Invalid cover.")
return
}
// Validate Document Exists in DB
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("[getDocumentCover] GetDocument DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
return
}
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.Assets))
return
}
// Derive Path
safePath := filepath.Join(api.Config.DataPath, "covers", *document.Coverfile)
// Validate File Exists
_, err = os.Stat(safePath)
if err != nil {
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.Assets))
return
}
c.File(safePath)
return
}
// Attempt Metadata
var coverDir string = filepath.Join(api.Config.DataPath, "covers")
var coverFile string = "UNKNOWN"
// Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadata(metadata.GBOOK, metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
})
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
firstResult := metadataResults[0]
// Save Cover
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
if err == nil {
coverFile = *fileName
}
// Store First Metadata Result
if _, err = api.DB.Queries.AddMetadata(api.DB.Ctx, database.AddMetadataParams{
DocumentID: document.ID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.ID,
Olid: nil,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.Error("[getDocumentCover] AddMetadata DB Error:", err)
}
}
// Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: document.ID,
Coverfile: &coverFile,
}); err != nil {
log.Warn("[getDocumentCover] UpsertDocument DB Error:", err)
}
// Return Unknown Cover
if coverFile == "UNKNOWN" {
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.Assets))
return
}
coverFilePath := filepath.Join(coverDir, coverFile)
c.File(coverFilePath)
}

View File

@ -75,13 +75,13 @@ type requestDocumentID struct {
DocumentID string `uri:"document" binding:"required"`
}
func (api *API) authorizeUser(c *gin.Context) {
func (api *API) koAuthorizeUser(c *gin.Context) {
c.JSON(200, gin.H{
"authorized": "OK",
})
}
func (api *API) createUser(c *gin.Context) {
func (api *API) koCreateUser(c *gin.Context) {
if !api.Config.RegistrationEnabled {
c.AbortWithStatus(http.StatusConflict)
return
@ -89,20 +89,20 @@ func (api *API) createUser(c *gin.Context) {
var rUser requestUser
if err := c.ShouldBindJSON(&rUser); err != nil {
log.Error("[createUser] Invalid JSON Bind")
log.Error("[koCreateUser] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
if rUser.Username == "" || rUser.Password == "" {
log.Error("[createUser] Invalid User - Empty Username or Password")
log.Error("[koCreateUser] Invalid User - Empty Username or Password")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
if err != nil {
log.Error("[createUser] Argon2 Hash Failure:", err)
log.Error("[koCreateUser] Argon2 Hash Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
@ -112,7 +112,7 @@ func (api *API) createUser(c *gin.Context) {
Pass: &hashedPassword,
})
if err != nil {
log.Error("[createUser] CreateUser DB Error:", err)
log.Error("[koCreateUser] CreateUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
@ -128,12 +128,12 @@ func (api *API) createUser(c *gin.Context) {
})
}
func (api *API) setProgress(c *gin.Context) {
func (api *API) koSetProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rPosition requestPosition
if err := c.ShouldBindJSON(&rPosition); err != nil {
log.Error("[setProgress] Invalid JSON Bind")
log.Error("[koSetProgress] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Progress Data"})
return
}
@ -145,14 +145,14 @@ func (api *API) setProgress(c *gin.Context) {
DeviceName: rPosition.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("[setProgress] UpsertDevice DB Error:", err)
log.Error("[koSetProgress] UpsertDevice DB Error:", err)
}
// Upsert Document
if _, err := api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: rPosition.DocumentID,
}); err != nil {
log.Error("[setProgress] UpsertDocument DB Error:", err)
log.Error("[koSetProgress] UpsertDocument DB Error:", err)
}
// Create or Replace Progress
@ -164,17 +164,17 @@ func (api *API) setProgress(c *gin.Context) {
Progress: rPosition.Progress,
})
if err != nil {
log.Error("[setProgress] UpdateProgress DB Error:", err)
log.Error("[koSetProgress] UpdateProgress DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Update Statistic
log.Info("[setProgress] UpdateDocumentUserStatistic Running...")
log.Info("[koSetProgress] UpdateDocumentUserStatistic Running...")
if err := api.DB.UpdateDocumentUserStatistic(rPosition.DocumentID, rUser.(string)); err != nil {
log.Error("[setProgress] UpdateDocumentUserStatistic Error:", err)
log.Error("[koSetProgress] UpdateDocumentUserStatistic Error:", err)
}
log.Info("[setProgress] UpdateDocumentUserStatistic Complete")
log.Info("[koSetProgress] UpdateDocumentUserStatistic Complete")
c.JSON(http.StatusOK, gin.H{
"document": progress.DocumentID,
@ -182,17 +182,17 @@ func (api *API) setProgress(c *gin.Context) {
})
}
func (api *API) getProgress(c *gin.Context) {
func (api *API) koGetProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[getProgress] Invalid URI Bind")
log.Error("[koGetProgress] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
progress, err := api.DB.Queries.GetProgress(api.DB.Ctx, database.GetProgressParams{
progress, err := api.DB.Queries.GetDocumentProgress(api.DB.Ctx, database.GetDocumentProgressParams{
DocumentID: rDocID.DocumentID,
UserID: rUser.(string),
})
@ -202,7 +202,7 @@ func (api *API) getProgress(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
return
} else if err != nil {
log.Error("[getProgress] GetProgress DB Error:", err)
log.Error("[koGetProgress] GetDocumentProgress DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
@ -216,12 +216,12 @@ func (api *API) getProgress(c *gin.Context) {
})
}
func (api *API) addActivities(c *gin.Context) {
func (api *API) koAddActivities(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rActivity requestActivity
if err := c.ShouldBindJSON(&rActivity); err != nil {
log.Error("[addActivity] Invalid JSON Bind")
log.Error("[koAddActivities] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
return
}
@ -229,7 +229,7 @@ func (api *API) addActivities(c *gin.Context) {
// Do Transaction
tx, err := api.DB.DB.Begin()
if err != nil {
log.Error("[addActivities] Transaction Begin DB Error:", err)
log.Error("[koAddActivities] Transaction Begin DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
@ -250,7 +250,7 @@ func (api *API) addActivities(c *gin.Context) {
if _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: doc,
}); err != nil {
log.Error("[addActivities] UpsertDocument DB Error:", err)
log.Error("[koAddActivities] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
@ -263,7 +263,7 @@ func (api *API) addActivities(c *gin.Context) {
DeviceName: rActivity.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("[addActivities] UpsertDevice DB Error:", err)
log.Error("[koAddActivities] UpsertDevice DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
return
}
@ -279,7 +279,7 @@ func (api *API) addActivities(c *gin.Context) {
StartPercentage: float64(item.Page) / float64(item.Pages),
EndPercentage: float64(item.Page+1) / float64(item.Pages),
}); err != nil {
log.Error("[addActivities] AddActivity DB Error:", err)
log.Error("[koAddActivities] AddActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
return
}
@ -287,18 +287,18 @@ func (api *API) addActivities(c *gin.Context) {
// Commit Transaction
if err := tx.Commit(); err != nil {
log.Error("[addActivities] Transaction Commit DB Error:", err)
log.Error("[koAddActivities] Transaction Commit DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
// Update Statistic
for _, doc := range allDocuments {
log.Info("[addActivities] UpdateDocumentUserStatistic Running...")
log.Info("[koAddActivities] UpdateDocumentUserStatistic Running...")
if err := api.DB.UpdateDocumentUserStatistic(doc, rUser.(string)); err != nil {
log.Error("[addActivities] UpdateDocumentUserStatistic Error:", err)
log.Error("[koAddActivities] UpdateDocumentUserStatistic Error:", err)
}
log.Info("[addActivities] UpdateDocumentUserStatistic Complete")
log.Info("[koAddActivities] UpdateDocumentUserStatistic Complete")
}
c.JSON(http.StatusOK, gin.H{
@ -306,12 +306,12 @@ func (api *API) addActivities(c *gin.Context) {
})
}
func (api *API) checkActivitySync(c *gin.Context) {
func (api *API) koCheckActivitySync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rCheckActivity requestCheckActivitySync
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
log.Error("[checkActivitySync] Invalid JSON Bind")
log.Error("[koCheckActivitySync] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
@ -323,7 +323,7 @@ func (api *API) checkActivitySync(c *gin.Context) {
DeviceName: rCheckActivity.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("[checkActivitySync] UpsertDevice DB Error", err)
log.Error("[koCheckActivitySync] UpsertDevice DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
return
}
@ -336,7 +336,7 @@ func (api *API) checkActivitySync(c *gin.Context) {
if err == sql.ErrNoRows {
lastActivity = time.UnixMilli(0).Format(time.RFC3339)
} else if err != nil {
log.Error("[checkActivitySync] GetLastActivity DB Error:", err)
log.Error("[koCheckActivitySync] GetLastActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
@ -344,7 +344,7 @@ func (api *API) checkActivitySync(c *gin.Context) {
// Parse Time
parsedTime, err := time.Parse(time.RFC3339, lastActivity)
if err != nil {
log.Error("[checkActivitySync] Time Parse Error:", err)
log.Error("[koCheckActivitySync] Time Parse Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
@ -354,10 +354,10 @@ func (api *API) checkActivitySync(c *gin.Context) {
})
}
func (api *API) addDocuments(c *gin.Context) {
func (api *API) koAddDocuments(c *gin.Context) {
var rNewDocs requestDocument
if err := c.ShouldBindJSON(&rNewDocs); err != nil {
log.Error("[addDocuments] Invalid JSON Bind")
log.Error("[koAddDocuments] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document(s)"})
return
}
@ -365,7 +365,7 @@ func (api *API) addDocuments(c *gin.Context) {
// Do Transaction
tx, err := api.DB.DB.Begin()
if err != nil {
log.Error("[addDocuments] Transaction Begin DB Error:", err)
log.Error("[koAddDocuments] Transaction Begin DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
@ -386,7 +386,7 @@ func (api *API) addDocuments(c *gin.Context) {
Description: api.sanitizeInput(doc.Description),
})
if err != nil {
log.Error("[addDocuments] UpsertDocument DB Error:", err)
log.Error("[koAddDocuments] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
@ -394,7 +394,7 @@ func (api *API) addDocuments(c *gin.Context) {
// Commit Transaction
if err := tx.Commit(); err != nil {
log.Error("[addDocuments] Transaction Commit DB Error:", err)
log.Error("[koAddDocuments] Transaction Commit DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
@ -404,12 +404,12 @@ func (api *API) addDocuments(c *gin.Context) {
})
}
func (api *API) checkDocumentsSync(c *gin.Context) {
func (api *API) koCheckDocumentsSync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rCheckDocs requestCheckDocumentSync
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
log.Error("[checkDocumentsSync] Invalid JSON Bind")
log.Error("[koCheckDocumentsSync] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
@ -422,7 +422,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
LastSynced: time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
log.Error("[checkDocumentsSync] UpsertDevice DB Error", err)
log.Error("[koCheckDocumentsSync] UpsertDevice DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
return
}
@ -433,7 +433,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
// Get Missing Documents
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("[checkDocumentsSync] GetMissingDocuments DB Error", err)
log.Error("[koCheckDocumentsSync] GetMissingDocuments DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
@ -441,7 +441,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
// Get Deleted Documents
deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("[checkDocumentsSync] GetDeletedDocuments DB Error", err)
log.Error("[koCheckDocumentsSync] GetDeletedDocuments DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
@ -449,14 +449,14 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
// Get Wanted Documents
jsonHaves, err := json.Marshal(rCheckDocs.Have)
if err != nil {
log.Error("[checkDocumentsSync] JSON Marshal Error", err)
log.Error("[koCheckDocumentsSync] JSON Marshal Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
wantedDocs, err := api.DB.Queries.GetWantedDocuments(api.DB.Ctx, string(jsonHaves))
if err != nil {
log.Error("[checkDocumentsSync] GetWantedDocuments DB Error", err)
log.Error("[koCheckDocumentsSync] GetWantedDocuments DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
@ -497,17 +497,17 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
c.JSON(http.StatusOK, rCheckDocSync)
}
func (api *API) uploadExistingDocument(c *gin.Context) {
func (api *API) koUploadExistingDocument(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[uploadExistingDocument] Invalid URI Bind")
log.Error("[koUploadExistingDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
fileData, err := c.FormFile("file")
if err != nil {
log.Error("[uploadExistingDocument] File Error:", err)
log.Error("[koUploadExistingDocument] File Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
@ -518,7 +518,7 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
fileExtension := fileMime.Extension()
if !slices.Contains([]string{".epub", ".html"}, fileExtension) {
log.Error("[uploadExistingDocument] Invalid FileType:", fileExtension)
log.Error("[koUploadExistingDocument] Invalid FileType:", fileExtension)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
return
}
@ -526,7 +526,7 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
// Validate Document Exists in DB
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("[uploadExistingDocument] GetDocument DB Error:", err)
log.Error("[koUploadExistingDocument] GetDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return
}
@ -559,7 +559,7 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
if os.IsNotExist(err) {
err = c.SaveUploadedFile(fileData, safePath)
if err != nil {
log.Error("[uploadExistingDocument] Save Failure:", err)
log.Error("[koUploadExistingDocument] Save Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
@ -568,7 +568,7 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
// Get MD5 Hash
fileHash, err := getFileMD5(safePath)
if err != nil {
log.Error("[uploadExistingDocument] Hash Failure:", err)
log.Error("[koUploadExistingDocument] Hash Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
@ -576,7 +576,7 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
// Get Word Count
wordCount, err := metadata.GetWordCount(safePath)
if err != nil {
log.Error("[uploadExistingDocument] Word Count Failure:", err)
log.Error("[koUploadExistingDocument] Word Count Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
@ -588,7 +588,7 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
Filepath: &fileName,
Words: &wordCount,
}); err != nil {
log.Error("[uploadExistingDocument] UpsertDocument DB Error:", err)
log.Error("[koUploadExistingDocument] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
return
}
@ -598,42 +598,8 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
})
}
func (api *API) downloadDocument(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[downloadDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get Document
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("[downloadDocument] GetDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return
}
if document.Filepath == nil {
log.Error("[downloadDocument] Document Doesn't Have File:", rDoc.DocumentID)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"})
return
}
// Derive Storage Location
filePath := filepath.Join(api.Config.DataPath, "documents", *document.Filepath)
// Validate File Exists
_, err = os.Stat(filePath)
if os.IsNotExist(err) {
log.Error("[downloadDocument] File Doesn't Exist:", rDoc.DocumentID)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"})
return
}
// Force Download (Security)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(*document.Filepath)))
c.File(filePath)
func (api *API) koDemoModeJSONError(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not Allowed in Demo Mode"})
}
func (api *API) sanitizeInput(val any) *string {

View File

@ -1272,3 +1272,14 @@ class EBookReader {
}
document.addEventListener("DOMContentLoaded", initReader);
// WIP
async function getTOC() {
let toc = currentReader.book.navigation.toc;
// Alternatively:
// let nav = await currentReader.book.loaded.navigation;
// let toc = nav.toc;
currentReader.rendition.display(nav.toc[10].href);
}

File diff suppressed because one or more lines are too long

View File

@ -146,6 +146,20 @@ ORDER BY devices.last_synced DESC;
SELECT * FROM documents
WHERE id = $document_id LIMIT 1;
-- name: GetDocumentProgress :one
SELECT
document_progress.*,
devices.device_name
FROM document_progress
JOIN devices ON document_progress.device_id = devices.id
WHERE
document_progress.user_id = $user_id
AND document_progress.document_id = $document_id
ORDER BY
document_progress.created_at
DESC
LIMIT 1;
-- name: GetDocumentWithStats :one
SELECT
docs.id,
@ -258,19 +272,30 @@ WHERE
AND documents.deleted = false
AND documents.id NOT IN (sqlc.slice('document_ids'));
-- name: GetProgress :one
-- name: GetProgress :many
SELECT
document_progress.*,
devices.device_name
FROM document_progress
JOIN devices ON document_progress.device_id = devices.id
documents.title,
documents.author,
devices.device_name,
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
progress.document_id,
progress.user_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', progress.created_at, users.time_offset) AS TEXT) AS created_at
FROM document_progress AS progress
LEFT JOIN users ON progress.user_id = users.id
LEFT JOIN devices ON progress.device_id = devices.id
LEFT JOIN documents ON progress.document_id = documents.id
WHERE
document_progress.user_id = $user_id
AND document_progress.document_id = $document_id
ORDER BY
document_progress.created_at
DESC
LIMIT 1;
progress.user_id = $user_id
AND (
(
CAST($doc_filter AS BOOLEAN) = TRUE
AND document_id = $document_id
) OR $doc_filter = FALSE
)
ORDER BY created_at DESC
LIMIT $limit
OFFSET $offset;
-- name: GetUser :one
SELECT * FROM users

View File

@ -478,6 +478,51 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
return i, err
}
const getDocumentProgress = `-- name: GetDocumentProgress :one
SELECT
document_progress.user_id, document_progress.document_id, document_progress.device_id, document_progress.percentage, document_progress.progress, document_progress.created_at,
devices.device_name
FROM document_progress
JOIN devices ON document_progress.device_id = devices.id
WHERE
document_progress.user_id = ?1
AND document_progress.document_id = ?2
ORDER BY
document_progress.created_at
DESC
LIMIT 1
`
type GetDocumentProgressParams struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
}
type GetDocumentProgressRow struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
Percentage float64 `json:"percentage"`
Progress string `json:"progress"`
CreatedAt string `json:"created_at"`
DeviceName string `json:"device_name"`
}
func (q *Queries) GetDocumentProgress(ctx context.Context, arg GetDocumentProgressParams) (GetDocumentProgressRow, error) {
row := q.db.QueryRowContext(ctx, getDocumentProgress, arg.UserID, arg.DocumentID)
var i GetDocumentProgressRow
err := row.Scan(
&i.UserID,
&i.DocumentID,
&i.DeviceID,
&i.Percentage,
&i.Progress,
&i.CreatedAt,
&i.DeviceName,
)
return i, err
}
const getDocumentWithStats = `-- name: GetDocumentWithStats :one
SELECT
docs.id,
@ -827,49 +872,85 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
return items, nil
}
const getProgress = `-- name: GetProgress :one
const getProgress = `-- name: GetProgress :many
SELECT
document_progress.user_id, document_progress.document_id, document_progress.device_id, document_progress.percentage, document_progress.progress, document_progress.created_at,
devices.device_name
FROM document_progress
JOIN devices ON document_progress.device_id = devices.id
documents.title,
documents.author,
devices.device_name,
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
progress.document_id,
progress.user_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', progress.created_at, users.time_offset) AS TEXT) AS created_at
FROM document_progress AS progress
LEFT JOIN users ON progress.user_id = users.id
LEFT JOIN devices ON progress.device_id = devices.id
LEFT JOIN documents ON progress.document_id = documents.id
WHERE
document_progress.user_id = ?1
AND document_progress.document_id = ?2
ORDER BY
document_progress.created_at
DESC
LIMIT 1
progress.user_id = ?1
AND (
(
CAST(?2 AS BOOLEAN) = TRUE
AND document_id = ?3
) OR ?2 = FALSE
)
ORDER BY created_at DESC
LIMIT ?5
OFFSET ?4
`
type GetProgressParams struct {
UserID string `json:"user_id"`
DocFilter bool `json:"doc_filter"`
DocumentID string `json:"document_id"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
}
type GetProgressRow struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
Percentage float64 `json:"percentage"`
Progress string `json:"progress"`
CreatedAt string `json:"created_at"`
Title *string `json:"title"`
Author *string `json:"author"`
DeviceName string `json:"device_name"`
Percentage float64 `json:"percentage"`
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) (GetProgressRow, error) {
row := q.db.QueryRowContext(ctx, getProgress, arg.UserID, arg.DocumentID)
var i GetProgressRow
err := row.Scan(
&i.UserID,
&i.DocumentID,
&i.DeviceID,
&i.Percentage,
&i.Progress,
&i.CreatedAt,
&i.DeviceName,
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {
rows, err := q.db.QueryContext(ctx, getProgress,
arg.UserID,
arg.DocFilter,
arg.DocumentID,
arg.Offset,
arg.Limit,
)
return i, err
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetProgressRow
for rows.Next() {
var i GetProgressRow
if err := rows.Scan(
&i.Title,
&i.Author,
&i.DeviceName,
&i.Percentage,
&i.DocumentID,
&i.UserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUser = `-- name: GetUser :one

View File

@ -152,11 +152,11 @@
<span class="mx-4 text-sm font-normal">Documents</span>
</a>
<a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
href="/local"
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "progress"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/progress"
>
<span class="text-left">{{ template "svg/documents" (dict "Size" 20) }}</span>
<span class="mx-4 text-sm font-normal">Local</span>
<span class="text-left">{{ template "svg/activity" (dict "Size" 20) }}</span>
<span class="mx-4 text-sm font-normal">Progress</span>
</a>
<a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "activity"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
@ -165,7 +165,7 @@
<span class="text-left">{{ template "svg/activity" (dict "Size" 20) }}</span>
<span class="mx-4 text-sm font-normal">Activity</span>
</a>
{{ if .SearchEnabled }}
{{ if .Config.SearchEnabled }}
<a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "search"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/search"
@ -223,7 +223,7 @@
class="transition duration-200 z-20 absolute right-4 top-16 pt-4"
>
<div
class="w-56 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5"
class="w-40 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5"
>
<div
class="py-1"
@ -240,6 +240,15 @@
<span>Settings</span>
</span>
</a>
<a
href="/local"
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span>Offline</span>
</span>
</a>
<a
href="/logout"
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"

View File

@ -129,7 +129,7 @@
</div>
</div>
</a>
<div class="w-full">
<a href="./progress" class="w-full">
<div
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
>
@ -142,7 +142,7 @@
<p class="text-sm text-gray-400">Progress Records</p>
</div>
</div>
</div>
</a>
<div class="w-full">
<div
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"

View File

@ -122,7 +122,7 @@
</button>
</form>
<div class="pt-12 pb-12 text-center">
{{ if .RegistrationEnabled }} {{ if .Register }}
{{ if .Config.RegistrationEnabled }} {{ if .Register }}
<p>
Trying to login?
<a href="./login" class="font-semibold underline">

View File

@ -0,0 +1,49 @@
{{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 class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400">
<tr>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
Document
</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
Device
</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
Percent
</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
Time
</th>
</tr>
</thead>
<tbody class="text-black dark:text-white">
{{ if not .Data }}
<tr>
<td class="text-center p-3" colspan="4">No Results</td>
</tr>
{{ end }}
{{range $progress := .Data }}
<tr>
<td class="p-3 border-b border-gray-200">
<a href="./documents/{{ $progress.DocumentID }}">{{ $progress.Author }} - {{ $progress.Title }}</p></a>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $progress.DeviceName }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $progress.Percentage }}%</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $progress.CreatedAt }}</p>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}

View File

@ -89,14 +89,7 @@
>
{{ range $item := GetUTCOffsets }}
<option
{{
if
(eq
$item.Value
$.Data.Settings.TimeOffset)
}}selected{{
end
}}
{{ if (eq $item.Value $.Data.TimeOffset) }}selected{{ end }}
value="{{ $item.Value }}"
>
{{ $item.Name }}