[add] document view, [add] html sanitization, [add] google books metadata enrichment, [improve] db query performance

This commit is contained in:
Evan Reichard 2023-09-22 22:12:36 -04:00
parent c1f463f0b9
commit 3150c89303
24 changed files with 750 additions and 456 deletions

View File

@ -40,6 +40,7 @@ In additional to the compatible KOSync API's, we add:
| DATA_PATH | /data | Directory where to store the documents and cover metadata | | DATA_PATH | /data | Directory where to store the documents and cover metadata |
| LISTEN_PORT | 8585 | Port the server listens at | | LISTEN_PORT | 8585 | Port the server listens at |
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) | | REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
# Client (KOReader Plugin) # Client (KOReader Plugin)

View File

@ -9,31 +9,44 @@ import (
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday"
log "github.com/sirupsen/logrus"
"reichard.io/bbank/config" "reichard.io/bbank/config"
"reichard.io/bbank/database" "reichard.io/bbank/database"
"reichard.io/bbank/graph" "reichard.io/bbank/graph"
) )
type API struct { type API struct {
Router *gin.Engine Router *gin.Engine
Config *config.Config Config *config.Config
DB *database.DBManager DB *database.DBManager
HTMLPolicy *bluemonday.Policy
} }
func NewApi(db *database.DBManager, c *config.Config) *API { func NewApi(db *database.DBManager, c *config.Config) *API {
api := &API{ api := &API{
Router: gin.Default(), HTMLPolicy: bluemonday.StripTagsPolicy(),
Config: c, Router: gin.Default(),
DB: db, Config: c,
DB: db,
} }
// Assets & Web App Templates // Assets & Web App Templates
api.Router.Static("/assets", "./assets") api.Router.Static("/assets", "./assets")
// Generate Secure Token // Generate Secure Token
newToken, err := generateToken(64) var newToken []byte
if err != nil { var err error
panic("Unable to generate secure token")
if c.CookieSessionKey != "" {
log.Info("[NewApi] Utilizing Environment Cookie Session Key")
newToken = []byte(c.CookieSessionKey)
} else {
log.Info("[NewApi] Generating Cookie Session Key")
newToken, err = generateToken(64)
if err != nil {
panic("Unable to generate secure token")
}
} }
// Configure Cookie Session Store // Configure Cookie Session Store
@ -69,6 +82,7 @@ func (api *API) registerWebAppRoutes() {
render.AddFromFilesFuncs("graphs", helperFuncs, "templates/base.html", "templates/graphs.html") render.AddFromFilesFuncs("graphs", helperFuncs, "templates/base.html", "templates/graphs.html")
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html") render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html") render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html")
render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html")
api.Router.HTMLRender = render api.Router.HTMLRender = render
@ -80,8 +94,9 @@ func (api *API) registerWebAppRoutes() {
api.Router.POST("/register", api.authFormRegister) api.Router.POST("/register", api.authFormRegister)
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home")) api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity")) api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile) api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile)
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover) api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -64,12 +65,38 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
} }
templateVars["Data"] = documents templateVars["Data"] = documents
} else if routeName == "document" {
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
}
document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{
UserID: rUser.(string),
DocumentID: rDocID.DocumentID,
})
if err != nil {
log.Error("[createAppResourcesRoute] GetDocumentWithStats DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
templateVars["Data"] = document
} else if routeName == "activity" { } else if routeName == "activity" {
activity, err := api.DB.Queries.GetActivity(api.DB.Ctx, database.GetActivityParams{ activityFilter := database.GetActivityParams{
UserID: rUser.(string), UserID: rUser.(string),
Offset: (*qParams.Page - 1) * *qParams.Limit, Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) }
if qParams.Document != nil {
activityFilter.DocFilter = true
activityFilter.DocumentID = *qParams.Document
}
activity, err := api.DB.Queries.GetActivity(api.DB.Ctx, activityFilter)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetActivity DB Error:", err) log.Error("[createAppResourcesRoute] GetActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
@ -78,6 +105,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
templateVars["Data"] = activity templateVars["Data"] = activity
} else if routeName == "home" { } else if routeName == "home" {
start_time := time.Now()
weekly_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ weekly_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
UserID: rUser.(string), UserID: rUser.(string),
Window: "WEEK", Window: "WEEK",
@ -85,6 +113,8 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
if err != nil { if err != nil {
log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err) log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err)
} }
log.Info("GetUserWindowStreaks - WEEK - ", time.Since(start_time))
start_time = time.Now()
daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
UserID: rUser.(string), UserID: rUser.(string),
@ -93,9 +123,15 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
if err != nil { if err != nil {
log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err) log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err)
} }
log.Info("GetUserWindowStreaks - DAY - ", time.Since(start_time))
start_time = time.Now()
database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string)) database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string))
log.Info("GetDatabaseInfo - ", time.Since(start_time))
start_time = time.Now()
read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string)) read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string))
log.Info("GetDailyReadStats - ", time.Since(start_time))
templateVars["Data"] = gin.H{ templateVars["Data"] = gin.H{
"DailyStreak": daily_streak, "DailyStreak": daily_streak,
@ -156,17 +192,39 @@ func (api *API) getDocumentCover(c *gin.Context) {
*/ */
var coverID string = "UNKNOWN" var coverID string = "UNKNOWN"
var coverFilePath *string var coverFilePath string
// Identify Documents & Save Covers // Identify Documents & Save Covers
coverIDs, err := metadata.GetCoverIDs(document.Title, document.Author) bookMetadata := metadata.MetadataInfo{
if err == nil && len(coverIDs) > 0 { Title: document.Title,
coverFilePath, err = metadata.DownloadAndSaveCover(coverIDs[0], api.Config.DataPath) 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)
err := metadata.SaveCover(*bookMetadata.GBID, coverFilePath)
if err == nil { if err == nil {
coverID = coverIDs[0] coverID = *bookMetadata.GBID
log.Info("Title:", *bookMetadata.Title)
log.Info("Author:", *bookMetadata.Author)
log.Info("Description:", *bookMetadata.Description)
log.Info("IDs:", bookMetadata.ISBN)
} }
} }
// 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 // Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: document.ID, ID: document.ID,
@ -181,5 +239,5 @@ func (api *API) getDocumentCover(c *gin.Context) {
return return
} }
c.File(*coverFilePath) c.File(coverFilePath)
} }

View File

@ -335,12 +335,12 @@ func (api *API) addDocuments(c *gin.Context) {
for _, doc := range rNewDocs.Documents { for _, doc := range rNewDocs.Documents {
doc, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ doc, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: doc.ID, ID: doc.ID,
Title: doc.Title, Title: api.sanitizeInput(doc.Title),
Author: doc.Author, Author: api.sanitizeInput(doc.Author),
Series: doc.Series, Series: api.sanitizeInput(doc.Series),
SeriesIndex: doc.SeriesIndex, SeriesIndex: doc.SeriesIndex,
Lang: doc.Lang, Lang: api.sanitizeInput(doc.Lang),
Description: doc.Description, Description: api.sanitizeInput(doc.Description),
}) })
if err != nil { if err != nil {
log.Error("[addDocuments] UpsertDocument DB Error:", err) log.Error("[addDocuments] UpsertDocument DB Error:", err)
@ -605,6 +605,22 @@ func (api *API) downloadDocumentFile(c *gin.Context) {
c.File(filePath) c.File(filePath)
} }
func (api *API) sanitizeInput(val any) *string {
switch v := val.(type) {
case *string:
if v != nil {
newString := api.HTMLPolicy.Sanitize(string(*v))
return &newString
}
case string:
if v != "" {
newString := api.HTMLPolicy.Sanitize(string(v))
return &newString
}
}
return nil
}
func getKeys[M ~map[K]V, K comparable, V any](m M) []K { func getKeys[M ~map[K]V, K comparable, V any](m M) []K {
r := make([]K, 0, len(m)) r := make([]K, 0, len(m))
for k := range m { for k := range m {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

View File

@ -21,6 +21,7 @@ type Config struct {
// Miscellaneous Settings // Miscellaneous Settings
RegistrationEnabled bool RegistrationEnabled bool
CookieSessionKey string
} }
func Load() *Config { func Load() *Config {
@ -32,6 +33,7 @@ func Load() *Config {
ConfigPath: getEnv("CONFIG_PATH", "/config"), ConfigPath: getEnv("CONFIG_PATH", "/config"),
DataPath: getEnv("DATA_PATH", "/data"), DataPath: getEnv("DATA_PATH", "/data"),
ListenPort: getEnv("LISTEN_PORT", "8585"), ListenPort: getEnv("LISTEN_PORT", "8585"),
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true", RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
} }
} }

View File

@ -154,6 +154,43 @@ ORDER BY created_at DESC
LIMIT $limit LIMIT $limit
OFFSET $offset; OFFSET $offset;
-- name: GetDocumentWithStats :one
WITH true_progress AS (
SELECT
start_time AS last_read,
SUM(duration) / 60 AS total_time_minutes,
document_id,
current_page,
total_pages,
ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage
FROM activity
WHERE user_id = $user_id
AND document_id = $document_id
GROUP BY document_id
HAVING MAX(start_time)
LIMIT 1
)
SELECT
documents.*,
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes,
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
CAST(CASE
WHEN percentage > 97.0 THEN 100.0
WHEN percentage IS NULL THEN 0.0
ELSE percentage
END AS REAL) AS percentage
FROM documents
LEFT JOIN true_progress ON true_progress.document_id = documents.id
LEFT JOIN users ON users.id = $user_id
WHERE documents.id = $document_id
ORDER BY true_progress.last_read DESC, documents.created_at DESC
LIMIT 1;
-- name: GetDocumentsWithStats :many -- name: GetDocumentsWithStats :many
WITH true_progress AS ( WITH true_progress AS (
SELECT SELECT
@ -272,7 +309,7 @@ FROM document_days;
WITH document_windows AS ( WITH document_windows AS (
SELECT SELECT
CASE CASE
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, time_offset, 'weekday 0', '-7 day') WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset) WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window, END AS read_window,
time_offset time_offset
@ -281,7 +318,6 @@ WITH document_windows AS (
WHERE user_id = $user_id WHERE user_id = $user_id
AND CAST($window AS TEXT) = CAST($window AS TEXT) AND CAST($window AS TEXT) = CAST($window AS TEXT)
GROUP BY read_window GROUP BY read_window
ORDER BY read_window DESC
), ),
partitions AS ( partitions AS (
SELECT SELECT
@ -312,6 +348,7 @@ max_streak AS (
start_date AS max_streak_start_date, start_date AS max_streak_start_date,
end_date AS max_streak_end_date end_date AS max_streak_end_date
FROM streaks FROM streaks
LIMIT 1
), ),
current_streak AS ( current_streak AS (
SELECT SELECT
@ -320,7 +357,7 @@ current_streak AS (
end_date AS current_streak_end_date end_date AS current_streak_end_date
FROM streaks FROM streaks
WHERE CASE WHERE CASE
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', 'now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date WHEN ?2 = "WEEK" THEN DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date
WHEN ?2 = "DAY" THEN DATE('now', time_offset, '-1 day') = current_streak_end_date OR DATE('now', time_offset) = current_streak_end_date WHEN ?2 = "DAY" THEN DATE('now', time_offset, '-1 day') = current_streak_end_date OR DATE('now', time_offset) = current_streak_end_date
END END
LIMIT 1 LIMIT 1
@ -360,6 +397,7 @@ activity_records AS (
FROM activity FROM activity
LEFT JOIN users ON users.id = activity.user_id LEFT JOIN users ON users.id = activity.user_id
WHERE user_id = $user_id WHERE user_id = $user_id
AND start_time > DATE('now', '-31 days')
GROUP BY day GROUP BY day
ORDER BY day DESC ORDER BY day DESC
LIMIT 30 LIMIT 30

View File

@ -190,6 +190,7 @@ activity_records AS (
FROM activity FROM activity
LEFT JOIN users ON users.id = activity.user_id LEFT JOIN users ON users.id = activity.user_id
WHERE user_id = ?1 WHERE user_id = ?1
AND start_time > DATE('now', '-31 days')
GROUP BY day GROUP BY day
ORDER BY day DESC ORDER BY day DESC
LIMIT 30 LIMIT 30
@ -483,6 +484,98 @@ func (q *Queries) GetDocumentReadStatsCapped(ctx context.Context, arg GetDocumen
return i, err return i, err
} }
const getDocumentWithStats = `-- name: GetDocumentWithStats :one
WITH true_progress AS (
SELECT
start_time AS last_read,
SUM(duration) / 60 AS total_time_minutes,
document_id,
current_page,
total_pages,
ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage
FROM activity
WHERE user_id = ?1
AND document_id = ?2
GROUP BY document_id
HAVING MAX(start_time)
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,
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes,
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
CAST(CASE
WHEN percentage > 97.0 THEN 100.0
WHEN percentage IS NULL THEN 0.0
ELSE percentage
END AS REAL) AS percentage
FROM documents
LEFT JOIN true_progress ON true_progress.document_id = documents.id
LEFT JOIN users ON users.id = ?1
WHERE documents.id = ?2
ORDER BY true_progress.last_read DESC, documents.created_at DESC
LIMIT 1
`
type GetDocumentWithStatsParams struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
}
type GetDocumentWithStatsRow struct {
ID string `json:"id"`
Md5 *string `json:"md5"`
Filepath *string `json:"filepath"`
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"`
Olid *string `json:"-"`
Synced bool `json:"-"`
Deleted bool `json:"-"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
CurrentPage int64 `json:"current_page"`
TotalPages int64 `json:"total_pages"`
TotalTimeMinutes int64 `json:"total_time_minutes"`
LastRead string `json:"last_read"`
Percentage float64 `json:"percentage"`
}
func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithStatsParams) (GetDocumentWithStatsRow, error) {
row := q.db.QueryRowContext(ctx, getDocumentWithStats, arg.UserID, arg.DocumentID)
var i GetDocumentWithStatsRow
err := row.Scan(
&i.ID,
&i.Md5,
&i.Filepath,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Olid,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
&i.CreatedAt,
&i.CurrentPage,
&i.TotalPages,
&i.TotalTimeMinutes,
&i.LastRead,
&i.Percentage,
)
return i, err
}
const getDocuments = `-- name: GetDocuments :many 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, title, author, series, series_index, lang, description, olid, synced, deleted, updated_at, created_at FROM documents
ORDER BY created_at DESC ORDER BY created_at DESC
@ -783,7 +876,7 @@ const getUserWindowStreaks = `-- name: GetUserWindowStreaks :one
WITH document_windows AS ( WITH document_windows AS (
SELECT SELECT
CASE CASE
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, time_offset, 'weekday 0', '-7 day') WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset) WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window, END AS read_window,
time_offset time_offset
@ -792,7 +885,6 @@ WITH document_windows AS (
WHERE user_id = ?1 WHERE user_id = ?1
AND CAST(?2 AS TEXT) = CAST(?2 AS TEXT) AND CAST(?2 AS TEXT) = CAST(?2 AS TEXT)
GROUP BY read_window GROUP BY read_window
ORDER BY read_window DESC
), ),
partitions AS ( partitions AS (
SELECT SELECT
@ -823,6 +915,7 @@ max_streak AS (
start_date AS max_streak_start_date, start_date AS max_streak_start_date,
end_date AS max_streak_end_date end_date AS max_streak_end_date
FROM streaks FROM streaks
LIMIT 1
), ),
current_streak AS ( current_streak AS (
SELECT SELECT
@ -831,7 +924,7 @@ current_streak AS (
end_date AS current_streak_end_date end_date AS current_streak_end_date
FROM streaks FROM streaks
WHERE CASE WHERE CASE
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', 'now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date WHEN ?2 = "WEEK" THEN DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date
WHEN ?2 = "DAY" THEN DATE('now', time_offset, '-1 day') = current_streak_end_date OR DATE('now', time_offset) = current_streak_end_date WHEN ?2 = "DAY" THEN DATE('now', time_offset, '-1 day') = current_streak_end_date OR DATE('now', time_offset) = current_streak_end_date
END END
LIMIT 1 LIMIT 1

View File

@ -1,4 +1,5 @@
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
-- Authentication -- Authentication
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (

3
go.mod
View File

@ -9,12 +9,14 @@ require (
github.com/gin-contrib/sessions v0.0.4 github.com/gin-contrib/sessions v0.0.4
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/mattn/go-sqlite3 v1.14.17 github.com/mattn/go-sqlite3 v1.14.17
github.com/microcosm-cc/bluemonday v1.0.25
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/urfave/cli/v2 v2.25.7 github.com/urfave/cli/v2 v2.25.7
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 golang.org/x/exp v0.0.0-20230905200255-921286631fa9
) )
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/sonic v1.10.0 // indirect github.com/bytedance/sonic v1.10.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect github.com/chenzhuoyu/iasm v0.9.0 // indirect
@ -25,6 +27,7 @@ require (
github.com/go-playground/validator/v10 v10.15.3 // indirect github.com/go-playground/validator/v10 v10.15.3 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect

6
go.sum
View File

@ -1,6 +1,8 @@
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 h1:qZaEtLxnqY5mJ0fVKbk31NVhlgi0yrKm51Pq/I5wcz4= github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 h1:qZaEtLxnqY5mJ0fVKbk31NVhlgi0yrKm51Pq/I5wcz4=
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736/go.mod h1:mTeFRcTdnpzOlRjMoFYC/80HwVUreupyAiqPkCZQOXc= github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736/go.mod h1:mTeFRcTdnpzOlRjMoFYC/80HwVUreupyAiqPkCZQOXc=
github.com/antonlindstrom/pgstore v0.0.0-20200229204646-b08ebf1105e0/go.mod h1:2Ti6VUHVxpC0VSmTZzEvpzysnaGAfGBOoMIz5ykPyyw= github.com/antonlindstrom/pgstore v0.0.0-20200229204646-b08ebf1105e0/go.mod h1:2Ti6VUHVxpC0VSmTZzEvpzysnaGAfGBOoMIz5ykPyyw=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI= github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
@ -58,6 +60,8 @@ github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
@ -92,6 +96,8 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc= github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

View File

@ -48,7 +48,9 @@ func cmdServer(ctx *cli.Context) error {
signal.Notify(c, os.Interrupt) signal.Notify(c, os.Interrupt)
<-c <-c
log.Info("Stopping Server")
server.StopServer() server.StopServer()
log.Info("Server Stopped")
os.Exit(0) os.Exit(0)
return nil return nil

View File

@ -8,97 +8,167 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
type coverResult struct { type MetadataInfo struct {
CoverEditionKey string `json:"cover_edition_key"` Title *string
Author *string
Description *string
GBID *string
ISBN []*string
} }
type queryResponse struct { type gBooksIdentifiers struct {
ResultCount int `json:"numFound"` Type string `json:"type"`
Start int `json:"start"` Identifier string `json:"identifier"`
ResultCountExact bool `json:"numFoundExact"`
Results []coverResult `json:"docs"`
} }
var BASE_QUERY_URL string = "https://openlibrary.org/search.json?q=%s&fields=cover_edition_key" type gBooksInfo struct {
var BASE_COVER_URL string = "https://covers.openlibrary.org/b/olid/%s-L.jpg" Title string `json:"title"`
Authors []string `json:"authors"`
Description string `json:"description"`
Identifiers []gBooksIdentifiers `json:"industryIdentifiers"`
}
func GetCoverIDs(title *string, author *string) ([]string, error) { type gBooksQueryItem struct {
if title == nil || author == nil { ID string `json:"id"`
log.Error("[metadata] Invalid Search Query") Info gBooksInfo `json:"volumeInfo"`
return nil, errors.New("Invalid Query") }
}
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *title, *author)) type gBooksQueryResponse struct {
apiQuery := fmt.Sprintf(BASE_QUERY_URL, searchQuery) TotalItems int `json:"totalItems"`
Items []gBooksQueryItem `json:"items"`
}
log.Info("[metadata] Acquiring CoverID") const GBOOKS_QUERY_URL string = "https://www.googleapis.com/books/v1/volumes?q=%s&filter=ebooks&download=epub"
resp, err := http.Get(apiQuery) const GBOOKS_GBID_INFO_URL string = "https://www.googleapis.com/books/v1/volumes/%s"
if err != nil { const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690"
log.Error("[metadata] Cover URL API Failure")
return nil, errors.New("API Failure")
}
target := queryResponse{} func GetMetadata(data *MetadataInfo) error {
err = json.NewDecoder(resp.Body).Decode(&target) var queryResult *gBooksQueryItem
if err != nil { if data.GBID != nil {
log.Error("[metadata] Cover URL API Decode Failure") // Use GBID
return nil, errors.New("API Failure") resp, err := performGBIDRequest(*data.GBID)
} if err != nil {
return err
var coverIDs []string
for _, result := range target.Results {
if result.CoverEditionKey != "" {
coverIDs = append(coverIDs, result.CoverEditionKey)
} }
queryResult = resp
} else if len(data.ISBN) > 0 {
searchQuery := "isbn:" + *data.ISBN[0]
resp, err := performSearchRequest(searchQuery)
if err != nil {
return err
}
queryResult = &resp.Items[0]
} else if data.Title != nil && data.Author != nil {
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *data.Title, *data.Author))
resp, err := performSearchRequest(searchQuery)
if err != nil {
return err
}
queryResult = &resp.Items[0]
} else {
return errors.New("Invalid Data")
} }
return coverIDs, nil // 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)
}
}
return nil
} }
func DownloadAndSaveCover(coverID string, dirPath string) (*string, error) { func SaveCover(id string, safePath string) error {
// Derive & Sanitize File Name
fileName := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", coverID))
// Generate Storage Path
safePath := filepath.Join(dirPath, "covers", fileName)
// Validate File Doesn't Exists // Validate File Doesn't Exists
_, err := os.Stat(safePath) _, err := os.Stat(safePath)
if err == nil { if err == nil {
log.Warn("[metadata] File Alreads Exists") log.Warn("[SaveCover] File Alreads Exists")
return &safePath, nil return nil
} }
// Create File // Create File
out, err := os.Create(safePath) out, err := os.Create(safePath)
if err != nil { if err != nil {
log.Error("[metadata] File Create Error") log.Error("[SaveCover] File Create Error")
return nil, errors.New("File Failure") return errors.New("File Failure")
} }
defer out.Close() defer out.Close()
// Download File // Download File
log.Info("[metadata] Downloading Cover") log.Info("[SaveCover] Downloading Cover")
coverURL := fmt.Sprintf(BASE_COVER_URL, coverID) coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, id)
resp, err := http.Get(coverURL) resp, err := http.Get(coverURL)
if err != nil { if err != nil {
log.Error("[metadata] Cover URL API Failure") log.Error("[SaveCover] Cover URL API Failure")
return nil, errors.New("API Failure") return errors.New("API Failure")
} }
defer resp.Body.Close() defer resp.Body.Close()
// Copy File to Disk // Copy File to Disk
log.Info("[SaveCover] Saving Cover")
_, err = io.Copy(out, resp.Body) _, err = io.Copy(out, resp.Body)
if err != nil { if err != nil {
log.Error("[metadata] File Copy Error") log.Error("[SaveCover] File Copy Error")
return nil, errors.New("File Failure") return errors.New("File Failure")
} }
// Return FilePath // Return FilePath
return &safePath, nil return nil
}
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
log.Info("[performSearchRequest] Acquiring CoverID")
resp, err := http.Get(apiQuery)
if err != nil {
log.Error("[performSearchRequest] Cover URL API Failure")
return nil, errors.New("API Failure")
}
parsedResp := gBooksQueryResponse{}
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
if err != nil {
log.Error("[performSearchRequest] Google Books Query API Decode Failure")
return nil, errors.New("API Failure")
}
if len(parsedResp.Items) == 0 {
log.Warn("[performSearchRequest] No Results")
return nil, errors.New("No Results")
}
return &parsedResp, nil
}
func performGBIDRequest(id string) (*gBooksQueryItem, error) {
apiQuery := fmt.Sprintf(GBOOKS_GBID_INFO_URL, id)
log.Info("[performGBIDRequest] Acquiring CoverID")
resp, err := http.Get(apiQuery)
if err != nil {
log.Error("[performGBIDRequest] Cover URL API Failure")
return nil, errors.New("API Failure")
}
parsedResp := gBooksQueryItem{}
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
if err != nil {
log.Error("[performGBIDRequest] Google Books ID API Decode Failure")
return nil, errors.New("API Failure")
}
return &parsedResp, nil
} }

107
metadata/olib.go Normal file
View File

@ -0,0 +1,107 @@
package metadata
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
)
type oLibCoverResult struct {
CoverEditionKey string `json:"cover_edition_key"`
}
type oLibQueryResponse struct {
ResultCount int `json:"numFound"`
Start int `json:"start"`
ResultCountExact bool `json:"numFoundExact"`
Results []oLibCoverResult `json:"docs"`
}
const OLIB_QUERY_URL string = "https://openlibrary.org/search.json?q=%s&fields=cover_edition_key"
const OLIB_OLID_COVER_URL string = "https://covers.openlibrary.org/b/olid/%s-L.jpg"
const OLIB_ISBN_COVER_URL string = "https://covers.openlibrary.org/b/isbn/%s-L.jpg"
const OLIB_OLID_LINK_URL string = "https://openlibrary.org/books/%s"
const OLIB_ISBN_LINK_URL string = "https://openlibrary.org/isbn/%s"
func GetCoverOLIDs(title *string, author *string) ([]string, error) {
if title == nil || author == nil {
log.Error("[metadata] Invalid Search Query")
return nil, errors.New("Invalid Query")
}
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *title, *author))
apiQuery := fmt.Sprintf(OLIB_QUERY_URL, searchQuery)
log.Info("[metadata] Acquiring CoverID")
resp, err := http.Get(apiQuery)
if err != nil {
log.Error("[metadata] Cover URL API Failure")
return nil, errors.New("API Failure")
}
target := oLibQueryResponse{}
err = json.NewDecoder(resp.Body).Decode(&target)
if err != nil {
log.Error("[metadata] Cover URL API Decode Failure")
return nil, errors.New("API Failure")
}
var coverIDs []string
for _, result := range target.Results {
if result.CoverEditionKey != "" {
coverIDs = append(coverIDs, result.CoverEditionKey)
}
}
return coverIDs, nil
}
func DownloadAndSaveCover(coverID string, dirPath string) (*string, error) {
// Derive & Sanitize File Name
fileName := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", coverID))
// Generate Storage Path
safePath := filepath.Join(dirPath, "covers", fileName)
// Validate File Doesn't Exists
_, err := os.Stat(safePath)
if err == nil {
log.Warn("[metadata] File Alreads Exists")
return &safePath, nil
}
// Create File
out, err := os.Create(safePath)
if err != nil {
log.Error("[metadata] File Create Error")
return nil, errors.New("File Failure")
}
defer out.Close()
// Download File
log.Info("[metadata] Downloading Cover")
coverURL := fmt.Sprintf(OLIB_OLID_COVER_URL, coverID)
resp, err := http.Get(coverURL)
if err != nil {
log.Error("[metadata] Cover URL API Failure")
return nil, errors.New("API Failure")
}
defer resp.Body.Close()
// Copy File to Disk
_, err = io.Copy(out, resp.Body)
if err != nil {
log.Error("[metadata] File Copy Error")
return nil, errors.New("File Failure")
}
// Return FilePath
return &safePath, nil
}

View File

@ -59,4 +59,5 @@ func (s *Server) StopServer() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
s.httpServer.Shutdown(ctx) s.httpServer.Shutdown(ctx)
s.API.DB.DB.Close()
} }

View File

@ -1,9 +1,9 @@
{{template "base.html" .}} {{define "title"}}Activity{{end}} {{define {{template "base.html" .}} {{define "title"}}Activity{{end}} {{define "header"}}
"content"}} <a href="./activity">Activity</a>
{{end}} {{define "content"}}
<div class="px-4 -mx-4 overflow-x-auto"> <div class="px-4 -mx-4 overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow"> <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-xs"> <table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm">
<thead class="text-gray-800 dark:text-gray-400"> <thead class="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th <th
@ -36,7 +36,7 @@
{{range $activity := .Data }} {{range $activity := .Data }}
<tr> <tr>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $activity.Author }} - {{ $activity.Title }}</p> <a href="./documents/{{ $activity.DocumentID }}">{{ $activity.Author }} - {{ $activity.Title }}</p></a>
</td> </td>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $activity.StartTime }}</p> <p>{{ $activity.StartTime }}</p>

View File

@ -5,7 +5,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<title>{{block "title" .}}{{end}}</title> <title>Book Manager - {{block "title" .}}{{end}}</title>
</head> </head>
<body> <body>
<main <main
@ -39,7 +39,7 @@
<nav class="mt-6"> <nav class="mt-6">
<div> <div>
<a <a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "home"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800{{end}}" class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "home"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/" href="/"
> >
<span class="text-left"> <span class="text-left">
@ -56,7 +56,7 @@
<span class="mx-4 text-sm font-normal"> Home </span> <span class="mx-4 text-sm font-normal"> Home </span>
</a> </a>
<a <a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "documents"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800{{end}}" class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "documents"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/documents" href="/documents"
> >
<span class="text-left"> <span class="text-left">
@ -74,7 +74,7 @@
<span class="mx-4 text-sm font-normal"> Documents </span> <span class="mx-4 text-sm font-normal"> Documents </span>
</a> </a>
<a <a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "activity"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800{{end}}" class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "activity"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/activity" href="/activity"
> >
<span class="text-left"> <span class="text-left">
@ -92,7 +92,7 @@
<span class="mx-4 text-sm font-normal"> Activity </span> <span class="mx-4 text-sm font-normal"> Activity </span>
</a> </a>
<a <a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "graphs"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800{{end}}" class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "graphs"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/graphs" href="/graphs"
> >
<span class="text-left"> <span class="text-left">
@ -138,7 +138,7 @@
</svg> </svg>
</label> </label>
</div> </div>
<h1 class="text-xl font-bold dark:text-white px-6">{{block "title" .}}{{end}}</h1> <h1 class="text-xl font-bold dark:text-white px-6">{{block "header" .}}{{end}}</h1>
<div <div
class="relative flex items-center justify-end w-full p-4 space-x-4" class="relative flex items-center justify-end w-full p-4 space-x-4"
> >

View File

@ -1,182 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<script src="https://cdn.tailwindcss.com"></script>
<title>{{block "title" .}}{{end}}</title>
</head>
<body>
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="px-8 mx-auto max-w-7xl">
<div class="flex items-center justify-between h-16">
<div class="flex items-center">
<div class="hidden md:block">
<div class="flex items-baseline space-x-4">
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/"
>
Home
</a>
<a
class="text-gray-800 dark:text-white hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/documents"
>
Documents
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/activity"
>
Activity
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/graphs"
>
Graphs
</a>
</div>
</div>
</div>
<div class="block">
<div class="flex items-center ml-4 md:ml-6">
<div class="relative ml-3">
<div
class="custom-profile-icon relative inline-block text-left"
>
<div>
<button
type="button"
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500"
id="options-menu"
>
<svg
width="20"
fill="currentColor"
height="20"
class="text-gray-800 dark:text-gray-200"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"
/>
</svg>
</button>
</div>
</div>
<div
class="custom-profile-dropdown transition duration-200 absolute right-0 w-56 pt-2 origin-top-right bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-black ring-opacity-5"
>
<div
class="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<a
href="#"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span> Settings </span>
</span>
</a>
<a
href="#"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span> Account </span>
</span>
</a>
<a
href="#"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span> Logout </span>
</span>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="flex -mr-2 md:hidden">
<button
class="text-gray-800 dark:text-white hover:text-gray-300 inline-flex items-center justify-center p-2 rounded-md focus:outline-none"
>
<svg
width="20"
height="20"
fill="currentColor"
class="w-8 h-8"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1664 1344v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45z"
/>
</svg>
</button>
</div>
</div>
</div>
<div class="md:hidden">
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Home
</a>
<a
class="text-gray-800 dark:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Gallery
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Content
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Contact
</a>
</div>
</div>
</nav>
<!-- Custom Animation CSS -->
<style>
.custom-profile-dropdown {
visibility: hidden;
opacity: 0;
}
.custom-profile-icon:hover + .custom-profile-dropdown,
.custom-profile-dropdown:hover {
visibility: visible;
opacity: 1;
}
</style>
<div
class="bg-white dark:text-gray-200 dark:bg-gray-800 shadow m-5 p-5 rounded"
>
<div class="flex items-center justify-between h-16">
{{block "content" .}}{{end}}
</div>
</div>
</body>
</html>

161
templates/document.html Normal file
View File

@ -0,0 +1,161 @@
{{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 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="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="metadata-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
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"
/>
</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>
</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-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>
<div>
<p class="text-gray-400">Author</p>
<p class="font-medium">
{{ or .Data.Author "N/A" }}
</p>
</div>
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium">
{{ .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>
</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

@ -1,15 +1,22 @@
{{template "base.html" .}} {{define "title"}}Documents{{end}} {{define {{template "base.html" .}}
"content"}}
{{define "title"}}Documents{{end}}
{{define "header"}}
<a href="./documents">Documents</a>
{{end}}
{{define "content"}}
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-3">
{{range $doc := .Data }} {{range $doc := .Data }}
<div class="w-full"> <div class="w-full relative">
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> <div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
<div class="min-w-fit h-48 relative"> <div class="min-w-fit h-48 relative">
<a href="./documents/{{$doc.ID}}/file"> <a href="./documents/{{$doc.ID}}">
<img class="rounded object-cover h-full" src="./documents/{{$doc.ID}}/cover"></img> <img class="rounded object-cover h-full" src="./documents/{{$doc.ID}}/cover"></img>
</a> </a>
</div> </div>
<div class="flex flex-col justify-around dark:text-white w-full text-xs"> <div class="flex flex-col justify-around dark:text-white w-full text-sm">
<div class="inline-flex shrink-0 items-center"> <div class="inline-flex shrink-0 items-center">
<div> <div>
<p class="text-gray-400">Title</p> <p class="text-gray-400">Title</p>
@ -43,9 +50,57 @@
</div> </div>
</div> </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 }}">
<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="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>
{{ if $doc.Filepath }}
<a href="./documents/{{$doc.ID}}/file">
<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
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>
</div> </div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View File

@ -1,3 +1,11 @@
{{template "base.html" .}} {{define "title"}}Graphs{{end}} {{define "content"}} {{template "base.html" .}}
{{define "title"}}Graphs{{end}}
{{define "header"}}
<a href="./graphs">Graphs</a>
{{end}}
{{define "content"}}
<h1>Graphs</h1> <h1>Graphs</h1>
{{end}} {{end}}

View File

@ -1,162 +0,0 @@
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="px-8 mx-auto max-w-7xl">
<div class="flex items-center justify-between h-16">
<div class="flex items-center">
<div class="hidden md:block">
<div class="flex items-baseline space-x-4">
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/"
>
Home
</a>
<a
class="text-gray-800 dark:text-white hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/documents"
>
Documents
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/activity"
>
Activity
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium"
href="/graphs"
>
Graphs
</a>
</div>
</div>
</div>
<div class="block">
<div class="flex items-center ml-4 md:ml-6">
<div class="relative ml-3">
<div class="custom-profile-icon relative inline-block text-left">
<div>
<button
type="button"
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500"
id="options-menu"
>
<svg
width="20"
fill="currentColor"
height="20"
class="text-gray-800 dark:text-gray-200"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"
/>
</svg>
</button>
</div>
</div>
<div
class="custom-profile-dropdown transition duration-200 absolute right-0 w-56 pt-2 origin-top-right bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-black ring-opacity-5"
>
<div
class="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<a
href="#"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span> Settings </span>
</span>
</a>
<a
href="#"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span> Account </span>
</span>
</a>
<a
href="#"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span> Logout </span>
</span>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="flex -mr-2 md:hidden">
<button
class="text-gray-800 dark:text-white hover:text-gray-300 inline-flex items-center justify-center p-2 rounded-md focus:outline-none"
>
<svg
width="20"
height="20"
fill="currentColor"
class="w-8 h-8"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1664 1344v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45z"
/>
</svg>
</button>
</div>
</div>
</div>
<div class="md:hidden">
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Home
</a>
<a
class="text-gray-800 dark:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Gallery
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Content
</a>
<a
class="text-gray-300 hover:text-gray-800 dark:hover:text-white block px-3 py-2 rounded-md text-base font-medium"
href="/#"
>
Contact
</a>
</div>
</div>
</nav>
<!-- Custom Animation CSS -->
<style>
.custom-profile-dropdown {
visibility: hidden;
opacity: 0;
}
.custom-profile-icon:hover + .custom-profile-dropdown,
.custom-profile-dropdown:hover {
visibility: visible;
opacity: 1;
}
</style>

View File

@ -1,5 +1,6 @@
{{template "base.html" .}} {{define "title"}}Home{{end}} {{define "content"}} {{template "base.html" .}} {{define "title"}}Home{{end}} {{define "header"}}
<a href="./">Home</a>
{{end}} {{define "content"}}
<div class="w-full"> <div class="w-full">
<div <div
class="relative w-full px-4 py-4 bg-white shadow-lg dark:bg-gray-700 rounded" class="relative w-full px-4 py-4 bg-white shadow-lg dark:bg-gray-700 rounded"
@ -103,7 +104,7 @@
<div <div
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded" 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-xs"> <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"> <p class="text-2xl font-bold text-black dark:text-white">
{{ .Data.DatabaseInfo.DocumentsSize }} {{ .Data.DatabaseInfo.DocumentsSize }}
</p> </p>
@ -116,7 +117,7 @@
<div <div
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded" 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-xs"> <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"> <p class="text-2xl font-bold text-black dark:text-white">
{{ .Data.DatabaseInfo.ActivitySize }} {{ .Data.DatabaseInfo.ActivitySize }}
</p> </p>
@ -129,7 +130,7 @@
<div <div
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded" 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-xs"> <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"> <p class="text-2xl font-bold text-black dark:text-white">
{{ .Data.DatabaseInfo.ProgressSize }} {{ .Data.DatabaseInfo.ProgressSize }}
</p> </p>
@ -142,7 +143,7 @@
<div <div
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded" 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-xs"> <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"> <p class="text-2xl font-bold text-black dark:text-white">
{{ .Data.DatabaseInfo.DevicesSize }} {{ .Data.DatabaseInfo.DevicesSize }}
</p> </p>
@ -173,7 +174,7 @@
> >
<div> <div>
<p>Current Daily Streak</p> <p>Current Daily Streak</p>
<div class="flex items-end text-xs text-gray-400"> <div class="flex items-end text-sm text-gray-400">
{{ .Data.DailyStreak.CurrentStreakStartDate }} ➞ {{ {{ .Data.DailyStreak.CurrentStreakStartDate }} ➞ {{
.Data.DailyStreak.CurrentStreakEndDate }} .Data.DailyStreak.CurrentStreakEndDate }}
</div> </div>
@ -185,7 +186,7 @@
<div class="flex items-center justify-between pb-2 mb-2 text-sm"> <div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div> <div>
<p>Best Daily Streak</p> <p>Best Daily Streak</p>
<div class="flex items-end text-xs text-gray-400"> <div class="flex items-end text-sm text-gray-400">
{{ .Data.DailyStreak.MaxStreakStartDate }} ➞ {{ {{ .Data.DailyStreak.MaxStreakStartDate }} ➞ {{
.Data.DailyStreak.MaxStreakEndDate }} .Data.DailyStreak.MaxStreakEndDate }}
</div> </div>
@ -218,7 +219,7 @@
> >
<div> <div>
<p>Current Weekly Streak</p> <p>Current Weekly Streak</p>
<div class="flex items-end text-xs text-gray-400"> <div class="flex items-end text-sm text-gray-400">
{{ .Data.WeeklyStreak.CurrentStreakStartDate }} ➞ {{ {{ .Data.WeeklyStreak.CurrentStreakStartDate }} ➞ {{
.Data.WeeklyStreak.CurrentStreakEndDate }} .Data.WeeklyStreak.CurrentStreakEndDate }}
</div> </div>
@ -230,7 +231,7 @@
<div class="flex items-center justify-between pb-2 mb-2 text-sm"> <div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div> <div>
<p>Best Weekly Streak</p> <p>Best Weekly Streak</p>
<div class="flex items-end text-xs text-gray-400"> <div class="flex items-end text-sm text-gray-400">
{{ .Data.WeeklyStreak.MaxStreakStartDate }} ➞ {{ {{ .Data.WeeklyStreak.MaxStreakStartDate }} ➞ {{
.Data.WeeklyStreak.MaxStreakEndDate }} .Data.WeeklyStreak.MaxStreakEndDate }}
</div> </div>

View File

@ -16,8 +16,8 @@
<form <form
class="flex flex-col pt-3 md:pt-8" class="flex flex-col pt-3 md:pt-8"
{{if {{if
.Register}}action="/register" .Register}}action="./register"
{{else}}action="/login" {{else}}action="./login"
{{end}} {{end}}
method="POST" method="POST"
> >