[add] document view, [add] html sanitization, [add] google books metadata enrichment, [improve] db query performance
This commit is contained in:
parent
c1f463f0b9
commit
3150c89303
@ -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)
|
||||||
|
|
||||||
|
19
api/api.go
19
api/api.go
@ -9,6 +9,8 @@ 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"
|
||||||
@ -18,10 +20,12 @@ 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{
|
||||||
|
HTMLPolicy: bluemonday.StripTagsPolicy(),
|
||||||
Router: gin.Default(),
|
Router: gin.Default(),
|
||||||
Config: c,
|
Config: c,
|
||||||
DB: db,
|
DB: db,
|
||||||
@ -31,10 +35,19 @@ func NewApi(db *database.DBManager, c *config.Config) *API {
|
|||||||
api.Router.Static("/assets", "./assets")
|
api.Router.Static("/assets", "./assets")
|
||||||
|
|
||||||
// Generate Secure Token
|
// Generate Secure Token
|
||||||
newToken, err := generateToken(64)
|
var newToken []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
panic("Unable to generate secure token")
|
panic("Unable to generate secure token")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configure Cookie Session Store
|
// Configure Cookie Session Store
|
||||||
store := cookie.NewStore(newToken)
|
store := cookie.NewStore(newToken)
|
||||||
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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 |
@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
3
go.mod
@ -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
6
go.sum
@ -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=
|
||||||
|
2
main.go
2
main.go
@ -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
|
||||||
|
@ -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"`
|
||||||
func GetCoverIDs(title *string, author *string) ([]string, error) {
|
Description string `json:"description"`
|
||||||
if title == nil || author == nil {
|
Identifiers []gBooksIdentifiers `json:"industryIdentifiers"`
|
||||||
log.Error("[metadata] Invalid Search Query")
|
|
||||||
return nil, errors.New("Invalid Query")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *title, *author))
|
type gBooksQueryItem struct {
|
||||||
apiQuery := fmt.Sprintf(BASE_QUERY_URL, searchQuery)
|
ID string `json:"id"`
|
||||||
|
Info gBooksInfo `json:"volumeInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("[metadata] Acquiring CoverID")
|
type gBooksQueryResponse struct {
|
||||||
resp, err := http.Get(apiQuery)
|
TotalItems int `json:"totalItems"`
|
||||||
|
Items []gBooksQueryItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const GBOOKS_QUERY_URL string = "https://www.googleapis.com/books/v1/volumes?q=%s&filter=ebooks&download=epub"
|
||||||
|
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 {
|
||||||
|
// Use GBID
|
||||||
|
resp, err := performGBIDRequest(*data.GBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[metadata] Cover URL API Failure")
|
return err
|
||||||
return nil, errors.New("API Failure")
|
|
||||||
}
|
}
|
||||||
|
queryResult = resp
|
||||||
target := queryResponse{}
|
} else if len(data.ISBN) > 0 {
|
||||||
err = json.NewDecoder(resp.Body).Decode(&target)
|
searchQuery := "isbn:" + *data.ISBN[0]
|
||||||
|
resp, err := performSearchRequest(searchQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[metadata] Cover URL API Decode Failure")
|
return err
|
||||||
return nil, errors.New("API Failure")
|
}
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverIDs []string
|
// Merge Data
|
||||||
for _, result := range target.Results {
|
data.GBID = &queryResult.ID
|
||||||
if result.CoverEditionKey != "" {
|
data.Description = &queryResult.Info.Description
|
||||||
coverIDs = append(coverIDs, result.CoverEditionKey)
|
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 coverIDs, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadAndSaveCover(coverID string, dirPath string) (*string, error) {
|
return nil
|
||||||
// Derive & Sanitize File Name
|
}
|
||||||
fileName := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", coverID))
|
|
||||||
|
|
||||||
// Generate Storage Path
|
|
||||||
safePath := filepath.Join(dirPath, "covers", fileName)
|
|
||||||
|
|
||||||
|
func SaveCover(id string, safePath string) error {
|
||||||
// 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
107
metadata/olib.go
Normal 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
|
||||||
|
}
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
@ -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
161
templates/document.html
Normal 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}}
|
@ -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>
|
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
<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>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
|
@ -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>
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
Loading…
Reference in New Issue
Block a user