[add] editing, deletion, metadata recording

This commit is contained in:
Evan Reichard 2023-09-23 14:14:57 -04:00
parent 3150c89303
commit 64ab10c95e
12 changed files with 920 additions and 163 deletions

View File

@ -25,7 +25,7 @@ type API struct {
func NewApi(db *database.DBManager, c *config.Config) *API {
api := &API{
HTMLPolicy: bluemonday.StripTagsPolicy(),
HTMLPolicy: bluemonday.StrictPolicy(),
Router: gin.Default(),
Config: c,
DB: db,
@ -97,6 +97,9 @@ func (api *API) registerWebAppRoutes() {
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.DELETE("/documents/:document", api.authWebAppMiddleware, api.deleteDocument)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.createAppResourcesRoute("document-edit"))
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile)
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)

View File

@ -2,17 +2,29 @@ package api
import (
"fmt"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"reichard.io/bbank/database"
"reichard.io/bbank/metadata"
)
type requestDocumentEdit struct {
Title *string `form:"title"`
Author *string `form:"author"`
Description *string `form:"description"`
RemoveCover *string `form:"remove_cover"`
CoverFile *multipart.FileHeader `form:"cover"`
}
func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Context) {
variables := gin.H{"RouteName": template}
if len(args) > 0 {
@ -84,6 +96,92 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
}
templateVars["Data"] = document
} else if routeName == "document-edit" {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[createAppResourcesRoute] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
var rDocEdit requestDocumentEdit
if err := c.ShouldBind(&rDocEdit); err != nil {
log.Error("[createAppResourcesRoute] Invalid Form Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Validate Something Exists
if rDocEdit.Author == nil &&
rDocEdit.Title == nil &&
rDocEdit.Description == nil &&
rDocEdit.CoverFile == nil &&
rDocEdit.RemoveCover == nil {
log.Error("[createAppResourcesRoute] Missing Form Values")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Handle Cover
var coverFileName *string
if rDocEdit.RemoveCover != nil && *rDocEdit.RemoveCover == "on" {
s := "UNKNOWN"
coverFileName = &s
} else if rDocEdit.CoverFile != nil {
// Validate Type & Derive Extension on MIME
uploadedFile, err := rDocEdit.CoverFile.Open()
if err != nil {
log.Error("[createAppResourcesRoute] File Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
fileMime, err := mimetype.DetectReader(uploadedFile)
if err != nil {
log.Error("[createAppResourcesRoute] MIME Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
fileExtension := fileMime.Extension()
// Validate Extension
if !slices.Contains([]string{".jpg", ".png"}, fileExtension) {
log.Error("[uploadDocumentFile] Invalid FileType: ", fileExtension)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
return
}
// Generate Storage Path
fileName := fmt.Sprintf("%s%s", rDocID.DocumentID, fileExtension)
safePath := filepath.Join(api.Config.DataPath, "covers", fileName)
// Save
err = c.SaveUploadedFile(rDocEdit.CoverFile, safePath)
if err != nil {
log.Error("[createAppResourcesRoute] File Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
coverFileName = &fileName
}
// Update Document
if _, err := api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: rDocID.DocumentID,
Title: api.sanitizeInput(rDocEdit.Title),
Author: api.sanitizeInput(rDocEdit.Author),
Description: api.sanitizeInput(rDocEdit.Description),
Coverfile: coverFileName,
}); err != nil {
log.Error("[createAppResourcesRoute] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
c.Redirect(http.StatusFound, "./")
return
} else if routeName == "activity" {
activityFilter := database.GetActivityParams{
UserID: rUser.(string),
@ -164,19 +262,19 @@ func (api *API) getDocumentCover(c *gin.Context) {
}
// Handle Identified Document
if document.Olid != nil {
if *document.Olid == "UNKNOWN" {
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
c.File("./assets/no-cover.jpg")
return
}
// Derive Path
fileName := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", *document.Olid))
safePath := filepath.Join(api.Config.DataPath, "covers", fileName)
safePath := filepath.Join(api.Config.DataPath, "covers", *document.Coverfile)
// Validate File Exists
_, err = os.Stat(safePath)
if err != nil {
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
c.File("./assets/no-cover.jpg")
return
}
@ -185,59 +283,126 @@ func (api *API) getDocumentCover(c *gin.Context) {
return
}
/*
This is a bit convoluted because we want to ensure we set the OLID to
UNKNOWN if there are any errors. This will ideally prevent us from
hitting the OpenLibrary API multiple times in the future.
*/
// --- Attempt Metadata ---
var coverID string = "UNKNOWN"
var coverFilePath string
var coverDir string = filepath.Join(api.Config.DataPath, "covers")
var coverFile string = "UNKNOWN"
// Identify Documents & Save Covers
bookMetadata := metadata.MetadataInfo{
metadataResults, err := metadata.GetMetadata(metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
}
err = metadata.GetMetadata(&bookMetadata)
if err == nil && bookMetadata.GBID != nil {
// Derive & Sanitize File Name
fileName := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", *bookMetadata.GBID))
})
// Generate Storage Path
coverFilePath = filepath.Join(api.Config.DataPath, "covers", fileName)
if err == nil && len(metadataResults) > 0 && metadataResults[0].GBID != nil {
firstResult := metadataResults[0]
err := metadata.SaveCover(*bookMetadata.GBID, coverFilePath)
// Save Cover
fileName, err := metadata.SaveCover(*firstResult.GBID, coverDir, document.ID)
if err == nil {
coverID = *bookMetadata.GBID
log.Info("Title:", *bookMetadata.Title)
log.Info("Author:", *bookMetadata.Author)
log.Info("Description:", *bookMetadata.Description)
log.Info("IDs:", bookMetadata.ISBN)
coverFile = *fileName
}
// Store First Metadata Result
if _, err = api.DB.Queries.AddMetadata(api.DB.Ctx, database.AddMetadataParams{
DocumentID: document.ID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.GBID,
Olid: firstResult.OLID,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.Error("[getDocumentCover] AddMetadata DB Error:", err)
}
}
// coverIDs, err := metadata.GetCoverOLIDs(document.Title, document.Author)
// if err == nil && len(coverIDs) > 0 {
// coverFilePath, err = metadata.DownloadAndSaveCover(coverIDs[0], api.Config.DataPath)
// if err == nil {
// coverID = coverIDs[0]
// }
// }
// Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: document.ID,
Olid: &coverID,
ID: document.ID,
Coverfile: &coverFile,
}); err != nil {
log.Warn("[getDocumentCover] UpsertDocument DB Error:", err)
}
// Return Unknown Cover
if coverID == "UNKNOWN" {
if coverFile == "UNKNOWN" {
c.File("./assets/no-cover.jpg")
return
}
coverFilePath := filepath.Join(coverDir, coverFile)
c.File(coverFilePath)
}
// DELETE /api/documents/:document
func (api *API) deleteDocument(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[deleteDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
changed, err := api.DB.Queries.DeleteDocument(api.DB.Ctx, rDocID.DocumentID)
if err != nil {
log.Error("[deleteDocument] DeleteDocument DB Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
if changed == 0 {
log.Error("[deleteDocument] DeleteDocument DB Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return
}
c.Redirect(http.StatusFound, "../")
}
// POST /api/documents/:document/identify
func (api *API) identifyDocument(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[identifyDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
isbn := strings.TrimSpace(c.PostForm("ISBN"))
if isbn == "" {
log.Error("[identifyDocument] Invalid Form")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
metadataResults, err := metadata.GetMetadata(metadata.MetadataInfo{
ISBN10: &isbn,
ISBN13: &isbn,
})
if err != nil || len(metadataResults) == 0 {
log.Error("[identifyDocument] Metadata Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Metadata Error"})
return
}
// TODO
firstResult := metadataResults[0]
if firstResult.Title != nil {
log.Info("Title:", *firstResult.Title)
}
if firstResult.Author != nil {
log.Info("Author:", *firstResult.Author)
}
if firstResult.Description != nil {
log.Info("Description:", *firstResult.Description)
}
if firstResult.ISBN10 != nil {
log.Info("ISBN 10:", *firstResult.ISBN10)
}
if firstResult.ISBN13 != nil {
log.Info("ISBN 13:", *firstResult.ISBN13)
}
c.Redirect(http.StatusFound, "/")
}

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"os"
@ -72,8 +73,6 @@ type requestDocumentID struct {
DocumentID string `uri:"document" binding:"required"`
}
var allowedExtensions []string = []string{".epub", ".html"}
func (api *API) authorizeUser(c *gin.Context) {
c.JSON(200, gin.H{
"authorized": "OK",
@ -485,7 +484,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
fileMime, err := mimetype.DetectReader(uploadedFile)
fileExtension := fileMime.Extension()
if !slices.Contains(allowedExtensions, fileExtension) {
if !slices.Contains([]string{".epub", ".html"}, fileExtension) {
log.Error("[uploadDocumentFile] Invalid FileType:", fileExtension)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
return
@ -609,12 +608,12 @@ func (api *API) sanitizeInput(val any) *string {
switch v := val.(type) {
case *string:
if v != nil {
newString := api.HTMLPolicy.Sanitize(string(*v))
newString := html.UnescapeString(api.HTMLPolicy.Sanitize(string(*v)))
return &newString
}
case string:
if v != "" {
newString := api.HTMLPolicy.Sanitize(string(v))
newString := html.UnescapeString(api.HTMLPolicy.Sanitize(string(v)))
return &newString
}
}

View File

@ -32,13 +32,17 @@ type Document struct {
ID string `json:"id"`
Md5 *string `json:"md5"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`
Author *string `json:"author"`
Series *string `json:"series"`
SeriesIndex *int64 `json:"series_index"`
Lang *string `json:"lang"`
Description *string `json:"description"`
Gbid *string `json:"gbid"`
Olid *string `json:"-"`
Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"`
Synced bool `json:"-"`
Deleted bool `json:"-"`
UpdatedAt time.Time `json:"updated_at"`
@ -62,6 +66,19 @@ type DocumentProgress struct {
CreatedAt time.Time `json:"created_at"`
}
type Metadatum struct {
ID int64 `json:"id"`
DocumentID string `json:"document_id"`
Title *string `json:"title"`
Author *string `json:"author"`
Description *string `json:"description"`
Gbid *string `json:"gbid"`
Olid *string `json:"olid"`
Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"`
CreatedAt time.Time `json:"created_at"`
}
type RescaledActivity struct {
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`

View File

@ -1,3 +1,17 @@
-- name: AddMetadata :one
INSERT INTO metadata (
document_id,
title,
author,
description,
gbid,
olid,
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: CreateUser :execrows
INSERT INTO users (id, pass)
VALUES (?, ?)
@ -12,26 +26,34 @@ INSERT INTO documents (
id,
md5,
filepath,
coverfile,
title,
author,
series,
series_index,
lang,
description,
olid
olid,
gbid,
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE
SET
md5 = COALESCE(excluded.md5, md5),
filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title),
author = COALESCE(excluded.author, author),
series = COALESCE(excluded.series, series),
series_index = COALESCE(excluded.series_index, series_index),
lang = COALESCE(excluded.lang, lang),
description = COALESCE(excluded.description, description),
olid = COALESCE(excluded.olid, olid)
olid = COALESCE(excluded.olid, olid),
gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING *;
-- name: DeleteDocument :execrows
@ -222,6 +244,7 @@ SELECT
FROM documents
LEFT JOIN true_progress ON true_progress.document_id = documents.id
LEFT JOIN users ON users.id = $user_id
WHERE documents.deleted == false
ORDER BY true_progress.last_read DESC, documents.created_at DESC
LIMIT $limit
OFFSET $offset;
@ -308,11 +331,11 @@ FROM document_days;
-- name: GetUserWindowStreaks :one
WITH document_windows AS (
SELECT
CASE
WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window,
time_offset
CASE
WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window,
time_offset
FROM activity
JOIN users ON users.id = activity.user_id
WHERE user_id = $user_id
@ -332,14 +355,14 @@ streaks AS (
count(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
time_offset
time_offset
FROM partitions
GROUP BY
CASE
WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day')
WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day')
END,
time_offset
CASE
WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day')
WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day')
END,
time_offset
ORDER BY end_date DESC
),
max_streak AS (

View File

@ -61,6 +61,59 @@ func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (Activ
return i, err
}
const addMetadata = `-- name: AddMetadata :one
INSERT INTO metadata (
document_id,
title,
author,
description,
gbid,
olid,
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, document_id, title, author, description, gbid, olid, isbn10, isbn13, created_at
`
type AddMetadataParams struct {
DocumentID string `json:"document_id"`
Title *string `json:"title"`
Author *string `json:"author"`
Description *string `json:"description"`
Gbid *string `json:"gbid"`
Olid *string `json:"olid"`
Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"`
}
func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metadatum, error) {
row := q.db.QueryRowContext(ctx, addMetadata,
arg.DocumentID,
arg.Title,
arg.Author,
arg.Description,
arg.Gbid,
arg.Olid,
arg.Isbn10,
arg.Isbn13,
)
var i Metadatum
err := row.Scan(
&i.ID,
&i.DocumentID,
&i.Title,
&i.Author,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.CreatedAt,
)
return i, err
}
const createUser = `-- name: CreateUser :execrows
INSERT INTO users (id, pass)
VALUES (?, ?)
@ -366,7 +419,7 @@ func (q *Queries) GetDevices(ctx context.Context, arg GetDevicesParams) ([]Devic
}
const getDocument = `-- name: GetDocument :one
SELECT id, md5, filepath, title, author, series, series_index, lang, description, olid, synced, deleted, updated_at, created_at FROM documents
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
WHERE id = ?1 LIMIT 1
`
@ -377,13 +430,17 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
@ -501,7 +558,7 @@ WITH true_progress AS (
LIMIT 1
)
SELECT
documents.id, documents.md5, documents.filepath, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.olid, documents.synced, documents.deleted, documents.updated_at, documents.created_at,
documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at,
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
@ -531,13 +588,17 @@ type GetDocumentWithStatsRow struct {
ID string `json:"id"`
Md5 *string `json:"md5"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`
Author *string `json:"author"`
Series *string `json:"series"`
SeriesIndex *int64 `json:"series_index"`
Lang *string `json:"lang"`
Description *string `json:"description"`
Gbid *string `json:"gbid"`
Olid *string `json:"-"`
Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"`
Synced bool `json:"-"`
Deleted bool `json:"-"`
UpdatedAt time.Time `json:"updated_at"`
@ -556,13 +617,17 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
@ -577,7 +642,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
}
const getDocuments = `-- name: GetDocuments :many
SELECT id, md5, filepath, title, author, series, series_index, lang, description, olid, synced, deleted, updated_at, created_at FROM documents
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
ORDER BY created_at DESC
LIMIT ?2
OFFSET ?1
@ -601,13 +666,17 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
@ -641,7 +710,7 @@ WITH true_progress AS (
HAVING MAX(start_time)
)
SELECT
documents.id, documents.md5, documents.filepath, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.olid, documents.synced, documents.deleted, documents.updated_at, documents.created_at,
documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at,
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
@ -657,6 +726,7 @@ SELECT
FROM documents
LEFT JOIN true_progress ON true_progress.document_id = documents.id
LEFT JOIN users ON users.id = ?1
WHERE documents.deleted == false
ORDER BY true_progress.last_read DESC, documents.created_at DESC
LIMIT ?3
OFFSET ?2
@ -672,13 +742,17 @@ type GetDocumentsWithStatsRow struct {
ID string `json:"id"`
Md5 *string `json:"md5"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`
Author *string `json:"author"`
Series *string `json:"series"`
SeriesIndex *int64 `json:"series_index"`
Lang *string `json:"lang"`
Description *string `json:"description"`
Gbid *string `json:"gbid"`
Olid *string `json:"-"`
Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"`
Synced bool `json:"-"`
Deleted bool `json:"-"`
UpdatedAt time.Time `json:"updated_at"`
@ -703,13 +777,17 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
@ -754,7 +832,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
}
const getMissingDocuments = `-- name: GetMissingDocuments :many
SELECT documents.id, documents.md5, documents.filepath, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.olid, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
WHERE
documents.filepath IS NOT NULL
AND documents.deleted = false
@ -784,13 +862,17 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
@ -875,11 +957,11 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
const getUserWindowStreaks = `-- name: GetUserWindowStreaks :one
WITH document_windows AS (
SELECT
CASE
WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window,
time_offset
CASE
WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window,
time_offset
FROM activity
JOIN users ON users.id = activity.user_id
WHERE user_id = ?1
@ -899,14 +981,14 @@ streaks AS (
count(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
time_offset
time_offset
FROM partitions
GROUP BY
CASE
WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day')
WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day')
END,
time_offset
CASE
WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day')
WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day')
END,
time_offset
ORDER BY end_date DESC
),
max_streak AS (
@ -1073,7 +1155,7 @@ UPDATE documents
SET
deleted = ?1
WHERE id = ?2
RETURNING id, md5, filepath, title, author, series, series_index, lang, description, olid, synced, deleted, updated_at, created_at
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
`
type UpdateDocumentDeletedParams struct {
@ -1088,13 +1170,17 @@ func (q *Queries) UpdateDocumentDeleted(ctx context.Context, arg UpdateDocumentD
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
@ -1108,7 +1194,7 @@ UPDATE documents
SET
synced = ?1
WHERE id = ?2
RETURNING id, md5, filepath, title, author, series, series_index, lang, description, olid, synced, deleted, updated_at, created_at
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
`
type UpdateDocumentSyncParams struct {
@ -1123,13 +1209,17 @@ func (q *Queries) UpdateDocumentSync(ctx context.Context, arg UpdateDocumentSync
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
@ -1211,33 +1301,42 @@ INSERT INTO documents (
id,
md5,
filepath,
coverfile,
title,
author,
series,
series_index,
lang,
description,
olid
olid,
gbid,
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE
SET
md5 = COALESCE(excluded.md5, md5),
filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title),
author = COALESCE(excluded.author, author),
series = COALESCE(excluded.series, series),
series_index = COALESCE(excluded.series_index, series_index),
lang = COALESCE(excluded.lang, lang),
description = COALESCE(excluded.description, description),
olid = COALESCE(excluded.olid, olid)
RETURNING id, md5, filepath, title, author, series, series_index, lang, description, olid, synced, deleted, updated_at, created_at
olid = COALESCE(excluded.olid, olid),
gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
`
type UpsertDocumentParams struct {
ID string `json:"id"`
Md5 *string `json:"md5"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`
Author *string `json:"author"`
Series *string `json:"series"`
@ -1245,6 +1344,9 @@ type UpsertDocumentParams struct {
Lang *string `json:"lang"`
Description *string `json:"description"`
Olid *string `json:"-"`
Gbid *string `json:"gbid"`
Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"`
}
func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams) (Document, error) {
@ -1252,6 +1354,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
arg.ID,
arg.Md5,
arg.Filepath,
arg.Coverfile,
arg.Title,
arg.Author,
arg.Series,
@ -1259,19 +1362,26 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
arg.Lang,
arg.Description,
arg.Olid,
arg.Gbid,
arg.Isbn10,
arg.Isbn13,
)
var i Document
err := row.Scan(
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,

View File

@ -18,13 +18,19 @@ CREATE TABLE IF NOT EXISTS documents (
md5 TEXT,
filepath TEXT,
coverfile TEXT,
title TEXT,
author TEXT,
series TEXT,
series_index INTEGER,
lang TEXT,
description TEXT,
gbid TEXT,
olid TEXT,
isbn10 TEXT,
isbn13 TEXT,
synced BOOLEAN NOT NULL DEFAULT 0 CHECK (synced IN (0, 1)),
deleted BOOLEAN NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)),
@ -32,6 +38,25 @@ CREATE TABLE IF NOT EXISTS documents (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Metadata
CREATE TABLE IF NOT EXISTS metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
title TEXT,
author TEXT,
description TEXT,
gbid TEXT,
olid TEXT,
isbn10 TEXT,
isbn13 TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents (id)
);
-- Devices
CREATE TABLE IF NOT EXISTS devices (
id TEXT NOT NULL PRIMARY KEY,

View File

@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
)
@ -17,7 +18,9 @@ type MetadataInfo struct {
Author *string
Description *string
GBID *string
ISBN []*string
OLID *string
ISBN10 *string
ISBN13 *string
}
type gBooksIdentifiers struct {
@ -46,73 +49,99 @@ const GBOOKS_QUERY_URL string = "https://www.googleapis.com/books/v1/volumes?q=%
const GBOOKS_GBID_INFO_URL string = "https://www.googleapis.com/books/v1/volumes/%s"
const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690"
func GetMetadata(data *MetadataInfo) error {
var queryResult *gBooksQueryItem
if data.GBID != nil {
func GetMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
var queryResults []gBooksQueryItem
if metadataSearch.GBID != nil {
// Use GBID
resp, err := performGBIDRequest(*data.GBID)
resp, err := performGBIDRequest(*metadataSearch.GBID)
if err != nil {
return err
return nil, err
}
queryResult = resp
} else if len(data.ISBN) > 0 {
searchQuery := "isbn:" + *data.ISBN[0]
queryResults = []gBooksQueryItem{*resp}
} else if metadataSearch.ISBN13 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN13
resp, err := performSearchRequest(searchQuery)
if err != nil {
return err
return nil, err
}
queryResult = &resp.Items[0]
} else if data.Title != nil && data.Author != nil {
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *data.Title, *data.Author))
queryResults = resp.Items
} else if metadataSearch.ISBN10 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN10
resp, err := performSearchRequest(searchQuery)
if err != nil {
return err
return nil, err
}
queryResult = &resp.Items[0]
queryResults = resp.Items
} else if metadataSearch.Title != nil && metadataSearch.Author != nil {
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *metadataSearch.Title, *metadataSearch.Author))
resp, err := performSearchRequest(searchQuery)
if err != nil {
return nil, err
}
queryResults = resp.Items
} else {
return errors.New("Invalid Data")
return nil, errors.New("Invalid Data")
}
// Merge Data
data.GBID = &queryResult.ID
data.Description = &queryResult.Info.Description
data.Title = &queryResult.Info.Title
if len(queryResult.Info.Authors) > 0 {
data.Author = &queryResult.Info.Authors[0]
}
for _, item := range queryResult.Info.Identifiers {
if item.Type == "ISBN_10" || item.Type == "ISBN_13" {
data.ISBN = append(data.ISBN, &item.Identifier)
// Normalize Data
allMetadata := []MetadataInfo{}
for i := range queryResults {
// Account for range value changing every iteration
item := queryResults[i]
itemResult := MetadataInfo{
GBID: &item.ID,
Title: &item.Info.Title,
Description: &item.Info.Description,
}
if len(item.Info.Authors) > 0 {
itemResult.Author = &item.Info.Authors[0]
}
for _, item := range item.Info.Identifiers {
if itemResult.ISBN10 != nil && itemResult.ISBN13 != nil {
break
} else if itemResult.ISBN10 == nil && item.Type == "ISBN_10" {
itemResult.ISBN10 = &item.Identifier
} else if itemResult.ISBN13 == nil && item.Type == "ISBN_13" {
itemResult.ISBN13 = &item.Identifier
}
}
allMetadata = append(allMetadata, itemResult)
}
return nil
return allMetadata, nil
}
func SaveCover(id string, safePath string) error {
func SaveCover(gbid string, coverDir string, documentID string) (*string, error) {
// Google Books -> JPG
coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID))
coverFilePath := filepath.Join(coverDir, coverFile)
// Validate File Doesn't Exists
_, err := os.Stat(safePath)
_, err := os.Stat(coverFilePath)
if err == nil {
log.Warn("[SaveCover] File Alreads Exists")
return nil
return &coverFile, nil
}
// Create File
out, err := os.Create(safePath)
out, err := os.Create(coverFilePath)
if err != nil {
log.Error("[SaveCover] File Create Error")
return errors.New("File Failure")
return nil, errors.New("File Failure")
}
defer out.Close()
// Download File
log.Info("[SaveCover] Downloading Cover")
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, id)
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, gbid)
resp, err := http.Get(coverURL)
if err != nil {
log.Error("[SaveCover] Cover URL API Failure")
return errors.New("API Failure")
return nil, errors.New("API Failure")
}
defer resp.Body.Close()
@ -121,20 +150,19 @@ func SaveCover(id string, safePath string) error {
_, err = io.Copy(out, resp.Body)
if err != nil {
log.Error("[SaveCover] File Copy Error")
return errors.New("File Failure")
return nil, errors.New("File Failure")
}
// Return FilePath
return nil
return &coverFile, nil
}
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
log.Info("[performSearchRequest] Acquiring CoverID")
log.Info("[performSearchRequest] Acquiring Metadata: ", apiQuery)
resp, err := http.Get(apiQuery)
if err != nil {
log.Error("[performSearchRequest] Cover URL API Failure")
log.Error("[performSearchRequest] Google Books Query URL API Failure")
return nil, errors.New("API Failure")
}

View File

@ -18,6 +18,10 @@ sql:
go_type:
type: "string"
pointer: true
- column: "documents.coverfile"
go_type:
type: "string"
pointer: true
- column: "documents.title"
go_type:
type: "string"
@ -46,6 +50,47 @@ sql:
go_type:
type: "string"
pointer: true
- column: "documents.gbid"
go_type:
type: "string"
pointer: true
- column: "documents.isbn10"
go_type:
type: "string"
pointer: true
- column: "documents.isbn13"
go_type:
type: "string"
pointer: true
- column: "metadata.title"
go_type:
type: "string"
pointer: true
- column: "metadata.author"
go_type:
type: "string"
pointer: true
- column: "metadata.description"
go_type:
type: "string"
pointer: true
- column: "metadata.gbid"
go_type:
type: "string"
pointer: true
- column: "metadata.olid"
go_type:
type: "string"
pointer: true
- column: "metadata.isbn10"
go_type:
type: "string"
pointer: true
- column: "metadata.isbn13"
go_type:
type: "string"
pointer: true
# Do not generate JSON
- column: "documents.synced"

View File

@ -0,0 +1,166 @@
{{template "base.html" .}}
{{define "title"}}Documents{{end}}
{{define "header"}}
<a href="../documents">Documents</a>
{{end}}
{{define "content"}}
<div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-6">
<div class="flex flex-col gap-2 float-left mr-6 mb-6">
<img class="rounded w-40 md:w-60 lg:w-80 object-fill h-full" src="../documents/{{.Data.ID}}/cover"></img>
<div class="flex gap-2 justify-end text-gray-500 dark:text-gray-400">
<div class="relative">
<label for="delete-button">
<svg
width="24"
height="24"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6.52381C3 6.12932 3.32671 5.80952 3.72973 5.80952H8.51787C8.52437 4.9683 8.61554 3.81504 9.45037 3.01668C10.1074 2.38839 11.0081 2 12 2C12.9919 2 13.8926 2.38839 14.5496 3.01668C15.3844 3.81504 15.4756 4.9683 15.4821 5.80952H20.2703C20.6733 5.80952 21 6.12932 21 6.52381C21 6.9183 20.6733 7.2381 20.2703 7.2381H3.72973C3.32671 7.2381 3 6.9183 3 6.52381Z"
/>
<path
d="M11.6066 22H12.3935C15.101 22 16.4547 22 17.3349 21.1368C18.2151 20.2736 18.3052 18.8576 18.4853 16.0257L18.7448 11.9452C18.8425 10.4086 18.8913 9.64037 18.4498 9.15352C18.0082 8.66667 17.2625 8.66667 15.7712 8.66667H8.22884C6.7375 8.66667 5.99183 8.66667 5.55026 9.15352C5.1087 9.64037 5.15756 10.4086 5.25528 11.9452L5.51479 16.0257C5.69489 18.8576 5.78494 20.2736 6.66513 21.1368C7.54532 22 8.89906 22 11.6066 22Z"
/>
</svg>
</label>
<input type="checkbox" id="delete-button" class="hidden css-button"/>
<form
method="POST"
action="./{{ .Data.ID }}/delete"
class="absolute bottom-7 left-5 text-black bg-gray-200 transition-all duration-200 rounded shadow-inner shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<p class="font-medium w-24 pb-2">Are you sure?</p>
<button class="font-medium w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Delete</button>
</form>
</div>
<a href="../activity?document={{ .Data.ID }}">
<svg
width="24"
height="24"
class="hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.5 2C8.67157 2 8 2.67157 8 3.5V4.5C8 5.32843 8.67157 6 9.5 6H14.5C15.3284 6 16 5.32843 16 4.5V3.5C16 2.67157 15.3284 2 14.5 2H9.5Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 4.03662C5.24209 4.10719 4.44798 4.30764 3.87868 4.87694C3 5.75562 3 7.16983 3 9.99826V15.9983C3 18.8267 3 20.2409 3.87868 21.1196C4.75736 21.9983 6.17157 21.9983 9 21.9983H15C17.8284 21.9983 19.2426 21.9983 20.1213 21.1196C21 20.2409 21 18.8267 21 15.9983V9.99826C21 7.16983 21 5.75562 20.1213 4.87694C19.552 4.30764 18.7579 4.10719 17.5 4.03662V4.5C17.5 6.15685 16.1569 7.5 14.5 7.5H9.5C7.84315 7.5 6.5 6.15685 6.5 4.5V4.03662ZM7 9.75C6.58579 9.75 6.25 10.0858 6.25 10.5C6.25 10.9142 6.58579 11.25 7 11.25H7.5C7.91421 11.25 8.25 10.9142 8.25 10.5C8.25 10.0858 7.91421 9.75 7.5 9.75H7ZM10.5 9.75C10.0858 9.75 9.75 10.0858 9.75 10.5C9.75 10.9142 10.0858 11.25 10.5 11.25H17C17.4142 11.25 17.75 10.9142 17.75 10.5C17.75 10.0858 17.4142 9.75 17 9.75H10.5ZM7 13.25C6.58579 13.25 6.25 13.5858 6.25 14C6.25 14.4142 6.58579 14.75 7 14.75H7.5C7.91421 14.75 8.25 14.4142 8.25 14C8.25 13.5858 7.91421 13.25 7.5 13.25H7ZM10.5 13.25C10.0858 13.25 9.75 13.5858 9.75 14C9.75 14.4142 10.0858 14.75 10.5 14.75H17C17.4142 14.75 17.75 14.4142 17.75 14C17.75 13.5858 17.4142 13.25 17 13.25H10.5ZM7 16.75C6.58579 16.75 6.25 17.0858 6.25 17.5C6.25 17.9142 6.58579 18.25 7 18.25H7.5C7.91421 18.25 8.25 17.9142 8.25 17.5C8.25 17.0858 7.91421 16.75 7.5 16.75H7ZM10.5 16.75C10.0858 16.75 9.75 17.0858 9.75 17.5C9.75 17.9142 10.0858 18.25 10.5 18.25H17C17.4142 18.25 17.75 17.9142 17.75 17.5C17.75 17.0858 17.4142 16.75 17 16.75H10.5Z"/>
</svg>
</a>
<div class="relative">
<label for="edit-button">
<svg
width="24"
height="24"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
/>
<path
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
/>
<path
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
/>
</svg>
</label>
<input type="checkbox" id="edit-button" class="hidden css-button"/>
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="absolute bottom-7 left-5 text-black bg-gray-200 transition-all duration-200 rounded shadow-inner shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<label class="font-medium" for="isbn">ISBN</label>
<input class="mt-1 mb-2 p-1 bg-gray-400 text-black dark:bg-gray-700 dark:text-white" type="text" id="isbn" name="ISBN"><br>
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Search Metadata</button>
</form>
</div>
{{ if .Data.Filepath }}
<a href="./{{.Data.ID}}/file">
<svg
width="24"
height="24"
class="hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
/>
</svg>
</a>
{{ else }}
<svg
width="24"
height="24"
class="text-gray-200 dark:text-gray-600"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
/>
</svg>
{{ end }}
</div>
</div>
<div class="flex flex-wrap justify-between gap-6 pb-6">
<div>
<p class="text-gray-400">Title</p>
<!-- <input type="text" class="font-medium bg-transparent" value="{{ or .Data.Title "Unknown" }}"/> -->
<p class="font-medium text-lg">
{{ or .Data.Title "N/A" }}
</p>
</div>
<div>
<p class="text-gray-400">Author</p>
<p class="font-medium text-lg">
{{ or .Data.Author "N/A" }}
</p>
</div>
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium text-lg">
{{ .Data.CurrentPage }} / {{ .Data.TotalPages }} ({{ .Data.Percentage }}%)
</p>
</div>
<div>
<p class="text-gray-400">Time Read</p>
<p class="font-medium text-lg">
{{ .Data.TotalTimeMinutes }} Minutes
</p>
</div>
</div>
<p class="text-gray-400">Description</p>
<p class="font-medium text-justify hyphens-auto">
{{ or .Data.Description "N/A" }}
</p>
</div>
<style>
.css-button:checked + form {
visibility: visible;
opacity: 1;
}
.css-button + form {
visibility: hidden;
opacity: 0;
}
</style>
{{end}}

View File

@ -7,10 +7,37 @@
{{end}}
{{define "content"}}
<div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white text-sm p-4">
<div class="flex flex-col gap-2 float-left mr-4">
<img class="rounded w-40 md:w-60 lg:w-80 object-fill h-full" src="../documents/{{.Data.ID}}/cover"></img>
<div class="flex gap-2 justify-end text-gray-500 dark:text-gray-400">
<div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4">
<div class="flex flex-col gap-2 float-left mr-4 mb-4 relative">
<label class="z-20 cursor-pointer" for="edit-cover-button">
<img class="rounded w-40 md:w-60 lg:w-80 object-fill h-full" src="../documents/{{.Data.ID}}/cover"></img>
</label>
<div class="flex relative z-40 gap-2 justify-end text-gray-500 dark:text-gray-400">
<input type="checkbox" id="edit-cover-button" class="hidden css-button"/>
<div class="absolute z-50 flex flex-col gap-2 top-0 left-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form
method="POST"
enctype="multipart/form-data"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
>
<input
type="file"
id="cover"
name="cover"
>
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Upload Cover</button>
</form>
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
>
<input type="checkbox" checked id="remove_cover" name="remove_cover" class="hidden" />
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Remove Cover</button>
</form>
</div>
<div class="relative">
<label for="delete-button">
<svg
@ -30,14 +57,16 @@
</svg>
</label>
<input type="checkbox" id="delete-button" class="hidden css-button"/>
<form
method="POST"
action="./{{ .Data.ID }}/delete"
class="absolute bottom-7 left-5 text-black bg-gray-200 transition-all duration-200 rounded shadow-inner shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<p class="font-medium w-24 pb-2">Are you sure?</p>
<button class="font-medium w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Delete</button>
</form>
<div class="absolute z-50 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form
method="POST"
action="./{{ .Data.ID }}/delete"
class="text-black dark:text-white text-sm"
>
<p class="font-medium w-24 pb-2">Are you sure?</p>
<button class="font-medium w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Delete</button>
</form>
</div>
</div>
<a href="../activity?document={{ .Data.ID }}">
<svg
@ -53,7 +82,7 @@
</svg>
</a>
<div class="relative">
<label for="metadata-button">
<label for="edit-button">
<svg
width="24"
height="24"
@ -63,22 +92,33 @@
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM12 7.75C11.3787 7.75 10.875 8.25368 10.875 8.875C10.875 9.28921 10.5392 9.625 10.125 9.625C9.71079 9.625 9.375 9.28921 9.375 8.875C9.375 7.42525 10.5503 6.25 12 6.25C13.4497 6.25 14.625 7.42525 14.625 8.875C14.625 9.58584 14.3415 10.232 13.883 10.704C13.7907 10.7989 13.7027 10.8869 13.6187 10.9708C13.4029 11.1864 13.2138 11.3753 13.0479 11.5885C12.8289 11.8699 12.75 12.0768 12.75 12.25V13C12.75 13.4142 12.4142 13.75 12 13.75C11.5858 13.75 11.25 13.4142 11.25 13V12.25C11.25 11.5948 11.555 11.0644 11.8642 10.6672C12.0929 10.3733 12.3804 10.0863 12.6138 9.85346C12.6842 9.78321 12.7496 9.71789 12.807 9.65877C13.0046 9.45543 13.125 9.18004 13.125 8.875C13.125 8.25368 12.6213 7.75 12 7.75ZM12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z"
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
/>
<path
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
/>
<path
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
/>
</svg>
</label>
<input type="checkbox" id="metadata-button" class="hidden css-button"/>
<form
method="POST"
action="./{{ .Data.ID }}/identify"
class="absolute bottom-7 left-5 text-black bg-gray-200 transition-all duration-200 rounded shadow-inner shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<label class="font-medium" for="isbn">ISBN</label>
<input class="mt-1 mb-2 p-1 bg-gray-400 text-black dark:bg-gray-700 dark:text-white" type="text" id="isbn" name="isbn"><br>
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Search Metadata</button>
</form>
<input type="checkbox" id="edit-button" class="hidden css-button"/>
<div class="absolute z-50 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="text-black dark:text-white text-sm"
>
<label class="font-medium" for="isbn">ISBN</label>
<input
type="text"
id="isbn"
name="ISBN"
class="mt-1 mb-2 p-2 bg-gray-400 text-black dark:bg-gray-700 dark:text-white"
><br>
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Search Metadata</button>
</form>
</div>
</div>
{{ if .Data.Filepath }}
<a href="./{{.Data.ID}}/file">
@ -116,44 +156,180 @@
</div>
</div>
<div class="flex flex-wrap justify-between gap-4 pb-4">
<div>
<p class="text-gray-400">Title</p>
<!-- <input type="text" class="font-medium bg-transparent" value="{{ or .Data.Title "Unknown" }}"/> -->
<p class="font-medium">
{{ or .Data.Title "N/A" }}
</p>
<div class="relative">
<div class="text-gray-400 inline-flex gap-2 relative">
<p>Title</p>
<label class="my-auto" for="edit-title-button">
<svg
width="18"
height="18"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
/>
<path
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
/>
<path
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
/>
</svg>
</label>
<input type="checkbox" id="edit-title-button" class="hidden css-button"/>
<div class="absolute z-50 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 text-black dark:text-white text-sm"
>
<input
type="text"
id="title"
name="title"
value="{{ or .Data.Title "N/A" }}"
class="p-2 bg-gray-400 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 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Save</button>
</form>
</div>
</div>
<p class="font-medium text-lg">
{{ or .Data.Title "N/A" }}
</p>
</div>
<div class="relative">
<div class="text-gray-400 inline-flex gap-2 relative">
<p>Author</p>
<label class="my-auto" for="edit-author-button">
<svg
width="18"
height="18"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
/>
<path
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
/>
<path
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
/>
</svg>
</label>
<input type="checkbox" id="edit-author-button" class="hidden css-button"/>
<div class="absolute z-50 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-400 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 dark:bg-gray-400 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>
<p class="text-gray-400">Author</p>
<p class="font-medium">
{{ or .Data.Author "N/A" }}
<p class="text-gray-400">Time Read</p>
<p class="font-medium text-lg">
{{ .Data.TotalTimeMinutes }} Minutes
</p>
</div>
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium">
<p class="font-medium text-lg">
{{ .Data.CurrentPage }} / {{ .Data.TotalPages }} ({{ .Data.Percentage }}%)
</p>
</div>
<!--
<div>
<p class="text-gray-400">Minutes Read</p>
<p class="font-medium">
{{ .Data.TotalTimeMinutes }} Minutes
<p class="text-gray-400">ISBN 10</p>
<p class="font-medium text-lg">
{{ or .Data.Isbn10 "N/A" }}
</p>
</div>
<div>
<p class="text-gray-400">ISBN 13</p>
<p class="font-medium text-lg">
{{ or .Data.Isbn13 "N/A" }}
</p>
</div>
-->
</div>
<div class="relative">
<div class="text-gray-400 inline-flex gap-2 relative">
<p>Description</p>
<label class="my-auto" for="edit-description-button">
<svg
width="18"
height="18"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
/>
<path
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
/>
<path
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
/>
</svg>
</label>
</div>
</div>
<div class="relative font-medium text-justify hyphens-auto">
<input type="checkbox" id="edit-description-button" class="hidden css-button"/>
<div
class="absolute h-full w-full min-h-[10em] z-50 top-1 right-0 gap-6 flex transition-all duration-200"
>
<img class="hidden md:block invisible rounded w-40 md:w-60 lg:w-80 object-fill" src="../documents/{{.Data.ID}}/cover"></img>
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-full text-black bg-gray-200 rounded shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<textarea
type="text"
id="description"
name="description"
class="h-full w-full p-2 bg-gray-400 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 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Save</button>
</form>
</div>
<p>{{ or .Data.Description "N/A" }}</p>
</div>
<p class="text-gray-400">Description</p>
<p class="font-medium text-justify hyphens-auto">
{{ or .Data.Description "N/A" }}
</p>
</div>
<style>
.css-button:checked + form {
.css-button:checked + div {
visibility: visible;
opacity: 1;
}
.css-button + form {
.css-button + div {
visibility: hidden;
opacity: 0;
}

View File

@ -43,7 +43,7 @@
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Minutes Read</p>
<p class="text-gray-400">Time Read</p>
<p class="font-medium">
{{ $doc.TotalTimeMinutes }} Minutes
</p>