Compare commits
2 Commits
e68dfc445f
...
7c6acad689
Author | SHA1 | Date | |
---|---|---|---|
7c6acad689 | |||
5482899075 |
@ -227,6 +227,7 @@ func (api *API) generateTemplates() *multitemplate.Renderer {
|
|||||||
render := multitemplate.NewRenderer()
|
render := multitemplate.NewRenderer()
|
||||||
helperFuncs := template.FuncMap{
|
helperFuncs := template.FuncMap{
|
||||||
"dict": dict,
|
"dict": dict,
|
||||||
|
"slice": slice,
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
"getSVGGraphData": getSVGGraphData,
|
"getSVGGraphData": getSVGGraphData,
|
||||||
"getTimeZones": getTimeZones,
|
"getTimeZones": getTimeZones,
|
||||||
|
@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/md5"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -12,14 +13,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/itchyny/gojq"
|
"github.com/itchyny/gojq"
|
||||||
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
|
"reichard.io/antholume/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminAction string
|
type adminAction string
|
||||||
@ -59,12 +65,13 @@ type operationType string
|
|||||||
const (
|
const (
|
||||||
opUpdate operationType = "UPDATE"
|
opUpdate operationType = "UPDATE"
|
||||||
opCreate operationType = "CREATE"
|
opCreate operationType = "CREATE"
|
||||||
|
opDelete operationType = "DELETE"
|
||||||
)
|
)
|
||||||
|
|
||||||
type requestAdminUpdateUser struct {
|
type requestAdminUpdateUser struct {
|
||||||
User string `form:"user"`
|
User string `form:"user"`
|
||||||
Password string `form:"password"`
|
Password *string `form:"password"`
|
||||||
isAdmin bool `form:"is_admin"`
|
isAdmin *bool `form:"is_admin"`
|
||||||
Operation operationType `form:"operation"`
|
Operation operationType `form:"operation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +79,22 @@ type requestAdminLogs struct {
|
|||||||
Filter string `form:"filter"`
|
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) {
|
func (api *API) appPerformAdminAction(c *gin.Context) {
|
||||||
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
|
||||||
@ -82,15 +105,18 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - Messages
|
|
||||||
switch rAdminAction.Action {
|
switch rAdminAction.Action {
|
||||||
case adminMetadataMatch:
|
case adminMetadataMatch:
|
||||||
// TODO
|
// TODO
|
||||||
// 1. Documents xref most recent metadata table?
|
// 1. Documents xref most recent metadata table?
|
||||||
// 2. Select all / deselect?
|
// 2. Select all / deselect?
|
||||||
case adminCacheTables:
|
case adminCacheTables:
|
||||||
go api.db.CacheTempTables()
|
go func() {
|
||||||
// TODO - Message
|
err := api.db.CacheTempTables()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to cache temp tables: ", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
case adminRestore:
|
case adminRestore:
|
||||||
api.processRestoreFile(rAdminAction, c)
|
api.processRestoreFile(rAdminAction, c)
|
||||||
return
|
return
|
||||||
@ -252,17 +278,18 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
|
|||||||
var err error
|
var err error
|
||||||
switch rAdminUserUpdate.Operation {
|
switch rAdminUserUpdate.Operation {
|
||||||
case opCreate:
|
case opCreate:
|
||||||
err = api.createUser(rAdminUserUpdate.User, rAdminUserUpdate.Password)
|
err = api.createUser(rAdminUserUpdate)
|
||||||
case opUpdate:
|
case opUpdate:
|
||||||
err = fmt.Errorf("unimplemented")
|
err = api.updateUser(rAdminUserUpdate)
|
||||||
|
case opDelete:
|
||||||
|
err = api.deleteUser(rAdminUserUpdate)
|
||||||
default:
|
default:
|
||||||
appErrorPage(c, http.StatusNotFound, "Unknown user operation")
|
appErrorPage(c, http.StatusNotFound, "Unknown user operation")
|
||||||
return
|
return
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,46 +365,157 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - Store results for approval?
|
// Get import directory
|
||||||
|
|
||||||
// Walk import directory & copy or import files
|
|
||||||
importDirectory := filepath.Clean(rAdminImport.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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.IsDir() {
|
if f.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get metadata
|
// Get relative path
|
||||||
fileMeta, err := metadata.GetMetadata(currentPath)
|
basePath := importDirectory
|
||||||
|
relFilePath, err := filepath.Rel(importDirectory, importPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("metadata error: %v\n", err)
|
log.Warnf("path error: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only needed if copying
|
// Track imports
|
||||||
newName := deriveBaseFileName(fileMeta)
|
iResult := importResult{
|
||||||
|
Path: relFilePath,
|
||||||
|
Status: importFailed,
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
importResults = append(importResults, iResult)
|
||||||
|
}()
|
||||||
|
|
||||||
// Open File on Disk
|
// Get metadata
|
||||||
// file, err := os.Open(currentPath)
|
fileMeta, err := metadata.GetMetadata(importPath)
|
||||||
// if err != nil {
|
if err != nil {
|
||||||
// return err
|
log.Errorf("metadata error: %v", err)
|
||||||
// }
|
iResult.Error = err
|
||||||
// defer file.Close()
|
return nil
|
||||||
|
}
|
||||||
|
iResult.ID = *fileMeta.PartialMD5
|
||||||
|
iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
|
||||||
|
|
||||||
// TODO - BasePath in DB
|
// Check already exists
|
||||||
// TODO - Copy / Import
|
_, 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
|
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) {
|
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")
|
c.Redirect(http.StatusFound, "/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore all data
|
|
||||||
func (api *API) restoreData(zipReader *zip.Reader) error {
|
func (api *API) restoreData(zipReader *zip.Reader) error {
|
||||||
// Ensure Directories
|
// Ensure Directories
|
||||||
api.cfg.EnsureDirectories()
|
api.cfg.EnsureDirectories()
|
||||||
@ -552,7 +689,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove all data
|
|
||||||
func (api *API) removeData() error {
|
func (api *API) removeData() error {
|
||||||
allPaths := []string{
|
allPaths := []string{
|
||||||
"covers",
|
"covers",
|
||||||
@ -575,7 +711,6 @@ func (api *API) removeData() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup all data
|
|
||||||
func (api *API) createBackup(w io.Writer, directories []string) error {
|
func (api *API) createBackup(w io.Writer, directories []string) error {
|
||||||
ar := zip.NewWriter(w)
|
ar := zip.NewWriter(w)
|
||||||
|
|
||||||
@ -628,7 +763,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
io.Copy(newDbFile, dbFile)
|
|
||||||
|
_, err = io.Copy(newDbFile, dbFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Backup Covers & Documents
|
// Backup Covers & Documents
|
||||||
for _, dir := range directories {
|
for _, dir := range directories {
|
||||||
@ -641,3 +780,118 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
|
|||||||
ar.Close()
|
ar.Close()
|
||||||
return nil
|
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")
|
||||||
|
}
|
||||||
|
@ -314,7 +314,11 @@ func (api *API) appGetSearch(c *gin.Context) {
|
|||||||
templateVars, _ := api.getBaseTemplateVars("search", c)
|
templateVars, _ := api.getBaseTemplateVars("search", c)
|
||||||
|
|
||||||
var sParams searchParams
|
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
|
// Only Handle Query
|
||||||
if sParams.Query != nil && sParams.Source != nil {
|
if sParams.Query != nil && sParams.Source != nil {
|
||||||
@ -369,10 +373,9 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
|
|||||||
DocumentID: rDoc.DocumentID,
|
DocumentID: rDoc.DocumentID,
|
||||||
UserID: auth.UserName,
|
UserID: auth.UserName,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
log.Error("UpsertDocument DB Error: ", err)
|
log.Error("GetDocumentProgress DB Error: ", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentProgress DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -461,7 +464,8 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
|
|||||||
|
|
||||||
// Derive & Sanitize File Name
|
// Derive & Sanitize File Name
|
||||||
fileName := deriveBaseFileName(metadataInfo)
|
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
|
// Open Destination File
|
||||||
destFile, err := os.Create(safePath)
|
destFile, err := os.Create(safePath)
|
||||||
@ -488,9 +492,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
|
|||||||
Md5: metadataInfo.MD5,
|
Md5: metadataInfo.MD5,
|
||||||
Words: metadataInfo.WordCount,
|
Words: metadataInfo.WordCount,
|
||||||
Filepath: &fileName,
|
Filepath: &fileName,
|
||||||
|
Basepath: &basePath,
|
||||||
// TODO (BasePath):
|
|
||||||
// - Should be current config directory
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorf("UpsertDocument DB Error: %v", err)
|
log.Errorf("UpsertDocument DB Error: %v", err)
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("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, "./")
|
c.Redirect(http.StatusFound, "./")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appDeleteDocument(c *gin.Context) {
|
func (api *API) appDeleteDocument(c *gin.Context) {
|
||||||
@ -764,6 +765,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
|
|||||||
|
|
||||||
// Derive Extension on MIME
|
// Derive Extension on MIME
|
||||||
fileMime, err := mimetype.DetectFile(tempFilePath)
|
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()
|
fileExtension := fileMime.Extension()
|
||||||
|
|
||||||
// Derive Filename
|
// Derive Filename
|
||||||
@ -797,7 +803,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
|
|||||||
defer sourceFile.Close()
|
defer sourceFile.Close()
|
||||||
|
|
||||||
// Generate Storage Path & Open File
|
// 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)
|
destFile, err := os.Create(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Dest File Error: ", err)
|
log.Error("Dest File Error: ", err)
|
||||||
@ -844,8 +852,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
|
|||||||
Title: rDocAdd.Title,
|
Title: rDocAdd.Title,
|
||||||
Author: rDocAdd.Author,
|
Author: rDocAdd.Author,
|
||||||
Md5: fileHash,
|
Md5: fileHash,
|
||||||
Filepath: &fileName,
|
|
||||||
Words: wordCount,
|
Words: wordCount,
|
||||||
|
Filepath: &fileName,
|
||||||
|
Basepath: &basePath,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("UpsertDocument DB Error: ", err)
|
log.Error("UpsertDocument DB Error: ", err)
|
||||||
sendDownloadMessage("Unable to save to database", gin.H{"Error": true})
|
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 & 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)
|
qtx := api.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
for _, item := range documents {
|
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 {
|
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
|
||||||
var qParams 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 {
|
if qParams.Limit == nil {
|
||||||
qParams.Limit = &defaultLimit
|
qParams.Limit = &defaultLimit
|
||||||
|
93
api/auth.go
93
api/auth.go
@ -28,18 +28,13 @@ type authKOHeader struct {
|
|||||||
AuthKey string `header:"x-auth-key"`
|
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) {
|
func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
|
||||||
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
|
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session First
|
// Check Session First
|
||||||
if auth, ok := api.getSession(session); ok == true {
|
if auth, ok := api.getSession(session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@ -98,7 +93,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
|
|||||||
user, rawPassword, hasAuth := c.Request.BasicAuth()
|
user, rawPassword, hasAuth := c.Request.BasicAuth()
|
||||||
|
|
||||||
// Validate Auth Fields
|
// Validate Auth Fields
|
||||||
if hasAuth != true || user == "" || rawPassword == "" {
|
if !hasAuth || user == "" || rawPassword == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -120,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session
|
// Check Session
|
||||||
if auth, ok := api.getSession(session); ok == true {
|
if auth, ok := api.getSession(session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@ -129,13 +124,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
c.Redirect(http.StatusFound, "/login")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth := data.(authData)
|
auth := data.(authData)
|
||||||
if auth.IsAdmin == true {
|
if auth.IsAdmin {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -143,7 +137,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
|
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appAuthLogin(c *gin.Context) {
|
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) {
|
func (api *API) appAuthLogout(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
session.Clear()
|
session.Clear()
|
||||||
session.Save()
|
if err := session.Save(); err != nil {
|
||||||
|
log.Error("unable to save session")
|
||||||
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
c.Redirect(http.StatusFound, "/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,7 +373,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
|
|||||||
// Refresh
|
// Refresh
|
||||||
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
||||||
log.Info("Refreshing Session")
|
log.Info("Refreshing Session")
|
||||||
api.setSession(session, auth)
|
if err := api.setSession(session, auth); err != nil {
|
||||||
|
log.Error("unable to get session")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorized
|
// Authorized
|
||||||
@ -422,7 +421,11 @@ func (api *API) rotateAllAuthHashes() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defer & Start Transaction
|
// 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)
|
qtx := api.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
users, err := qtx.GetUsers(api.db.Ctx)
|
users, err := qtx.GetUsers(api.db.Ctx)
|
||||||
@ -430,7 +433,8 @@ func (api *API) rotateAllAuthHashes() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update users
|
// Update Users
|
||||||
|
newAuthHashCache := make(map[string]string, 0)
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
// Generate Auth Hash
|
// Generate Auth Hash
|
||||||
rawAuthHash, err := utils.GenerateToken(64)
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
@ -448,8 +452,8 @@ func (api *API) rotateAllAuthHashes() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Cache
|
// Save New Hash Cache
|
||||||
api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
|
newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit Transaction
|
// Commit Transaction
|
||||||
@ -458,56 +462,9 @@ func (api *API) rotateAllAuthHashes() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// Transaction Succeeded -> Update Cache
|
||||||
}
|
for user, hash := range newAuthHashCache {
|
||||||
|
api.userAuthCache[user] = hash
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -2,11 +2,12 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
)
|
)
|
||||||
@ -34,8 +35,14 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive Basepath
|
||||||
|
basepath := filepath.Join(api.cfg.DataPath, "documents")
|
||||||
|
if document.Basepath != nil && *document.Basepath != "" {
|
||||||
|
basepath = *document.Basepath
|
||||||
|
}
|
||||||
|
|
||||||
// Derive Storage Location
|
// Derive Storage Location
|
||||||
filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath)
|
filePath := filepath.Join(basepath, *document.Filepath)
|
||||||
|
|
||||||
// Validate File Exists
|
// Validate File Exists
|
||||||
_, err = os.Stat(filePath)
|
_, err = os.Stat(filePath)
|
||||||
|
@ -193,7 +193,11 @@ func (api *API) koAddActivities(c *gin.Context) {
|
|||||||
allDocuments := getKeys(allDocumentsMap)
|
allDocuments := getKeys(allDocumentsMap)
|
||||||
|
|
||||||
// Defer & Start Transaction
|
// 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)
|
qtx := api.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
// Upsert Documents
|
// Upsert Documents
|
||||||
@ -316,7 +320,11 @@ func (api *API) koAddDocuments(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defer & Start Transaction
|
// 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)
|
qtx := api.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
// Upsert Documents
|
// Upsert Documents
|
||||||
@ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
missingDocs := []database.Document{}
|
|
||||||
deletedDocIDs := []string{}
|
|
||||||
|
|
||||||
// Get Missing Documents
|
// 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 {
|
if err != nil {
|
||||||
log.Error("GetMissingDocuments DB Error", err)
|
log.Error("GetMissingDocuments DB Error", err)
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
@ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get Deleted Documents
|
// 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 {
|
if err != nil {
|
||||||
log.Error("GetDeletedDocuments DB Error", err)
|
log.Error("GetDeletedDocuments DB Error", err)
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
@ -494,7 +499,8 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Generate Storage Path
|
// 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
|
// Save & Prevent Overwrites
|
||||||
_, err = os.Stat(safePath)
|
_, err = os.Stat(safePath)
|
||||||
@ -521,6 +527,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
|
|||||||
Md5: metadataInfo.MD5,
|
Md5: metadataInfo.MD5,
|
||||||
Words: metadataInfo.WordCount,
|
Words: metadataInfo.WordCount,
|
||||||
Filepath: &fileName,
|
Filepath: &fileName,
|
||||||
|
Basepath: &basePath,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("UpsertDocument DB Error:", err)
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
apiErrorPage(c, http.StatusBadRequest, "Document Error")
|
apiErrorPage(c, http.StatusBadRequest, "Document Error")
|
||||||
|
23
api/utils.go
23
api/utils.go
@ -108,11 +108,11 @@ func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv
|
|||||||
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
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 {
|
if len(values)%2 != 0 {
|
||||||
return nil, errors.New("invalid dict call")
|
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 {
|
for i := 0; i < len(values); i += 2 {
|
||||||
key, ok := values[i].(string)
|
key, ok := values[i].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -123,12 +123,12 @@ func dict(values ...interface{}) (map[string]interface{}, error) {
|
|||||||
return dict, nil
|
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))
|
v := reflect.Indirect(reflect.ValueOf(value))
|
||||||
if v.Kind() != reflect.Struct {
|
if v.Kind() != reflect.Struct {
|
||||||
return nil, fmt.Errorf("%T is not a struct", value)
|
return nil, fmt.Errorf("%T is not a struct", value)
|
||||||
}
|
}
|
||||||
m := make(map[string]interface{})
|
m := make(map[string]any)
|
||||||
t := v.Type()
|
t := v.Type()
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for i := 0; i < t.NumField(); i++ {
|
||||||
sv := t.Field(i)
|
sv := t.Field(i)
|
||||||
@ -137,6 +137,10 @@ func fields(value interface{}) (map[string]interface{}, error) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func slice(elements ...any) []any {
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
|
||||||
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
||||||
// Derive New FileName
|
// Derive New FileName
|
||||||
var newFileName string
|
var newFileName string
|
||||||
@ -155,3 +159,14 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
|||||||
fileName := strings.ReplaceAll(newFileName, "/", "")
|
fileName := strings.ReplaceAll(newFileName, "/", "")
|
||||||
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
|
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
20
assets/sw.js
20
assets/sw.js
@ -99,7 +99,7 @@ const PRECACHE_ASSETS = [
|
|||||||
// ----------------------- Helpers ----------------------- //
|
// ----------------------- Helpers ----------------------- //
|
||||||
// ------------------------------------------------------- //
|
// ------------------------------------------------------- //
|
||||||
|
|
||||||
function purgeCache() {
|
async function purgeCache() {
|
||||||
console.log("[purgeCache] Purging Cache");
|
console.log("[purgeCache] Purging Cache");
|
||||||
return caches.keys().then(function (names) {
|
return caches.keys().then(function (names) {
|
||||||
for (let name of names) caches.delete(name);
|
for (let name of names) caches.delete(name);
|
||||||
@ -136,7 +136,7 @@ async function handleFetch(event) {
|
|||||||
const directive = ROUTES.find(
|
const directive = ROUTES.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
(item.route instanceof RegExp && url.match(item.route)) ||
|
(item.route instanceof RegExp && url.match(item.route)) ||
|
||||||
url == item.route
|
url == item.route,
|
||||||
) || { type: CACHE_NEVER };
|
) || { type: CACHE_NEVER };
|
||||||
|
|
||||||
// Get Fallback
|
// Get Fallback
|
||||||
@ -161,11 +161,11 @@ async function handleFetch(event) {
|
|||||||
);
|
);
|
||||||
case CACHE_UPDATE_SYNC:
|
case CACHE_UPDATE_SYNC:
|
||||||
return updateCache(event.request).catch(
|
return updateCache(event.request).catch(
|
||||||
(e) => currentCache || fallbackFunc(event)
|
(e) => currentCache || fallbackFunc(event),
|
||||||
);
|
);
|
||||||
case CACHE_UPDATE_ASYNC:
|
case CACHE_UPDATE_ASYNC:
|
||||||
let newResponse = updateCache(event.request).catch((e) =>
|
let newResponse = updateCache(event.request).catch((e) =>
|
||||||
fallbackFunc(event)
|
fallbackFunc(event),
|
||||||
);
|
);
|
||||||
|
|
||||||
return currentCache || newResponse;
|
return currentCache || newResponse;
|
||||||
@ -192,7 +192,7 @@ function handleMessage(event) {
|
|||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.startsWith("/documents/") ||
|
item.startsWith("/documents/") ||
|
||||||
item.startsWith("/reader/progress/")
|
item.startsWith("/reader/progress/"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derive Unique IDs
|
// Derive Unique IDs
|
||||||
@ -200,8 +200,8 @@ function handleMessage(event) {
|
|||||||
new Set(
|
new Set(
|
||||||
docResources
|
docResources
|
||||||
.filter((item) => item.startsWith("/documents/"))
|
.filter((item) => item.startsWith("/documents/"))
|
||||||
.map((item) => item.split("/")[2])
|
.map((item) => item.split("/")[2]),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -214,14 +214,14 @@ function handleMessage(event) {
|
|||||||
.filter(
|
.filter(
|
||||||
(id) =>
|
(id) =>
|
||||||
docResources.includes("/documents/" + id + "/file") &&
|
docResources.includes("/documents/" + id + "/file") &&
|
||||||
docResources.includes("/reader/progress/" + id)
|
docResources.includes("/reader/progress/" + id),
|
||||||
)
|
)
|
||||||
.map(async (id) => {
|
.map(async (id) => {
|
||||||
let url = "/reader/progress/" + id;
|
let url = "/reader/progress/" + id;
|
||||||
let currentCache = await caches.match(url);
|
let currentCache = await caches.match(url);
|
||||||
let resp = await updateCache(url).catch((e) => currentCache);
|
let resp = await updateCache(url).catch((e) => currentCache);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
event.source.postMessage({ id, data: cachedDocuments });
|
event.source.postMessage({ id, data: cachedDocuments });
|
||||||
@ -233,7 +233,7 @@ function handleMessage(event) {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
cache.delete("/documents/" + data.id + "/file"),
|
cache.delete("/documents/" + data.id + "/file"),
|
||||||
cache.delete("/reader/progress/" + data.id),
|
cache.delete("/reader/progress/" + data.id),
|
||||||
])
|
]),
|
||||||
)
|
)
|
||||||
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
||||||
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));
|
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));
|
||||||
|
38
database/migrations/20240510123707_import_basepath.go
Normal file
38
database/migrations/20240510123707_import_basepath.go
Normal 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
|
||||||
|
}
|
@ -30,6 +30,7 @@ type Device struct {
|
|||||||
type Document struct {
|
type Document struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Md5 *string `json:"md5"`
|
Md5 *string `json:"md5"`
|
||||||
|
Basepath *string `json:"basepath"`
|
||||||
Filepath *string `json:"filepath"`
|
Filepath *string `json:"filepath"`
|
||||||
Coverfile *string `json:"coverfile"`
|
Coverfile *string `json:"coverfile"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
|
@ -396,6 +396,7 @@ RETURNING *;
|
|||||||
INSERT INTO documents (
|
INSERT INTO documents (
|
||||||
id,
|
id,
|
||||||
md5,
|
md5,
|
||||||
|
basepath,
|
||||||
filepath,
|
filepath,
|
||||||
coverfile,
|
coverfile,
|
||||||
title,
|
title,
|
||||||
@ -410,10 +411,11 @@ INSERT INTO documents (
|
|||||||
isbn10,
|
isbn10,
|
||||||
isbn13
|
isbn13
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT DO UPDATE
|
ON CONFLICT DO UPDATE
|
||||||
SET
|
SET
|
||||||
md5 = COALESCE(excluded.md5, md5),
|
md5 = COALESCE(excluded.md5, md5),
|
||||||
|
basepath = COALESCE(excluded.basepath, basepath),
|
||||||
filepath = COALESCE(excluded.filepath, filepath),
|
filepath = COALESCE(excluded.filepath, filepath),
|
||||||
coverfile = COALESCE(excluded.coverfile, coverfile),
|
coverfile = COALESCE(excluded.coverfile, coverfile),
|
||||||
title = COALESCE(excluded.title, title),
|
title = COALESCE(excluded.title, title),
|
||||||
|
@ -454,7 +454,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDocument = `-- name: GetDocument :one
|
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
|
WHERE id = ?1 LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -464,6 +464,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
|
|||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Md5,
|
&i.Md5,
|
||||||
|
&i.Basepath,
|
||||||
&i.Filepath,
|
&i.Filepath,
|
||||||
&i.Coverfile,
|
&i.Coverfile,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
@ -612,7 +613,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDocuments = `-- name: GetDocuments :many
|
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
|
ORDER BY created_at DESC
|
||||||
LIMIT ?2
|
LIMIT ?2
|
||||||
OFFSET ?1
|
OFFSET ?1
|
||||||
@ -635,6 +636,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Md5,
|
&i.Md5,
|
||||||
|
&i.Basepath,
|
||||||
&i.Filepath,
|
&i.Filepath,
|
||||||
&i.Coverfile,
|
&i.Coverfile,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
@ -819,7 +821,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getMissingDocuments = `-- name: GetMissingDocuments :many
|
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
|
WHERE
|
||||||
documents.filepath IS NOT NULL
|
documents.filepath IS NOT NULL
|
||||||
AND documents.deleted = false
|
AND documents.deleted = false
|
||||||
@ -848,6 +850,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Md5,
|
&i.Md5,
|
||||||
|
&i.Basepath,
|
||||||
&i.Filepath,
|
&i.Filepath,
|
||||||
&i.Coverfile,
|
&i.Coverfile,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
@ -1325,6 +1328,7 @@ const upsertDocument = `-- name: UpsertDocument :one
|
|||||||
INSERT INTO documents (
|
INSERT INTO documents (
|
||||||
id,
|
id,
|
||||||
md5,
|
md5,
|
||||||
|
basepath,
|
||||||
filepath,
|
filepath,
|
||||||
coverfile,
|
coverfile,
|
||||||
title,
|
title,
|
||||||
@ -1339,10 +1343,11 @@ INSERT INTO documents (
|
|||||||
isbn10,
|
isbn10,
|
||||||
isbn13
|
isbn13
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT DO UPDATE
|
ON CONFLICT DO UPDATE
|
||||||
SET
|
SET
|
||||||
md5 = COALESCE(excluded.md5, md5),
|
md5 = COALESCE(excluded.md5, md5),
|
||||||
|
basepath = COALESCE(excluded.basepath, basepath),
|
||||||
filepath = COALESCE(excluded.filepath, filepath),
|
filepath = COALESCE(excluded.filepath, filepath),
|
||||||
coverfile = COALESCE(excluded.coverfile, coverfile),
|
coverfile = COALESCE(excluded.coverfile, coverfile),
|
||||||
title = COALESCE(excluded.title, title),
|
title = COALESCE(excluded.title, title),
|
||||||
@ -1356,12 +1361,13 @@ SET
|
|||||||
gbid = COALESCE(excluded.gbid, gbid),
|
gbid = COALESCE(excluded.gbid, gbid),
|
||||||
isbn10 = COALESCE(excluded.isbn10, isbn10),
|
isbn10 = COALESCE(excluded.isbn10, isbn10),
|
||||||
isbn13 = COALESCE(excluded.isbn13, isbn13)
|
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 {
|
type UpsertDocumentParams struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Md5 *string `json:"md5"`
|
Md5 *string `json:"md5"`
|
||||||
|
Basepath *string `json:"basepath"`
|
||||||
Filepath *string `json:"filepath"`
|
Filepath *string `json:"filepath"`
|
||||||
Coverfile *string `json:"coverfile"`
|
Coverfile *string `json:"coverfile"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
@ -1381,6 +1387,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
|
|||||||
row := q.db.QueryRowContext(ctx, upsertDocument,
|
row := q.db.QueryRowContext(ctx, upsertDocument,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
arg.Md5,
|
arg.Md5,
|
||||||
|
arg.Basepath,
|
||||||
arg.Filepath,
|
arg.Filepath,
|
||||||
arg.Coverfile,
|
arg.Coverfile,
|
||||||
arg.Title,
|
arg.Title,
|
||||||
@ -1399,6 +1406,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
|
|||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Md5,
|
&i.Md5,
|
||||||
|
&i.Basepath,
|
||||||
&i.Filepath,
|
&i.Filepath,
|
||||||
&i.Coverfile,
|
&i.Coverfile,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
|
@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS documents (
|
|||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
|
||||||
md5 TEXT,
|
md5 TEXT,
|
||||||
|
basepath TEXT,
|
||||||
filepath TEXT,
|
filepath TEXT,
|
||||||
coverfile TEXT,
|
coverfile TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
|
@ -294,5 +294,3 @@ INNER JOIN
|
|||||||
ON ga.document_id = d.id
|
ON ga.document_id = d.id
|
||||||
GROUP BY ga.document_id, ga.user_id
|
GROUP BY ga.document_id, ga.user_id
|
||||||
ORDER BY total_wpm DESC;
|
ORDER BY total_wpm DESC;
|
||||||
|
|
||||||
|
|
||||||
|
1
go.mod
1
go.mod
@ -49,6 +49,7 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.1 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
@ -19,6 +19,10 @@ sql:
|
|||||||
go_type:
|
go_type:
|
||||||
type: "string"
|
type: "string"
|
||||||
pointer: true
|
pointer: true
|
||||||
|
- column: "documents.basepath"
|
||||||
|
go_type:
|
||||||
|
type: "string"
|
||||||
|
pointer: true
|
||||||
- column: "documents.coverfile"
|
- column: "documents.coverfile"
|
||||||
go_type:
|
go_type:
|
||||||
type: "string"
|
type: "string"
|
||||||
|
@ -54,6 +54,19 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* -------- CSS Button -------- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
.css-button:checked+div {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.css-button+div {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------- */
|
/* ----------------------------- */
|
||||||
/* ------- User Dropdown ------- */
|
/* ------- User Dropdown ------- */
|
||||||
/* ----------------------------- */
|
/* ----------------------------- */
|
||||||
|
14
templates/components/button.tmpl
Normal file
14
templates/components/button.tmpl
Normal 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 }}
|
43
templates/components/document-card.tmpl
Normal file
43
templates/components/document-card.tmpl
Normal 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>
|
12
templates/components/info-card.tmpl
Normal file
12
templates/components/info-card.tmpl
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{{ if .Link }}<a href="{{ .Link }}" {{ else }} <div {{ end }}class="w-full">
|
||||||
|
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">{{ .Size }}</p>
|
||||||
|
<p class="text-sm text-gray-400">{{ .Title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if .Link }}
|
||||||
|
</a>
|
||||||
|
{{ else }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
20
templates/components/key-val-edit.tmpl
Normal file
20
templates/components/key-val-edit.tmpl
Normal 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>
|
102
templates/components/metadata.tmpl
Normal file
102
templates/components/metadata.tmpl
Normal 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 }}
|
39
templates/components/streak-card.tmpl
Normal file
39
templates/components/streak-card.tmpl
Normal 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>
|
28
templates/components/table.tmpl
Normal file
28
templates/components/table.tmpl
Normal 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>
|
46
templates/pages/admin-import-results.tmpl
Normal file
46
templates/pages/admin-import-results.tmpl
Normal 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 }}
|
@ -19,12 +19,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-around gap-2 mr-4">
|
<div class="flex flex-col justify-around gap-2 mr-4">
|
||||||
<div class="inline-flex gap-2 items-center">
|
<div class="inline-flex gap-2 items-center">
|
||||||
<input checked type="radio" id="copy" name="type" value="COPY" />
|
<input checked type="radio" id="direct" name="type" value="DIRECT" />
|
||||||
<label for="copy">Copy</label>
|
<label for="direct">Direct</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex gap-2 items-center">
|
<div class="inline-flex gap-2 items-center">
|
||||||
<input type="radio" id="direct" name="type" value="DIRECT" />
|
<input type="radio" id="copy" name="type" value="COPY" />
|
||||||
<label for="direct">Direct</label>
|
<label for="copy">Copy</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,10 +17,12 @@
|
|||||||
placeholder="JQ Filter" />
|
placeholder="JQ Filter" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<div class="lg:w-60">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Filter</span>
|
"Title" "Filter"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<!-- Required for iOS "Hover" Events (onclick) -->
|
<!-- Required for iOS "Hover" Events (onclick) -->
|
||||||
|
@ -35,6 +35,7 @@
|
|||||||
<label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label>
|
<label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label>
|
||||||
</th>
|
</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">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">
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center">
|
||||||
Permissions
|
Permissions
|
||||||
</th>
|
</th>
|
||||||
@ -55,6 +56,33 @@
|
|||||||
<td class="p-3 border-b border-gray-200">
|
<td class="p-3 border-b border-gray-200">
|
||||||
<p>{{ $user.ID }}</p>
|
<p>{{ $user.ID }}</p>
|
||||||
</td>
|
</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">
|
<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-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>
|
<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>
|
||||||
|
@ -21,10 +21,12 @@
|
|||||||
<label for="backup_documents">Documents</label>
|
<label for="backup_documents">Documents</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<div class="w-40 h-10">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Backup</span>
|
"Title" "Backup"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
@ -34,10 +36,12 @@
|
|||||||
<div class="flex items-center w-1/2">
|
<div class="flex items-center w-1/2">
|
||||||
<input type="file" accept=".zip" name="restore_file" class="w-full" />
|
<input type="file" accept=".zip" name="restore_file" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<div class="w-40 h-10">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Restore</span>
|
"Title" "Restore"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{ if .PasswordErrorMessage }}
|
{{ if .PasswordErrorMessage }}
|
||||||
@ -57,10 +61,12 @@
|
|||||||
<td class="py-2 float-right">
|
<td class="py-2 float-right">
|
||||||
<form action="./admin" method="POST">
|
<form action="./admin" method="POST">
|
||||||
<input type="text" name="action" value="METADATA_MATCH" class="hidden" />
|
<input type="text" name="action" value="METADATA_MATCH" class="hidden" />
|
||||||
<button type="submit"
|
<div class="w-40 h-10 text-base">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Run</span>
|
"Title" "Run"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -71,10 +77,12 @@
|
|||||||
<td class="py-2 float-right">
|
<td class="py-2 float-right">
|
||||||
<form action="./admin" method="POST">
|
<form action="./admin" method="POST">
|
||||||
<input type="text" name="action" value="CACHE_TABLES" class="hidden" />
|
<input type="text" name="action" value="CACHE_TABLES" class="hidden" />
|
||||||
<button type="submit"
|
<div class="w-40 h-10 text-base">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Run</span>
|
"Title" "Run"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -33,8 +33,7 @@
|
|||||||
action="./{{ .Data.ID }}/edit"
|
action="./{{ .Data.ID }}/edit"
|
||||||
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm">
|
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm">
|
||||||
<input type="file" id="cover_file" name="cover_file">
|
<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"
|
{{ template "component/button" (dict "Title" "Upload Cover") }}
|
||||||
type="submit">Upload Cover</button>
|
|
||||||
</form>
|
</form>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="./{{ .Data.ID }}/edit"
|
action="./{{ .Data.ID }}/edit"
|
||||||
@ -44,8 +43,7 @@
|
|||||||
id="remove_cover"
|
id="remove_cover"
|
||||||
name="remove_cover"
|
name="remove_cover"
|
||||||
class="hidden" />
|
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"
|
{{ template "component/button" (dict "Title" "Remove Cover") }}
|
||||||
type="submit">Remove Cover</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<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">
|
<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"
|
<form method="POST"
|
||||||
action="./{{ .Data.ID }}/delete"
|
action="./{{ .Data.ID }}/delete"
|
||||||
class="text-black dark:text-white text-sm">
|
class="text-black dark:text-white text-sm w-24">
|
||||||
<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"
|
{{ template "component/button" (dict "Title" "Delete") }}
|
||||||
type="submit">Delete</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -86,8 +83,7 @@
|
|||||||
placeholder="ISBN 10 / ISBN 13"
|
placeholder="ISBN 10 / ISBN 13"
|
||||||
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
|
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
|
||||||
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
|
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"
|
{{ template "component/button" (dict "Title" "Identify") }}
|
||||||
type="submit">Identify</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -100,40 +96,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
|
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
|
||||||
<div class="relative">
|
{{ template "component/key-val-edit" (dict
|
||||||
<div class="text-gray-500 inline-flex gap-2 relative">
|
"Title" "Title"
|
||||||
<p>Title</p>
|
"Value" .Data.Title
|
||||||
<label class="my-auto" for="edit-title-button">{{ template "svg/edit" (dict "Size" 18) }}</label>
|
"URL" (printf "./%s/edit" .Data.ID)
|
||||||
<input type="checkbox" id="edit-title-button" class="hidden css-button" />
|
"FormValue" "title"
|
||||||
<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"
|
{{ template "component/key-val-edit" (dict
|
||||||
action="./{{ .Data.ID }}/edit"
|
"Title" "Author"
|
||||||
class="flex flex-col gap-2 text-black dark:text-white text-sm">
|
"Value" .Data.Author
|
||||||
<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">
|
"URL" (printf "./%s/edit" .Data.ID)
|
||||||
<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"
|
"FormValue" "author"
|
||||||
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>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="text-gray-500 inline-flex gap-2 relative">
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
<p>Time Read</p>
|
<p>Time Read</p>
|
||||||
@ -181,119 +155,16 @@
|
|||||||
id="description"
|
id="description"
|
||||||
name="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>
|
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"
|
{{ template "component/button" (dict "Title" "Save") }}
|
||||||
type="submit">Save</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ or .Data.Description "N/A" }}</p>
|
<p>{{ or .Data.Description "N/A" }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ if .MetadataError }}
|
{{ template "component/metadata" (dict
|
||||||
<div class="absolute top-0 left-0 w-full h-full z-50">
|
"ID" .Data.ID
|
||||||
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
"Metadata" .Metadata
|
||||||
<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">
|
"Error" .MetadataError
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ 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 }}
|
|
||||||
|
@ -18,58 +18,17 @@
|
|||||||
placeholder="Search Author / Title" />
|
placeholder="Search Author / Title" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<div class="lg:w-60">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Search</span>
|
"Title" "Search"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{{ range $doc := .Data }}
|
{{ range $doc := .Data }}
|
||||||
<div class="w-full relative">
|
{{ template "component/document-card" $doc }}
|
||||||
<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>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
||||||
@ -89,7 +48,7 @@
|
|||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
action="./documents"
|
action="./documents"
|
||||||
class="flex flex-col gap-2">
|
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"
|
<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>
|
type="submit">Upload File</button>
|
||||||
</form>
|
</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"
|
<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>
|
for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label>
|
||||||
</div>
|
</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 }}
|
{{ end }}
|
||||||
|
@ -41,82 +41,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
<a href="./documents" class="w-full">
|
{{ template "component/info-card" (dict
|
||||||
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
"Title" "Documents"
|
||||||
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
"Size" .Data.DatabaseInfo.DocumentsSize
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DocumentsSize }}</p>
|
"Link" "./documents"
|
||||||
<p class="text-sm text-gray-400">Documents</p>
|
)}}
|
||||||
</div>
|
{{ template "component/info-card" (dict
|
||||||
</div>
|
"Title" "Activity Records"
|
||||||
</a>
|
"Size" .Data.DatabaseInfo.ActivitySize
|
||||||
<a href="./activity" class="w-full">
|
"Link" "./activity"
|
||||||
<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">
|
{{ template "component/info-card" (dict
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ActivitySize }}</p>
|
"Title" "Progress Records"
|
||||||
<p class="text-sm text-gray-400">Activity Records</p>
|
"Size" .Data.DatabaseInfo.ProgressSize
|
||||||
</div>
|
"Link" "./progress"
|
||||||
</div>
|
)}}
|
||||||
</a>
|
{{ template "component/info-card" (dict
|
||||||
<a href="./progress" class="w-full">
|
"Title" "Devices"
|
||||||
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
"Size" .Data.DatabaseInfo.DevicesSize
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{{ range $item := .Data.Streaks }}
|
{{ range $item := .Data.Streaks }}
|
||||||
<div class="w-full">
|
{{ template "component/streak-card" $item }}
|
||||||
<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>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@ -39,6 +39,13 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -30,10 +30,12 @@
|
|||||||
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
|
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<div class="lg:w-60">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Search</span>
|
"Title" "Search"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{ if .SearchErrorMessage }}
|
{{ if .SearchErrorMessage }}
|
||||||
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>
|
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>
|
||||||
|
@ -39,10 +39,12 @@
|
|||||||
placeholder="New Password" />
|
placeholder="New Password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<div class="lg:w-60">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Submit</span>
|
"Title" "Submit"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{ if .PasswordErrorMessage }}
|
{{ if .PasswordErrorMessage }}
|
||||||
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
|
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
|
||||||
@ -69,10 +71,12 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"
|
<div class="lg:w-60">
|
||||||
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">
|
{{ template "component/button" (dict
|
||||||
<span class="w-full">Submit</span>
|
"Title" "Submit"
|
||||||
</button>
|
"Variant" "Secondary"
|
||||||
|
) }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{ if .TimeOffsetErrorMessage }}
|
{{ if .TimeOffsetErrorMessage }}
|
||||||
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>
|
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>
|
||||||
|
Loading…
Reference in New Issue
Block a user