Compare commits

...

2 Commits

Author SHA1 Message Date
7c6acad689 chore(templates): component-ize things
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-25 20:04:26 -04:00
5482899075 feat(admin): adding user & importing 2024-05-25 20:02:57 -04:00
36 changed files with 914 additions and 473 deletions

View File

@ -227,6 +227,7 @@ func (api *API) generateTemplates() *multitemplate.Renderer {
render := multitemplate.NewRenderer()
helperFuncs := template.FuncMap{
"dict": dict,
"slice": slice,
"fields": fields,
"getSVGGraphData": getSVGGraphData,
"getTimeZones": getTimeZones,

View File

@ -3,6 +3,7 @@ package api
import (
"archive/zip"
"bufio"
"crypto/md5"
"encoding/json"
"fmt"
"io"
@ -12,14 +13,19 @@ import (
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
"github.com/itchyny/gojq"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
"reichard.io/antholume/utils"
)
type adminAction string
@ -59,12 +65,13 @@ type operationType string
const (
opUpdate operationType = "UPDATE"
opCreate operationType = "CREATE"
opDelete operationType = "DELETE"
)
type requestAdminUpdateUser struct {
User string `form:"user"`
Password string `form:"password"`
isAdmin bool `form:"is_admin"`
Password *string `form:"password"`
isAdmin *bool `form:"is_admin"`
Operation operationType `form:"operation"`
}
@ -72,6 +79,22 @@ type requestAdminLogs struct {
Filter string `form:"filter"`
}
type importStatus string
const (
importFailed importStatus = "FAILED"
importSuccess importStatus = "SUCCESS"
importExists importStatus = "EXISTS"
)
type importResult struct {
ID string
Name string
Path string
Status importStatus
Error error
}
func (api *API) appPerformAdminAction(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin", c)
@ -82,15 +105,18 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
return
}
// TODO - Messages
switch rAdminAction.Action {
case adminMetadataMatch:
// TODO
// 1. Documents xref most recent metadata table?
// 2. Select all / deselect?
case adminCacheTables:
go api.db.CacheTempTables()
// TODO - Message
go func() {
err := api.db.CacheTempTables()
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
}()
case adminRestore:
api.processRestoreFile(rAdminAction, c)
return
@ -252,17 +278,18 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
var err error
switch rAdminUserUpdate.Operation {
case opCreate:
err = api.createUser(rAdminUserUpdate.User, rAdminUserUpdate.Password)
err = api.createUser(rAdminUserUpdate)
case opUpdate:
err = fmt.Errorf("unimplemented")
err = api.updateUser(rAdminUserUpdate)
case opDelete:
err = api.deleteUser(rAdminUserUpdate)
default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return
}
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create user: %v", err))
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create or update user: %v", err))
return
}
@ -338,46 +365,157 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
return
}
// TODO - Store results for approval?
// Walk import directory & copy or import files
// Get import directory
importDirectory := filepath.Clean(rAdminImport.Directory)
_ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error {
// Get data directory
absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents"))
// Validate different path
if absoluteDataPath == importDirectory {
appErrorPage(c, http.StatusBadRequest, "Directory is the same as data path")
return
}
// Do Transaction
tx, err := api.db.DB.Begin()
if err != nil {
log.Error("Transaction Begin DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown error")
return
}
// Defer & Start Transaction
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Track imports
importResults := make([]importResult, 0)
// Walk Directory & Import
err = filepath.WalkDir(importDirectory, func(importPath string, f fs.DirEntry, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
// Get metadata
fileMeta, err := metadata.GetMetadata(currentPath)
// Get relative path
basePath := importDirectory
relFilePath, err := filepath.Rel(importDirectory, importPath)
if err != nil {
fmt.Printf("metadata error: %v\n", err)
log.Warnf("path error: %v", err)
return nil
}
// Only needed if copying
newName := deriveBaseFileName(fileMeta)
// Track imports
iResult := importResult{
Path: relFilePath,
Status: importFailed,
}
defer func() {
importResults = append(importResults, iResult)
}()
// Open File on Disk
// file, err := os.Open(currentPath)
// if err != nil {
// return err
// }
// defer file.Close()
// Get metadata
fileMeta, err := metadata.GetMetadata(importPath)
if err != nil {
log.Errorf("metadata error: %v", err)
iResult.Error = err
return nil
}
iResult.ID = *fileMeta.PartialMD5
iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
// TODO - BasePath in DB
// TODO - Copy / Import
// Check already exists
_, err = qtx.GetDocument(api.db.Ctx, *fileMeta.PartialMD5)
if err == nil {
log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
iResult.Status = importExists
return nil
}
fmt.Printf("New File Metadata: %s\n", newName)
// Import Copy
if rAdminImport.Type == importCopy {
// Derive & Sanitize File Name
relFilePath = deriveBaseFileName(fileMeta)
safePath := filepath.Join(api.cfg.DataPath, "documents", relFilePath)
// Open Source File
srcFile, err := os.Open(importPath)
if err != nil {
log.Errorf("unable to open current file: %v", err)
iResult.Error = err
return nil
}
defer srcFile.Close()
// Open Destination File
destFile, err := os.Create(safePath)
if err != nil {
log.Errorf("unable to open destination file: %v", err)
iResult.Error = err
return nil
}
defer destFile.Close()
// Copy File
if _, err = io.Copy(destFile, srcFile); err != nil {
log.Errorf("unable to save file: %v", err)
iResult.Error = err
return nil
}
// Update Base & Path
basePath = filepath.Join(api.cfg.DataPath, "documents")
iResult.Path = relFilePath
}
// Upsert document
if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5,
Title: fileMeta.Title,
Author: fileMeta.Author,
Description: fileMeta.Description,
Md5: fileMeta.MD5,
Words: fileMeta.WordCount,
Filepath: &relFilePath,
Basepath: &basePath,
}); err != nil {
log.Errorf("UpsertDocument DB Error: %v", err)
iResult.Error = err
return nil
}
iResult.Status = importSuccess
return nil
})
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import Failed: %v", err))
return
}
templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory)
// Commit transaction
if err := tx.Commit(); err != nil {
log.Error("Transaction Commit DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import DB Error: %v", err))
return
}
c.HTML(http.StatusOK, "page/admin-import", templateVars)
// Sort import results
sort.Slice(importResults, func(i int, j int) bool {
return importStatusPriority(importResults[i].Status) <
importStatusPriority(importResults[j].Status)
})
templateVars["Data"] = importResults
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
}
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
@ -521,7 +659,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
c.Redirect(http.StatusFound, "/login")
}
// Restore all data
func (api *API) restoreData(zipReader *zip.Reader) error {
// Ensure Directories
api.cfg.EnsureDirectories()
@ -552,7 +689,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error {
return nil
}
// Remove all data
func (api *API) removeData() error {
allPaths := []string{
"covers",
@ -575,7 +711,6 @@ func (api *API) removeData() error {
return nil
}
// Backup all data
func (api *API) createBackup(w io.Writer, directories []string) error {
ar := zip.NewWriter(w)
@ -628,7 +763,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
if err != nil {
return err
}
io.Copy(newDbFile, dbFile)
_, err = io.Copy(newDbFile, dbFile)
if err != nil {
return err
}
// Backup Covers & Documents
for _, dir := range directories {
@ -641,3 +780,118 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
ar.Close()
return nil
}
func (api *API) createUser(createRequest requestAdminUpdateUser) error {
// Validate Necessary Parameters
if createRequest.User == "" {
return fmt.Errorf("username can't be empty")
}
if createRequest.Password == nil || *createRequest.Password == "" {
return fmt.Errorf("password can't be empty")
}
// Base Params
createParams := database.CreateUserParams{
ID: createRequest.User,
}
// Handle Admin (Explicit or False)
if createRequest.isAdmin != nil {
createParams.Admin = *createRequest.isAdmin
} else {
createParams.Admin = false
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*createRequest.Password)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
createParams.Pass = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
createParams.AuthHash = &authHash
// Create user in DB
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, createParams); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
log.Warn("User Already Exists:", createParams.ID)
return fmt.Errorf("user already exists")
}
return nil
}
func (api *API) updateUser(updateRequest requestAdminUpdateUser) error {
// Validate Necessary Parameters
if updateRequest.User == "" {
return fmt.Errorf("username can't be empty")
}
if updateRequest.Password == nil && updateRequest.isAdmin == nil {
return fmt.Errorf("nothing to update")
}
// Base Params
updateParams := database.UpdateUserParams{
UserID: updateRequest.User,
}
// Handle Admin (Update or Existing)
if updateRequest.isAdmin != nil {
updateParams.Admin = *updateRequest.isAdmin
} else {
user, err := api.db.Queries.GetUser(api.db.Ctx, updateRequest.User)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
}
updateParams.Admin = user.Admin
}
// TODO:
// - Validate Not Last Admin
// Handle Password
if updateRequest.Password != nil {
if *updateRequest.Password == "" {
return fmt.Errorf("password can't be empty")
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*updateRequest.Password)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
updateParams.Password = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
updateParams.AuthHash = &authHash
}
// Update User
_, err := api.db.Queries.UpdateUser(api.db.Ctx, updateParams)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
}
return nil
}
func (api *API) deleteUser(updateRequest requestAdminUpdateUser) error {
// TODO:
// - Validate Not Last Admin
return errors.New("unimplemented")
}

View File

@ -314,7 +314,11 @@ func (api *API) appGetSearch(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("search", c)
var sParams searchParams
c.BindQuery(&sParams)
err := c.BindQuery(&sParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return
}
// Only Handle Query
if sParams.Query != nil && sParams.Source != nil {
@ -369,10 +373,9 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
DocumentID: rDoc.DocumentID,
UserID: auth.UserName,
})
if err != nil && err != sql.ErrNoRows {
log.Error("UpsertDocument DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
log.Error("GetDocumentProgress DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentProgress DB Error: %v", err))
return
}
@ -461,7 +464,8 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
// Derive & Sanitize File Name
fileName := deriveBaseFileName(metadataInfo)
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName)
basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Open Destination File
destFile, err := os.Create(safePath)
@ -488,9 +492,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount,
Filepath: &fileName,
// TODO (BasePath):
// - Should be current config directory
Basepath: &basePath,
}); err != nil {
log.Errorf("UpsertDocument DB Error: %v", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
@ -595,7 +597,6 @@ func (api *API) appEditDocument(c *gin.Context) {
}
c.Redirect(http.StatusFound, "./")
return
}
func (api *API) appDeleteDocument(c *gin.Context) {
@ -764,6 +765,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
// Derive Extension on MIME
fileMime, err := mimetype.DetectFile(tempFilePath)
if err != nil {
log.Warn("MIME Detect Error: ", err)
sendDownloadMessage("Unable to download file", gin.H{"Error": true})
return
}
fileExtension := fileMime.Extension()
// Derive Filename
@ -797,7 +803,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
defer sourceFile.Close()
// Generate Storage Path & Open File
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName)
basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
destFile, err := os.Create(safePath)
if err != nil {
log.Error("Dest File Error: ", err)
@ -844,8 +852,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
Title: rDocAdd.Title,
Author: rDocAdd.Author,
Md5: fileHash,
Filepath: &fileName,
Words: wordCount,
Filepath: &fileName,
Basepath: &basePath,
}); err != nil {
log.Error("UpsertDocument DB Error: ", err)
sendDownloadMessage("Unable to save to database", gin.H{"Error": true})
@ -951,7 +960,11 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
}
// Defer & Start Transaction
defer tx.Rollback()
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
for _, item := range documents {
@ -1000,7 +1013,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
var qParams queryParams
c.BindQuery(&qParams)
err := c.BindQuery(&qParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return qParams
}
if qParams.Limit == nil {
qParams.Limit = &defaultLimit

View File

@ -28,18 +28,13 @@ type authKOHeader struct {
AuthKey string `header:"x-auth-key"`
}
// OPDS Auth Headers
type authOPDSHeader struct {
Authorization string `header:"authorization"`
}
func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil {
return
}
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true {
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return
}
@ -57,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session First
if auth, ok := api.getSession(session); ok == true {
if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
@ -98,7 +93,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
user, rawPassword, hasAuth := c.Request.BasicAuth()
// Validate Auth Fields
if hasAuth != true || user == "" || rawPassword == "" {
if !hasAuth || user == "" || rawPassword == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return
}
@ -120,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session
if auth, ok := api.getSession(session); ok == true {
if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
@ -129,13 +124,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData)
if auth.IsAdmin == true {
if auth.IsAdmin {
c.Next()
return
}
@ -143,7 +137,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
c.Abort()
return
}
func (api *API) appAuthLogin(c *gin.Context) {
@ -276,7 +269,10 @@ func (api *API) appAuthRegister(c *gin.Context) {
func (api *API) appAuthLogout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
if err := session.Save(); err != nil {
log.Error("unable to save session")
}
c.Redirect(http.StatusFound, "/login")
}
@ -377,7 +373,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
// Refresh
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session")
api.setSession(session, auth)
if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session")
return
}
}
// Authorized
@ -422,7 +421,11 @@ func (api *API) rotateAllAuthHashes() error {
}
// Defer & Start Transaction
defer tx.Rollback()
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
users, err := qtx.GetUsers(api.db.Ctx)
@ -430,7 +433,8 @@ func (api *API) rotateAllAuthHashes() error {
return err
}
// Update users
// Update Users
newAuthHashCache := make(map[string]string, 0)
for _, user := range users {
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
@ -448,8 +452,8 @@ func (api *API) rotateAllAuthHashes() error {
return err
}
// Update Cache
api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
// Save New Hash Cache
newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
}
// Commit Transaction
@ -458,56 +462,9 @@ func (api *API) rotateAllAuthHashes() error {
return err
}
return nil
}
func (api *API) createUser(username string, rawPassword string) error {
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if username == "" {
return fmt.Errorf("username can't be empty")
}
if rawPassword == "" {
return fmt.Errorf("password can't be empty")
}
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
// Generate auth hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
// Get current users
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
return fmt.Errorf("unable to get current users")
}
// Determine if we should be admin
isAdmin := false
if len(currentUsers) == 0 {
isAdmin = true
}
// Create user in DB
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: isAdmin,
}); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
log.Warn("User Already Exists:", username)
return fmt.Errorf("user already exists")
// Transaction Succeeded -> Update Cache
for user, hash := range newAuthHashCache {
api.userAuthCache[user] = hash
}
return nil

View File

@ -2,11 +2,12 @@ package api
import (
"fmt"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
)
@ -34,8 +35,14 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int,
return
}
// Derive Basepath
basepath := filepath.Join(api.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location
filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath)
filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists
_, err = os.Stat(filePath)

View File

@ -193,7 +193,11 @@ func (api *API) koAddActivities(c *gin.Context) {
allDocuments := getKeys(allDocumentsMap)
// Defer & Start Transaction
defer tx.Rollback()
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Upsert Documents
@ -316,7 +320,11 @@ func (api *API) koAddDocuments(c *gin.Context) {
}
// Defer & Start Transaction
defer tx.Rollback()
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Upsert Documents
@ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return
}
missingDocs := []database.Document{}
deletedDocIDs := []string{}
// Get Missing Documents
missingDocs, err = api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have)
missingDocs, err := api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("GetMissingDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
}
// Get Deleted Documents
deletedDocIDs, err = api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have)
deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("GetDeletedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@ -494,7 +499,8 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
})
// Generate Storage Path
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName)
basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Save & Prevent Overwrites
_, err = os.Stat(safePath)
@ -521,6 +527,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount,
Filepath: &fileName,
Basepath: &basePath,
}); err != nil {
log.Error("UpsertDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Document Error")

View File

@ -108,11 +108,11 @@ func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
}
func dict(values ...interface{}) (map[string]interface{}, error) {
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
@ -123,12 +123,12 @@ func dict(values ...interface{}) (map[string]interface{}, error) {
return dict, nil
}
func fields(value interface{}) (map[string]interface{}, error) {
func fields(value any) (map[string]any, error) {
v := reflect.Indirect(reflect.ValueOf(value))
if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("%T is not a struct", value)
}
m := make(map[string]interface{})
m := make(map[string]any)
t := v.Type()
for i := 0; i < t.NumField(); i++ {
sv := t.Field(i)
@ -137,6 +137,10 @@ func fields(value interface{}) (map[string]interface{}, error) {
return m, nil
}
func slice(elements ...any) []any {
return elements
}
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// Derive New FileName
var newFileName string
@ -155,3 +159,14 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
fileName := strings.ReplaceAll(newFileName, "/", "")
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
}
func importStatusPriority(status importStatus) int {
switch status {
case importFailed:
return 1
case importExists:
return 2
default:
return 3
}
}

File diff suppressed because one or more lines are too long

View File

@ -99,7 +99,7 @@ const PRECACHE_ASSETS = [
// ----------------------- Helpers ----------------------- //
// ------------------------------------------------------- //
function purgeCache() {
async function purgeCache() {
console.log("[purgeCache] Purging Cache");
return caches.keys().then(function (names) {
for (let name of names) caches.delete(name);
@ -136,7 +136,7 @@ async function handleFetch(event) {
const directive = ROUTES.find(
(item) =>
(item.route instanceof RegExp && url.match(item.route)) ||
url == item.route
url == item.route,
) || { type: CACHE_NEVER };
// Get Fallback
@ -161,11 +161,11 @@ async function handleFetch(event) {
);
case CACHE_UPDATE_SYNC:
return updateCache(event.request).catch(
(e) => currentCache || fallbackFunc(event)
(e) => currentCache || fallbackFunc(event),
);
case CACHE_UPDATE_ASYNC:
let newResponse = updateCache(event.request).catch((e) =>
fallbackFunc(event)
fallbackFunc(event),
);
return currentCache || newResponse;
@ -192,7 +192,7 @@ function handleMessage(event) {
.filter(
(item) =>
item.startsWith("/documents/") ||
item.startsWith("/reader/progress/")
item.startsWith("/reader/progress/"),
);
// Derive Unique IDs
@ -200,8 +200,8 @@ function handleMessage(event) {
new Set(
docResources
.filter((item) => item.startsWith("/documents/"))
.map((item) => item.split("/")[2])
)
.map((item) => item.split("/")[2]),
),
);
/**
@ -214,14 +214,14 @@ function handleMessage(event) {
.filter(
(id) =>
docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/reader/progress/" + id)
docResources.includes("/reader/progress/" + id),
)
.map(async (id) => {
let url = "/reader/progress/" + id;
let currentCache = await caches.match(url);
let resp = await updateCache(url).catch((e) => currentCache);
return resp.json();
})
}),
);
event.source.postMessage({ id, data: cachedDocuments });
@ -233,7 +233,7 @@ function handleMessage(event) {
Promise.all([
cache.delete("/documents/" + data.id + "/file"),
cache.delete("/reader/progress/" + data.id),
])
]),
)
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));

View File

@ -0,0 +1,38 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upImportBasepath, downImportBasepath)
}
func upImportBasepath(ctx context.Context, tx *sql.Tx) error {
// Determine if we have a new DB or not
isNew := ctx.Value("isNew").(bool)
if isNew {
return nil
}
// Add basepath column
_, err := tx.Exec(`ALTER TABLE documents ADD COLUMN basepath TEXT;`)
if err != nil {
return err
}
// This code is executed when the migration is applied.
return nil
}
func downImportBasepath(ctx context.Context, tx *sql.Tx) error {
// Drop basepath column
_, err := tx.Exec("ALTER documents DROP COLUMN basepath;")
if err != nil {
return err
}
return nil
}

View File

@ -30,6 +30,7 @@ type Device struct {
type Document struct {
ID string `json:"id"`
Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`

View File

@ -396,6 +396,7 @@ RETURNING *;
INSERT INTO documents (
id,
md5,
basepath,
filepath,
coverfile,
title,
@ -410,10 +411,11 @@ INSERT INTO documents (
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE
SET
md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title),

View File

@ -454,7 +454,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
}
const getDocument = `-- name: GetDocument :one
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
WHERE id = ?1 LIMIT 1
`
@ -464,6 +464,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
err := row.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,
@ -612,7 +613,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
}
const getDocuments = `-- name: GetDocuments :many
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
ORDER BY created_at DESC
LIMIT ?2
OFFSET ?1
@ -635,6 +636,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
if err := rows.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,
@ -819,7 +821,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
}
const getMissingDocuments = `-- name: GetMissingDocuments :many
SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
SELECT documents.id, documents.md5, documents.basepath, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
WHERE
documents.filepath IS NOT NULL
AND documents.deleted = false
@ -848,6 +850,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
if err := rows.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,
@ -1325,6 +1328,7 @@ const upsertDocument = `-- name: UpsertDocument :one
INSERT INTO documents (
id,
md5,
basepath,
filepath,
coverfile,
title,
@ -1339,10 +1343,11 @@ INSERT INTO documents (
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE
SET
md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title),
@ -1356,12 +1361,13 @@ SET
gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
RETURNING id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
`
type UpsertDocumentParams struct {
ID string `json:"id"`
Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`
@ -1381,6 +1387,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
row := q.db.QueryRowContext(ctx, upsertDocument,
arg.ID,
arg.Md5,
arg.Basepath,
arg.Filepath,
arg.Coverfile,
arg.Title,
@ -1399,6 +1406,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
err := row.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,

View File

@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS documents (
id TEXT NOT NULL PRIMARY KEY,
md5 TEXT,
basepath TEXT,
filepath TEXT,
coverfile TEXT,
title TEXT,

View File

@ -294,5 +294,3 @@ INNER JOIN
ON ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id
ORDER BY total_wpm DESC;

1
go.mod
View File

@ -49,6 +49,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect

View File

@ -19,6 +19,10 @@ sql:
go_type:
type: "string"
pointer: true
- column: "documents.basepath"
go_type:
type: "string"
pointer: true
- column: "documents.coverfile"
go_type:
type: "string"

View File

@ -54,6 +54,19 @@
display: none;
}
/* ----------------------------- */
/* -------- CSS Button -------- */
/* ----------------------------- */
.css-button:checked+div {
visibility: visible;
opacity: 1;
}
.css-button+div {
visibility: hidden;
opacity: 0;
}
/* ----------------------------- */
/* ------- User Dropdown ------- */
/* ----------------------------- */

View File

@ -0,0 +1,14 @@
<!-- Variant -->
{{ $baseClass := "transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white" }}
{{ if eq .Variant "Secondary" }}
{{ $baseClass = printf "bg-black shadow-md hover:text-black hover:bg-white %s" $baseClass }}
{{ else }}
{{ $baseClass = printf "bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100 %s" $baseClass }}
{{ end }}
<!-- Type -->
{{ if eq .Type "Link" }}
<a href="{{ .URL }}" class="text-center {{ $baseClass }}" type="submit">{{ .Title }}</a>
{{ else }}
<button class="{{ $baseClass }}" type="submit" {{ if .FormName }} form="{{ .FormName}}" {{ end }}>{{ .Title }}
</button>
{{ end }}

View File

@ -0,0 +1,43 @@
<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>

View 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 }}

View File

@ -0,0 +1,20 @@
<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>

View File

@ -0,0 +1,102 @@
{{ 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.ID }}?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.ID }}" />
</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 }}

View File

@ -0,0 +1,39 @@
<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>

View File

@ -0,0 +1,28 @@
{{ $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">
<p>{{ index (fields $row) $key }}</p>
</td>
{{ end }}
</tr>
{{ end }}
</tbody>
</table>

View File

@ -0,0 +1,46 @@
{{ template "base" . }}
{{ define "title" }}Admin - Import Results{{ end }}
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Import Results</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">Status</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Error</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 $result := .Data }}
<tr>
<td class="p-3 border-b border-gray-200 grid"
style="grid-template-columns: 4rem auto">
<span class="text-gray-800 dark:text-gray-400">Name:</span>
{{ if (eq $result.ID "") }}
<span>N/A</span>
{{ else }}
<a href="../documents/{{ $result.ID }}">{{ $result.Name }}</a>
{{ end }}
<span class="text-gray-800 dark:text-gray-400">File:</span>
<span>{{ $result.Path }}</span>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Status }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Error }}</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ end }}

View File

@ -19,12 +19,12 @@
</div>
<div class="flex flex-col justify-around gap-2 mr-4">
<div class="inline-flex gap-2 items-center">
<input checked type="radio" id="copy" name="type" value="COPY" />
<label for="copy">Copy</label>
<input checked type="radio" id="direct" name="type" value="DIRECT" />
<label for="direct">Direct</label>
</div>
<div class="inline-flex gap-2 items-center">
<input type="radio" id="direct" name="type" value="DIRECT" />
<label for="direct">Direct</label>
<input type="radio" id="copy" name="type" value="COPY" />
<label for="copy">Copy</label>
</div>
</div>
</div>

View File

@ -17,10 +17,12 @@
placeholder="JQ Filter" />
</div>
</div>
<button type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Filter</span>
</button>
<div class="lg:w-60">
{{ template "component/button" (dict
"Title" "Filter"
"Variant" "Secondary"
) }}
</div>
</form>
</div>
<!-- Required for iOS "Hover" Events (onclick) -->

View File

@ -35,6 +35,7 @@
<label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label>
</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Password</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center">
Permissions
</th>
@ -55,6 +56,33 @@
<td class="p-3 border-b border-gray-200">
<p>{{ $user.ID }}</p>
</td>
<td class="border-b border-gray-200 relative px-3">
<label for="edit-{{ $user.ID }}-button" class="cursor-pointer">
<span class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Reset</span>
</label>
<input type="checkbox"
id="edit-{{ $user.ID }}-button"
class="hidden css-button" />
<div class="absolute z-30 -bottom-1.5 left-16 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="./users"
class="flex flex gap-2 text-black dark:text-white text-sm">
<input type="text"
id="operation"
name="operation"
value="UPDATE"
class="hidden" />
<input type="password"
id="password"
name="password"
placeholder="Password"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" />
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Change</button>
</form>
</div>
</td>
<td class="p-3 border-b border-gray-200 text-center min-w-40">
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span>
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span>

View File

@ -21,10 +21,12 @@
<label for="backup_documents">Documents</label>
</div>
</div>
<button type="submit"
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Backup</span>
</button>
<div class="w-40 h-10">
{{ template "component/button" (dict
"Title" "Backup"
"Variant" "Secondary"
) }}
</div>
</form>
<form method="POST"
enctype="multipart/form-data"
@ -34,10 +36,12 @@
<div class="flex items-center w-1/2">
<input type="file" accept=".zip" name="restore_file" class="w-full" />
</div>
<button type="submit"
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Restore</span>
</button>
<div class="w-40 h-10">
{{ template "component/button" (dict
"Title" "Restore"
"Variant" "Secondary"
) }}
</div>
</form>
</div>
{{ if .PasswordErrorMessage }}
@ -57,10 +61,12 @@
<td class="py-2 float-right">
<form action="./admin" method="POST">
<input type="text" name="action" value="METADATA_MATCH" class="hidden" />
<button type="submit"
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Run</span>
</button>
<div class="w-40 h-10 text-base">
{{ template "component/button" (dict
"Title" "Run"
"Variant" "Secondary"
) }}
</div>
</form>
</td>
</tr>
@ -71,10 +77,12 @@
<td class="py-2 float-right">
<form action="./admin" method="POST">
<input type="text" name="action" value="CACHE_TABLES" class="hidden" />
<button type="submit"
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Run</span>
</button>
<div class="w-40 h-10 text-base">
{{ template "component/button" (dict
"Title" "Run"
"Variant" "Secondary"
) }}
</div>
</form>
</td>
</tr>

View File

@ -33,8 +33,7 @@
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">
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Upload Cover</button>
{{ template "component/button" (dict "Title" "Upload Cover") }}
</form>
<form method="POST"
action="./{{ .Data.ID }}/edit"
@ -44,8 +43,7 @@
id="remove_cover"
name="remove_cover"
class="hidden" />
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Remove Cover</button>
{{ template "component/button" (dict "Title" "Remove Cover") }}
</form>
</div>
<div class="relative">
@ -54,9 +52,8 @@
<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">
<button class="font-medium w-24 px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Delete</button>
class="text-black dark:text-white text-sm w-24">
{{ template "component/button" (dict "Title" "Delete") }}
</form>
</div>
</div>
@ -86,8 +83,7 @@
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">
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Identify</button>
{{ template "component/button" (dict "Title" "Identify") }}
</form>
</div>
</div>
@ -100,40 +96,18 @@
</div>
</div>
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
<div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative">
<p>Title</p>
<label class="my-auto" for="edit-title-button">{{ template "svg/edit" (dict "Size" 18) }}</label>
<input type="checkbox" id="edit-title-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="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 text-black dark:text-white text-sm">
<input type="text" id="title" name="title" value="{{ or .Data.Title "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Save</button>
</form>
</div>
</div>
<p class="font-medium text-lg">{{ or .Data.Title "N/A" }}</p>
</div>
<div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative">
<p>Author</p>
<label class="my-auto" for="edit-author-button">{{ template "svg/edit" (dict "Size" 18) }}</label>
<input type="checkbox" id="edit-author-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="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 text-black dark:text-white text-sm">
<input type="text" id="author" name="author" value="{{ or .Data.Author "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Save</button>
</form>
</div>
</div>
<p class="font-medium text-lg">{{ or .Data.Author "N/A" }}</p>
</div>
{{ 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>
@ -181,119 +155,16 @@
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>
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Save</button>
{{ template "component/button" (dict "Title" "Save") }}
</form>
</div>
<p>{{ or .Data.Description "N/A" }}</p>
</div>
</div>
{{ if .MetadataError }}
<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>
<a href="/documents/{{ .Data.ID }}"
class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Back to Document</a>
</div>
{{ template "component/metadata" (dict
"ID" .Data.ID
"Metadata" .Metadata
"Error" .MetadataError
)}}
</div>
{{ end }}
<!-- Metadata Info -->
{{ 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/{{ .Data.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.ID }}?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.ID }}">
</div>
</form>
<div class="flex justify-end gap-4 m-4">
<a href="/documents/{{ .Data.ID }}"
class="w-24 text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Cancel</a>
<button form="metadata-save"
class="w-24 font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Save</button>
</div>
</div>
</div>
{{ end }}
</div>
<style>
.css-button:checked+div {
visibility: visible;
opacity: 1;
}
.css-button+div {
visibility: hidden;
opacity: 0;
}
</style>
{{ end }}

View File

@ -18,58 +18,17 @@
placeholder="Search Author / Title" />
</div>
</div>
<button type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Search</span>
</button>
<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 }}
<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/{{$doc.ID}}">
<img class="rounded object-cover h-full"
src="./documents/{{$doc.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 $doc.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 $doc.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">{{ $doc.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 $doc.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={{ $doc.ID }}">{{ template "svg/activity" }}</a>
{{ if $doc.Filepath }}
<a href="./documents/{{$doc.ID}}/file">{{ template "svg/download" }}</a>
{{ else }}
{{ template "svg/download" (dict "Disabled" true) }}
{{ end }}
</div>
</div>
</div>
{{ template "component/document-card" $doc }}
{{ end }}
</div>
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
@ -89,7 +48,7 @@
enctype="multipart/form-data"
action="./documents"
class="flex flex-col gap-2">
<input type="file" accept=".epub" id="document_file" name="document_file">
<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>
@ -102,19 +61,4 @@
<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>
<style>
.css-button:checked+div {
display: block;
opacity: 1;
}
.css-button+div {
display: none;
opacity: 0;
}
.css-button:checked+div+label {
display: none;
}
</style>
{{ end }}

View File

@ -41,82 +41,29 @@
</div>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<a href="./documents" 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">{{ .Data.DatabaseInfo.DocumentsSize }}</p>
<p class="text-sm text-gray-400">Documents</p>
</div>
</div>
</a>
<a href="./activity" 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">{{ .Data.DatabaseInfo.ActivitySize }}</p>
<p class="text-sm text-gray-400">Activity Records</p>
</div>
</div>
</a>
<a href="./progress" 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">{{ .Data.DatabaseInfo.ProgressSize }}</p>
<p class="text-sm text-gray-400">Progress Records</p>
</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">
<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">{{ .Data.DatabaseInfo.DevicesSize }}</p>
<p class="text-sm text-gray-400">Devices</p>
</div>
</div>
</div>
{{ 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 }}
<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 $item.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">{{ $item.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 $item.Window "WEEK" }} Current Weekly Streak {{ else }}
Current Daily Streak {{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">
{{ $item.CurrentStreakStartDate }} ➞ {{ $item.CurrentStreakEndDate }}
</div>
</div>
<div class="flex items-end font-bold">{{ $item.CurrentStreak }}</div>
</div>
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div>
<p>
{{ if eq $item.Window "WEEK" }}
Best Weekly Streak
{{ else }}
Best Daily Streak
{{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">{{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}</div>
</div>
<div class="flex items-end font-bold">{{ $item.MaxStreak }}</div>
</div>
</div>
</div>
</div>
{{ template "component/streak-card" $item }}
{{ end }}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">

View File

@ -39,6 +39,13 @@
{{ end }}
</tbody>
</table>
<!--
{{ template "component/table" (dict
"Columns" (slice "Author" "Title" "Device Name" "Percentage" "Created At")
"Keys" (slice "Author" "Title" "DeviceName" "Percentage" "CreatedAt")
"Rows" .Data
)}}
-->
</div>
</div>
{{ end }}

View File

@ -30,10 +30,12 @@
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
</select>
</div>
<button type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Search</span>
</button>
<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>

View File

@ -39,10 +39,12 @@
placeholder="New Password" />
</div>
</div>
<button type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Submit</span>
</button>
<div class="lg:w-60">
{{ template "component/button" (dict
"Title" "Submit"
"Variant" "Secondary"
) }}
</div>
</form>
{{ if .PasswordErrorMessage }}
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
@ -69,10 +71,12 @@
{{ end }}
</select>
</div>
<button type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
<span class="w-full">Submit</span>
</button>
<div class="lg:w-60">
{{ template "component/button" (dict
"Title" "Submit"
"Variant" "Secondary"
) }}
</div>
</form>
{{ if .TimeOffsetErrorMessage }}
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>