This commit is contained in:
Evan Reichard 2025-08-30 20:52:27 -04:00
parent 01abea6bd6
commit 10bbd908e6
31 changed files with 789 additions and 479 deletions

View File

@ -145,23 +145,23 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress) router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
// Web App - Templates // Web App - Templates
router.GET("/", api.authWebAppMiddleware, api.appGetHomeNew) // DONE router.GET("/", api.authWebAppMiddleware, api.appGetHome) // DONE
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivityNew) // DONE router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity) // DONE
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgressNew) // DONE router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress) // DONE
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocumentsNew) // DONE router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments) // DONE
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocumentNew) // DONE router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument) // DONE
// Web App - Other Routes // Web App - Other Routes
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout) router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout) // DONE
router.POST("/login", api.appAuthLogin) // DONE router.POST("/login", api.appAuthLogin) // DONE
router.POST("/register", api.appAuthRegister) // DONE router.POST("/register", api.appAuthRegister) // DONE
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings) // DONE
// TODO // TODO
router.GET("/login", api.appGetLogin) router.GET("/login", api.appGetLogin)
router.GET("/register", api.appGetRegister) router.GET("/register", api.appGetRegister)
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs) router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport) router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport) router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
@ -182,12 +182,13 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // TODO router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // DONE
} }
// Search enabled configuration // Search enabled configuration
if api.cfg.SearchEnabled { if api.cfg.SearchEnabled {
router.GET("/search", api.authWebAppMiddleware, api.appGetSearchNew) // WIP router.GET("/search", api.authWebAppMiddleware, api.appGetSearch) // DONE
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO
} }
} }
@ -358,13 +359,13 @@ func loggingMiddleware(c *gin.Context) {
} }
// Get username // Get username
var auth authData var auth *authData
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData) auth = data.(*authData)
} }
// Log user // Log user
if auth.UserName != "" { if auth != nil && auth.UserName != "" {
logData["user"] = auth.UserName logData["user"] = auth.UserName
} }

View File

@ -2,6 +2,7 @@ package api
import ( import (
"cmp" "cmp"
"crypto/md5"
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
@ -9,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
@ -18,20 +20,19 @@ import (
"reichard.io/antholume/pkg/sliceutils" "reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/pkg/utils" "reichard.io/antholume/pkg/utils"
"reichard.io/antholume/search" "reichard.io/antholume/search"
"reichard.io/antholume/web/components/layout"
"reichard.io/antholume/web/components/stats" "reichard.io/antholume/web/components/stats"
"reichard.io/antholume/web/models" "reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages" "reichard.io/antholume/web/pages"
) )
func (api *API) appGetHomeNew(c *gin.Context) { func (api *API) appGetHome(c *gin.Context) {
_, auth := api.getBaseTemplateVars("home", c) _, auth := api.getBaseTemplateVars("home", c)
start := time.Now() start := time.Now()
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName) dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDailyReadStats DB Error: ", err) log.WithError(err).Error("failed to get daily read stats")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get daily read stats: %s", err))
return return
} }
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start)) log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
@ -39,8 +40,8 @@ func (api *API) appGetHomeNew(c *gin.Context) {
start = time.Now() start = time.Now()
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName) databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDatabaseInfo DB Error: ", err) log.WithError(err).Error("failed to get database info")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get database info: %s", err))
return return
} }
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start)) log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
@ -48,8 +49,8 @@ func (api *API) appGetHomeNew(c *gin.Context) {
start = time.Now() start = time.Now()
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName) streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetUserStreaks DB Error: ", err) log.WithError(err).Error("failed to get user streaks")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user streaks: %s", err))
return return
} }
log.Debug("GetUserStreaks DB Performance: ", time.Since(start)) log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
@ -57,35 +58,27 @@ func (api *API) appGetHomeNew(c *gin.Context) {
start = time.Now() start = time.Now()
userStatistics, err := api.db.Queries.GetUserStatistics(c) userStatistics, err := api.db.Queries.GetUserStatistics(c)
if err != nil { if err != nil {
log.Error("GetUserStatistics DB Error: ", err) log.WithError(err).Error("failed to get user statistics")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user statistics: %s", err))
return return
} }
log.Debug("GetUserStatistics DB Performance: ", time.Since(start)) log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
err = layout.Layout( api.renderPage(c, &pages.Home{
pages.Home{ Leaderboard: arrangeUserStatistic(userStatistics),
Leaderboard: arrangeUserStatisticsNew(userStatistics), Streaks: streaks,
Streaks: streaks, DailyStats: dailyStats,
DailyStats: dailyStats, RecordInfo: &databaseInfo,
RecordInfo: &databaseInfo, })
},
layout.LayoutOptions{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
).Render(c.Writer)
if err != nil {
log.Error("Render Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err))
}
} }
func (api *API) appGetDocumentsNew(c *gin.Context) { func (api *API) appGetDocuments(c *gin.Context) {
_, auth := api.getBaseTemplateVars("documents", c) qParams, err := bindQueryParams(c, 9)
qParams := bindQueryParams(c, 9) if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
var query *string var query *string
if qParams.Search != nil && *qParams.Search != "" { if qParams.Search != nil && *qParams.Search != "" {
@ -93,6 +86,7 @@ func (api *API) appGetDocumentsNew(c *gin.Context) {
query = &search query = &search
} }
_, auth := api.getBaseTemplateVars("documents", c)
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{ documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
UserID: auth.UserName, UserID: auth.UserName,
Query: query, Query: query,
@ -101,170 +95,114 @@ func (api *API) appGetDocumentsNew(c *gin.Context) {
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) })
if err != nil { if err != nil {
log.Error("GetDocumentsWithStats DB Error: ", err) log.WithError(err).Error("failed to get documents with stats")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get documents with stats: %s", err))
return return
} }
length, err := api.db.Queries.GetDocumentsSize(c, query) length, err := api.db.Queries.GetDocumentsSize(c, query)
if err != nil { if err != nil {
log.Error("GetDocumentsSize DB Error: ", err) log.WithError(err).Error("failed to get document sizes")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document sizes: %s", err))
return return
} }
if err = api.getDocumentsWordCount(c, documents); err != nil { if err = api.getDocumentsWordCount(c, documents); err != nil {
log.Error("Unable to Get Word Counts: ", err) log.WithError(err).Error("failed to get word counts")
} }
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit))) totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
nextPage := *qParams.Page + 1 nextPage := *qParams.Page + 1
previousPage := *qParams.Page - 1 previousPage := *qParams.Page - 1
err = layout.Layout( api.renderPage(c, pages.Documents{
pages.Documents{ Data: sliceutils.Map(documents, convertDBDocToUI),
Data: sliceutils.Map(documents, convertDBDocToUI), Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0),
Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0), Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0),
Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0), Limit: int(ptr.Deref(qParams.Limit)),
Limit: int(ptr.Deref(qParams.Limit)), })
},
layout.LayoutOptions{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
).Render(c.Writer)
if err != nil {
log.Error("Render Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err))
}
} }
func (api *API) appGetDocumentNew(c *gin.Context) { func (api *API) appGetDocument(c *gin.Context) {
_, auth := api.getBaseTemplateVars("document", c)
var rDocID requestDocumentID var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil { if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("Invalid URI Bind") log.WithError(err).Error("failed to bind URI")
appErrorPage(c, http.StatusNotFound, "Invalid document") appErrorPage(c, http.StatusNotFound, "Invalid document")
return return
} }
_, auth := api.getBaseTemplateVars("document", c)
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName) document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDocument DB Error: ", err) log.WithError(err).Error("failed to get document")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
return return
} }
err = layout.Layout( api.renderPage(c, &pages.Document{Data: convertDBDocToUI(*document)})
pages.Document{
Data: convertDBDocToUI(*document),
},
layout.LayoutOptions{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
).Render(c.Writer)
if err != nil {
log.Error("Render Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err))
}
} }
func (api *API) appGetActivityNew(c *gin.Context) { func (api *API) appGetActivity(c *gin.Context) {
qParams, err := bindQueryParams(c, 15)
if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
_, auth := api.getBaseTemplateVars("activity", c) _, auth := api.getBaseTemplateVars("activity", c)
qParams := bindQueryParams(c, 15) activity, err := api.db.Queries.GetActivity(c, database.GetActivityParams{
UserID: auth.UserName,
activityFilter := database.GetActivityParams{ Offset: (*qParams.Page - 1) * *qParams.Limit,
UserID: auth.UserName, Limit: *qParams.Limit,
Offset: (*qParams.Page - 1) * *qParams.Limit, DocFilter: qParams.Document != nil,
Limit: *qParams.Limit, DocumentID: ptr.Deref(qParams.Document),
} })
if qParams.Document != nil {
activityFilter.DocFilter = true
activityFilter.DocumentID = *qParams.Document
}
activity, err := api.db.Queries.GetActivity(c, activityFilter)
if err != nil { if err != nil {
log.Error("GetActivity DB Error: ", err) log.WithError(err).Error("failed to get activity")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get activity: %s", err))
return return
} }
err = layout.Layout( api.renderPage(c, &pages.Activity{Data: sliceutils.Map(activity, convertDBActivityToUI)})
pages.Activity{
Data: sliceutils.Map(activity, convertDBActivityToUI),
},
layout.LayoutOptions{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
).Render(c.Writer)
if err != nil {
log.Error("Render Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err))
}
} }
func (api *API) appGetProgressNew(c *gin.Context) { func (api *API) appGetProgress(c *gin.Context) {
_, auth := api.getBaseTemplateVars("progress", c) qParams, err := bindQueryParams(c, 15)
qParams := bindQueryParams(c, 15)
progressFilter := database.GetProgressParams{
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
}
if qParams.Document != nil {
progressFilter.DocFilter = true
progressFilter.DocumentID = *qParams.Document
}
progress, err := api.db.Queries.GetProgress(c, progressFilter)
if err != nil { if err != nil {
log.Error("GetProgress DB Error: ", err) log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err)) appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return return
} }
err = layout.Layout( _, auth := api.getBaseTemplateVars("progress", c)
pages.Progress{ progress, err := api.db.Queries.GetProgress(c, database.GetProgressParams{
Data: sliceutils.Map(progress, convertDBProgressToUI), UserID: auth.UserName,
}, Offset: (*qParams.Page - 1) * *qParams.Limit,
layout.LayoutOptions{ Limit: *qParams.Limit,
Username: auth.UserName, DocFilter: qParams.Document != nil,
IsAdmin: auth.IsAdmin, DocumentID: ptr.Deref(qParams.Document),
SearchEnabled: api.cfg.SearchEnabled, })
Version: api.cfg.Version,
},
).Render(c.Writer)
if err != nil { if err != nil {
log.Error("Render Error: ", err) log.WithError(err).Error("failed to get progress")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get progress: %s", err))
return
} }
api.renderPage(c, &pages.Progress{Data: sliceutils.Map(progress, convertDBProgressToUI)})
} }
func (api *API) appIdentifyDocumentNew(c *gin.Context) { func (api *API) appIdentifyDocumentNew(c *gin.Context) {
var rDocID requestDocumentID var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil { if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("Invalid URI Bind") log.WithError(err).Error("failed to bind URI")
appErrorPage(c, http.StatusNotFound, "Invalid document") appErrorPage(c, http.StatusNotFound, "Invalid document")
return return
} }
var rDocIdentify requestDocumentIdentify var rDocIdentify requestDocumentIdentify
if err := c.ShouldBind(&rDocIdentify); err != nil { if err := c.ShouldBind(&rDocIdentify); err != nil {
log.Error("Invalid Form Bind") log.WithError(err).Error("failed to bind form")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return return
} }
@ -282,15 +220,14 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) {
// Validate Values // Validate Values
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil { if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
log.Error("Invalid Form") log.Error("invalid or missing form values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return return
} }
// Get Template Variables
_, auth := api.getBaseTemplateVars("document", c)
// Get Metadata // Get Metadata
var searchResult *models.DocumentMetadata
var allNotifications []*models.Notification
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{ metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
Title: rDocIdentify.Title, Title: rDocIdentify.Title,
Author: rDocIdentify.Author, Author: rDocIdentify.Author,
@ -298,14 +235,12 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) {
ISBN13: rDocIdentify.ISBN, ISBN13: rDocIdentify.ISBN,
}) })
if err != nil { if err != nil {
log.Error("Search Metadata Error: ", err) log.WithError(err).Error("failed to search metadata")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Metadata Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to search metadata: %s", err))
return return
} } else if firstResult, found := sliceutils.First(metadataResults); found {
searchResult = convertMetaToUI(firstResult)
var errorMsg *string
firstResult, found := sliceutils.First(metadataResults)
if found {
// Store First Metadata Result // Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{ if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
DocumentID: rDocID.DocumentID, DocumentID: rDocID.DocumentID,
@ -313,52 +248,42 @@ func (api *API) appIdentifyDocumentNew(c *gin.Context) {
Author: firstResult.Author, Author: firstResult.Author,
Description: firstResult.Description, Description: firstResult.Description,
Gbid: firstResult.SourceID, Gbid: firstResult.SourceID,
Olid: nil,
Isbn10: firstResult.ISBN10, Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13, Isbn13: firstResult.ISBN13,
}); err != nil { }); err != nil {
log.Error("AddMetadata DB Error: ", err) log.WithError(err).Error("failed to add metadata")
} }
} else { } else {
errorMsg = ptr.Of("No Metadata Found") allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "No Metadata Found",
})
} }
// Get Auth
_, auth := api.getBaseTemplateVars("document", c)
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName) document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDocument DB Error: ", err) log.WithError(err).Error("failed to get document")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
return return
} }
err = layout.Layout( api.renderPage(c, &pages.Document{
pages.Document{ Data: convertDBDocToUI(*document),
Data: convertDBDocToUI(*document), Search: searchResult,
Search: convertMetaToUI(firstResult, errorMsg), }, allNotifications...)
},
layout.LayoutOptions{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
).Render(c.Writer)
if err != nil {
log.Error("Render Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err))
}
} }
// Tabs: // Tabs:
// - General (Import, Backup & Restore, Version (githash?), Stats?) // - General (Import, Backup & Restore, Version (githash?), Stats?)
// - Users // - Users
// - Metadata // - Metadata
func (api *API) appGetSearchNew(c *gin.Context) { func (api *API) appGetSearch(c *gin.Context) {
_, auth := api.getBaseTemplateVars("search", c)
var sParams searchParams var sParams searchParams
err := c.BindQuery(&sParams) if err := c.BindQuery(&sParams); err != nil {
if err != nil { log.WithError(err).Error("failed to bind form")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return return
} }
@ -368,6 +293,7 @@ func (api *API) appGetSearchNew(c *gin.Context) {
if sParams.Query != nil && sParams.Source != nil { if sParams.Query != nil && sParams.Source != nil {
results, err := search.SearchBook(*sParams.Query, *sParams.Source) results, err := search.SearchBook(*sParams.Query, *sParams.Source)
if err != nil { if err != nil {
log.WithError(err).Error("failed to search book")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
return return
} }
@ -376,23 +302,159 @@ func (api *API) appGetSearchNew(c *gin.Context) {
searchError = "Invailid Query" searchError = "Invailid Query"
} }
err = layout.Layout( api.renderPage(c, &pages.Search{
pages.Search{ Results: searchResults,
Results: searchResults, Source: ptr.Deref(sParams.Source),
Source: ptr.Deref(sParams.Source), Query: ptr.Deref(sParams.Query),
Query: ptr.Deref(sParams.Query), Error: searchError,
Error: searchError, })
}, }
layout.LayoutOptions{
Username: auth.UserName, func (api *API) appGetSettings(c *gin.Context) {
IsAdmin: auth.IsAdmin, _, auth := api.getBaseTemplateVars("settings", c)
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version, user, err := api.db.Queries.GetUser(c, auth.UserName)
},
).Render(c.Writer)
if err != nil { if err != nil {
log.Error("Render Error: ", err) log.WithError(err).Error("failed to get user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unknown Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
return
}
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get devices")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
return
}
api.renderPage(c, &pages.Settings{
Timezone: ptr.Deref(user.Timezone),
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
})
}
func (api *API) appEditSettings(c *gin.Context) {
var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil {
log.WithError(err).Error("failed to bind form")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
log.Error("invalid or missing form values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
_, auth := api.getBaseTemplateVars("settings", c)
newUserSettings := database.UpdateUserParams{
UserID: auth.UserName,
Admin: auth.IsAdmin,
}
// Set New Password
var allNotifications []*models.Notification
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
if _, err := api.authorizeCredentials(c, auth.UserName, password); err != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "Invalid Password",
})
} else {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "Unknown Error",
})
} else {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeSuccess,
Content: "Password Updated",
})
newUserSettings.Password = &hashedPassword
}
}
}
// Set Time Offset
if rUserSettings.Timezone != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeSuccess,
Content: "Time Offset Updated",
})
newUserSettings.Timezone = rUserSettings.Timezone
}
// Update User
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
if err != nil {
log.WithError(err).Error("failed to update user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to update user: %s", err))
return
}
// Get User
user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
return
}
// Get Devices
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get devices")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
return
}
api.renderPage(c, &pages.Settings{
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
Timezone: ptr.Deref(user.Timezone),
}, allNotifications...)
}
func (api *API) renderPage(c *gin.Context, page pages.Page, notifications ...*models.Notification) {
// Get Authentication Data
auth, err := getAuthData(c)
if err != nil {
log.WithError(err).Error("failed to acquire auth data")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to acquire auth data: %s", err))
return
}
// Generate Page
pageNode, err := page.Generate(models.PageContext{
UserInfo: &models.UserInfo{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
ServerInfo: &models.ServerInfo{
RegistrationEnabled: api.cfg.RegistrationEnabled,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
Notifications: notifications,
})
if err != nil {
log.WithError(err).Error("failed to generate page")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to generate page: %s", err))
return
}
// Render Page
err = pageNode.Render(c.Writer)
if err != nil {
log.WithError(err).Error("failed to render page")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to render page: %s", err))
return
} }
} }
@ -415,7 +477,7 @@ func sortItem[T cmp.Ordered](
return items return items
} }
func arrangeUserStatisticsNew(data []database.GetUserStatisticsRow) []stats.LeaderboardData { func arrangeUserStatistic(data []database.GetUserStatisticsRow) []stats.LeaderboardData {
wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) } wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) }
return []stats.LeaderboardData{ return []stats.LeaderboardData{
{ {

View File

@ -2,7 +2,6 @@ package api
import ( import (
"context" "context"
"crypto/md5"
"database/sql" "database/sql"
"fmt" "fmt"
"io" "io"
@ -13,7 +12,6 @@ import (
"strings" "strings"
"time" "time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -97,31 +95,6 @@ func (api *API) appDocumentReader(c *gin.Context) {
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets)) c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
} }
func (api *API) appGetSettings(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("settings", c)
user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil {
log.Error("GetUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return
}
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.Error("GetDevices DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
return
}
templateVars["Data"] = gin.H{
"Timezone": *user.Timezone,
"Devices": devices,
}
c.HTML(http.StatusOK, "page/settings", templateVars)
}
func (api *API) appGetLogin(c *gin.Context) { func (api *API) appGetLogin(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("login", c) templateVars, _ := api.getBaseTemplateVars("login", c)
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
@ -539,84 +512,6 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
}) })
} }
func (api *API) appEditSettings(c *gin.Context) {
var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil {
log.Error("Invalid Form Bind")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
log.Error("Missing Form Values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
templateVars, auth := api.getBaseTemplateVars("settings", c)
newUserSettings := database.UpdateUserParams{
UserID: auth.UserName,
Admin: auth.IsAdmin,
}
// Set New Password
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
data := api.authorizeCredentials(c, auth.UserName, password)
if data == nil {
templateVars["PasswordErrorMessage"] = "Invalid Password"
} else {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
templateVars["PasswordErrorMessage"] = "Unknown Error"
} else {
templateVars["PasswordMessage"] = "Password Updated"
newUserSettings.Password = &hashedPassword
}
}
}
// Set Time Offset
if rUserSettings.Timezone != nil {
templateVars["TimeOffsetMessage"] = "Time Offset Updated"
newUserSettings.Timezone = rUserSettings.Timezone
}
// Update User
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
if err != nil {
log.Error("UpdateUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err))
return
}
// Get User
user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil {
log.Error("GetUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return
}
// Get Devices
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.Error("GetDevices DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
return
}
templateVars["Data"] = gin.H{
"Timezone": *user.Timezone,
"Devices": devices,
}
c.HTML(http.StatusOK, "page/settings", templateVars)
}
func (api *API) appDemoModeError(c *gin.Context) { func (api *API) appDemoModeError(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode") appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
} }
@ -664,10 +559,10 @@ func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.
return nil return nil
} }
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, authData) { func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *authData) {
var auth authData var auth *authData
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData) auth = data.(*authData)
} }
return gin.H{ return gin.H{
@ -681,12 +576,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
}, auth }, auth
} }
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) {
var qParams queryParams var qParams queryParams
err := c.BindQuery(&qParams) err := c.BindQuery(&qParams)
if err != nil { if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) return nil, err
return qParams
} }
if qParams.Limit == nil { if qParams.Limit == nil {
@ -701,7 +595,7 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
qParams.Page = &oneValue qParams.Page = &oneValue
} }
return qParams return &qParams, nil
} }
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) { func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {

View File

@ -30,31 +30,31 @@ type authKOHeader struct {
AuthKey string `header:"x-auth-key"` AuthKey string `header:"x-auth-key"`
} }
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) { func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (*authData, error) {
user, err := api.db.Queries.GetUser(ctx, username) user, err := api.db.Queries.GetUser(ctx, username)
if err != nil { if err != nil {
return return nil, err
} }
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match { if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return return nil, err
} }
// Update auth cache // Update Auth Cache
api.userAuthCache[user.ID] = *user.AuthHash api.userAuthCache[user.ID] = *user.AuthHash
return &authData{ return &authData{
UserName: user.ID, UserName: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
AuthHash: *user.AuthHash, AuthHash: *user.AuthHash,
} }, nil
} }
func (api *API) authKOMiddleware(c *gin.Context) { func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session First // Check Session First
if auth, ok := api.getSession(c, session); ok { if auth, ok := api.authorizeSession(c, session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@ -65,21 +65,25 @@ func (api *API) authKOMiddleware(c *gin.Context) {
var rHeader authKOHeader var rHeader authKOHeader
if err := c.ShouldBindHeader(&rHeader); err != nil { if err := c.ShouldBindHeader(&rHeader); err != nil {
log.WithError(err).Error("failed to bind auth headers")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
return return
} }
if rHeader.AuthUser == "" || rHeader.AuthKey == "" { if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
log.Error("invalid authentication headers")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return return
} }
authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey) authData, err := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
if authData == nil { if err != nil {
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to authorize credentials")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
} }
if err := api.setSession(session, *authData); err != nil { if err := api.setSession(session, authData); err != nil {
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to set session")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
} }
@ -96,14 +100,16 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
// Validate Auth Fields // Validate Auth Fields
if !hasAuth || user == "" || rawPassword == "" { if !hasAuth || user == "" || rawPassword == "" {
log.Error("invalid authorization headers")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return return
} }
// Validate Auth // Validate Auth
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
authData := api.authorizeCredentials(c, user, password) authData, err := api.authorizeCredentials(c, user, password)
if authData == nil { if err != nil {
log.WithField("user", user).WithError(err).Error("failed to authorize credentials")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
} }
@ -117,7 +123,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session // Check Session
if auth, ok := api.getSession(c, session); ok { if auth, ok := api.authorizeSession(c, session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@ -130,7 +136,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
func (api *API) authAdminWebAppMiddleware(c *gin.Context) { func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData) auth := data.(*authData)
if auth.IsAdmin { if auth.IsAdmin {
c.Next() c.Next()
return return
@ -155,8 +161,9 @@ func (api *API) appAuthLogin(c *gin.Context) {
// MD5 - KOSync Compatiblity // MD5 - KOSync Compatiblity
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
authData := api.authorizeCredentials(c, username, password) authData, err := api.authorizeCredentials(c, username, password)
if authData == nil { if err != nil {
log.WithField("user", username).WithError(err).Error("failed to authorize credentials")
templateVars["Error"] = "Invalid Credentials" templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars) c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return return
@ -164,7 +171,7 @@ func (api *API) appAuthLogin(c *gin.Context) {
// Set Session // Set Session
session := sessions.Default(c) session := sessions.Default(c)
if err := api.setSession(session, *authData); err != nil { if err := api.setSession(session, authData); err != nil {
templateVars["Error"] = "Invalid Credentials" templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars) c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return return
@ -253,7 +260,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
} }
// Set session // Set session
auth := authData{ auth := &authData{
UserName: user.ID, UserName: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
AuthHash: *user.AuthHash, AuthHash: *user.AuthHash,
@ -349,35 +356,40 @@ func (api *API) koAuthRegister(c *gin.Context) {
}) })
} }
func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) { func (api *API) authorizeSession(ctx context.Context, session sessions.Session) (*authData, bool) {
// Get Session // Get Session
authorizedUser := session.Get("authorizedUser") authorizedUser := session.Get("authorizedUser")
isAdmin := session.Get("isAdmin") isAdmin := session.Get("isAdmin")
expiresAt := session.Get("expiresAt") expiresAt := session.Get("expiresAt")
authHash := session.Get("authHash") authHash := session.Get("authHash")
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil { if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
return return nil, false
} }
// Create Auth Object // Create Auth Object
auth = authData{ auth := &authData{
UserName: authorizedUser.(string), UserName: authorizedUser.(string),
IsAdmin: isAdmin.(bool), IsAdmin: isAdmin.(bool),
AuthHash: authHash.(string), AuthHash: authHash.(string),
} }
logger := log.WithField("user", auth.UserName)
// Validate Auth Hash // Validate Auth Hash
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName) correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
if err != nil || correctAuthHash != auth.AuthHash { if err != nil {
return logger.WithError(err).Error("failed to get auth hash")
return nil, false
} else if correctAuthHash != auth.AuthHash {
logger.Warn("user auth hash mismatch")
return nil, false
} }
// Refresh // Refresh
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session") logger.Info("refreshing session")
if err := api.setSession(session, auth); err != nil { if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session") logger.WithError(err).Error("failed to refresh session")
return return nil, false
} }
} }
@ -385,7 +397,7 @@ func (api *API) getSession(ctx context.Context, session sessions.Session) (auth
return auth, true return auth, true
} }
func (api *API) setSession(session sessions.Session, auth authData) error { func (api *API) setSession(session sessions.Session, auth *authData) error {
// Set Session Cookie // Set Session Cookie
session.Set("authorizedUser", auth.UserName) session.Set("authorizedUser", auth.UserName)
session.Set("isAdmin", auth.IsAdmin) session.Set("isAdmin", auth.IsAdmin)

View File

@ -28,7 +28,7 @@ func convertDBDocToUI(r database.GetDocumentsWithStatsRow) models.Document {
} }
} }
func convertMetaToUI(m metadata.MetadataInfo, errorMsg *string) *models.DocumentMetadata { func convertMetaToUI(m metadata.MetadataInfo) *models.DocumentMetadata {
return &models.DocumentMetadata{ return &models.DocumentMetadata{
SourceID: ptr.Deref(m.SourceID), SourceID: ptr.Deref(m.SourceID),
ISBN10: ptr.Deref(m.ISBN10), ISBN10: ptr.Deref(m.ISBN10),
@ -37,7 +37,6 @@ func convertMetaToUI(m metadata.MetadataInfo, errorMsg *string) *models.Document
Author: ptr.Deref(m.Author), Author: ptr.Deref(m.Author),
Description: ptr.Deref(m.Description), Description: ptr.Deref(m.Description),
Source: m.Source, Source: m.Source,
Error: errorMsg,
} }
} }
@ -63,6 +62,14 @@ func convertDBProgressToUI(r database.GetProgressRow) models.Progress {
} }
} }
func convertDBDeviceToUI(r database.GetDevicesRow) models.Device {
return models.Device{
DeviceName: r.DeviceName,
LastSynced: r.LastSynced,
CreatedAt: r.CreatedAt,
}
}
func convertSearchToUI(r search.SearchItem) models.SearchResult { func convertSearchToUI(r search.SearchItem) models.SearchResult {
return models.SearchResult{ return models.SearchResult{
ID: r.ID, ID: r.ID,

View File

@ -62,13 +62,19 @@ func (api *API) opdsEntry(c *gin.Context) {
} }
func (api *API) opdsDocuments(c *gin.Context) { func (api *API) opdsDocuments(c *gin.Context) {
var auth authData auth, err := getAuthData(c)
if data, _ := c.Get("Authorization"); data != nil { if err != nil {
auth = data.(authData) log.WithError(err).Error("failed to acquire auth data")
c.AbortWithStatus(http.StatusInternalServerError)
} }
// Potential URL Parameters (Default Pagination - 100) // Potential URL Parameters (Default Pagination - 100)
qParams := bindQueryParams(c, 100) qParams, err := bindQueryParams(c, 100)
if err != nil {
log.WithError(err).Error("failed to bind query params")
c.AbortWithStatus(http.StatusBadRequest)
return
}
// Possible Query // Possible Query
var query *string var query *string
@ -86,7 +92,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) })
if err != nil { if err != nil {
log.Error("GetDocumentsWithStats DB Error:", err) log.WithError(err).Error("failed to get documents with stats")
c.AbortWithStatus(http.StatusBadRequest) c.AbortWithStatus(http.StatusBadRequest)
return return
} }

View File

@ -8,11 +8,22 @@ import (
"reflect" "reflect"
"strings" "strings"
"github.com/gin-gonic/gin"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/graph" "reichard.io/antholume/graph"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
) )
func getAuthData(ctx *gin.Context) (*authData, error) {
if data, ok := ctx.Get("Authorization"); ok {
var auth *authData
if auth, ok = data.(*authData); ok {
return auth, nil
}
}
return nil, errors.New("could not acquire auth data")
}
// getTimeZones returns a string slice of IANA timezones. // getTimeZones returns a string slice of IANA timezones.
func getTimeZones() []string { func getTimeZones() []string {
return []string{ return []string{

File diff suppressed because one or more lines are too long

View File

@ -138,8 +138,8 @@ WHERE id = $device_id LIMIT 1;
SELECT SELECT
devices.id, devices.id,
devices.device_name, devices.device_name,
LOCAL_TIME(devices.created_at, users.timezone) AS created_at, CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
FROM devices FROM devices
JOIN users ON users.id = devices.user_id JOIN users ON users.id = devices.user_id
WHERE users.id = $user_id WHERE users.id = $user_id

View File

@ -422,8 +422,8 @@ const getDevices = `-- name: GetDevices :many
SELECT SELECT
devices.id, devices.id,
devices.device_name, devices.device_name,
LOCAL_TIME(devices.created_at, users.timezone) AS created_at, CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
FROM devices FROM devices
JOIN users ON users.id = devices.user_id JOIN users ON users.id = devices.user_id
WHERE users.id = ?1 WHERE users.id = ?1
@ -431,10 +431,10 @@ ORDER BY devices.last_synced DESC
` `
type GetDevicesRow struct { type GetDevicesRow struct {
ID string `json:"id"` ID string `json:"id"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
CreatedAt interface{} `json:"created_at"` CreatedAt string `json:"created_at"`
LastSynced interface{} `json:"last_synced"` LastSynced string `json:"last_synced"`
} }
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) { func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {

View File

@ -58,7 +58,6 @@ func SearchBook(query string, source Source) ([]SearchItem, error) {
if !found { if !found {
return nil, fmt.Errorf("invalid source: %s", source) return nil, fmt.Errorf("invalid source: %s", source)
} }
log.Debug("Source: ", source)
return searchFunc(query) return searchFunc(query)
} }

View File

@ -17,6 +17,20 @@ module.exports = {
minWidth: { minWidth: {
40: "10rem", 40: "10rem",
}, },
animation: {
notification:
"slideIn 0.25s ease-out forwards, slideOut 0.25s ease-out 4.5s forwards",
},
keyframes: {
slideIn: {
"0%": { transform: "translateX(100%)" },
"100%": { transform: "translateX(0)" },
},
slideOut: {
"0%": { transform: "translateX(0)" },
"100%": { transform: "translateX(100%)" },
},
},
}, },
}, },
plugins: [], plugins: [],

View File

@ -1 +1 @@
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 18.429688 10.285156 C 18.785156 10.285156 19.089844 10.410156 19.339844 10.660156 C 19.589844 10.910156 19.714844 11.214844 19.714844 11.570312 L 19.714844 19.285156 C 19.714844 19.644531 19.589844 19.945312 19.339844 20.195312 C 19.089844 20.445312 18.785156 20.570312 18.429688 20.570312 L 5.570312 20.570312 C 5.214844 20.570312 4.910156 20.445312 4.660156 20.195312 C 4.410156 19.945312 4.285156 19.644531 4.285156 19.285156 L 4.285156 11.570312 C 4.285156 11.214844 4.410156 10.910156 4.660156 10.660156 C 4.910156 10.410156 5.214844 10.285156 5.570312 10.285156 L 6 10.285156 L 6 6 C 6 4.347656 6.585938 2.933594 7.761719 1.761719 C 8.933594 0.585938 10.347656 0 12 0 C 13.652344 0 15.066406 0.585938 16.238281 1.761719 C 17.414062 2.933594 18 4.347656 18 6 C 18 6.230469 17.914062 6.433594 17.746094 6.601562 C 17.574219 6.773438 17.375 6.855469 17.144531 6.855469 L 16.285156 6.855469 C 16.054688 6.855469 15.851562 6.773438 15.683594 6.601562 C 15.511719 6.433594 15.429688 6.230469 15.429688 6 C 15.429688 5.054688 15.09375 4.246094 14.425781 3.574219 C 13.753906 2.90625 12.945312 2.570312 12 2.570312 C 11.054688 2.570312 10.246094 2.90625 9.574219 3.574219 C 8.90625 4.246094 8.570312 5.054688 8.570312 6 L 8.570312 10.285156 Z M 18.429688 10.285156 "/> <path fill-rule="nonzero" fill-opacity="1" d="M 18.429688 10.285156 C 18.785156 10.285156 19.089844 10.410156 19.339844 10.660156 C 19.589844 10.910156 19.714844 11.214844 19.714844 11.570312 L 19.714844 19.285156 C 19.714844 19.644531 19.589844 19.945312 19.339844 20.195312 C 19.089844 20.445312 18.785156 20.570312 18.429688 20.570312 L 5.570312 20.570312 C 5.214844 20.570312 4.910156 20.445312 4.660156 20.195312 C 4.410156 19.945312 4.285156 19.644531 4.285156 19.285156 L 4.285156 11.570312 C 4.285156 11.214844 4.410156 10.910156 4.660156 10.660156 C 4.910156 10.410156 5.214844 10.285156 5.570312 10.285156 L 6 10.285156 L 6 6 C 6 4.347656 6.585938 2.933594 7.761719 1.761719 C 8.933594 0.585938 10.347656 0 12 0 C 13.652344 0 15.066406 0.585938 16.238281 1.761719 C 17.414062 2.933594 18 4.347656 18 6 C 18 6.230469 17.914062 6.433594 17.746094 6.601562 C 17.574219 6.773438 17.375 6.855469 17.144531 6.855469 L 16.285156 6.855469 C 16.054688 6.855469 15.851562 6.773438 15.683594 6.601562 C 15.511719 6.433594 15.429688 6.230469 15.429688 6 C 15.429688 5.054688 15.09375 4.246094 14.425781 3.574219 C 13.753906 2.90625 12.945312 2.570312 12 2.570312 C 11.054688 2.570312 10.246094 2.90625 9.574219 3.574219 C 8.90625 4.246094 8.570312 5.054688 8.570312 6 L 8.570312 10.285156 Z M 18.429688 10.285156 "/>

View File

@ -15,21 +15,6 @@ func IdentifyPopover(docID string, m *models.DocumentMetadata) g.Node {
return nil return nil
} }
if m.Error != nil {
return ui.Popover(h.Div(
h.Class("flex flex-col gap-2"),
h.H3(
h.Class("text-lg font-bold text-center"),
g.Text("Error"),
),
h.Div(
h.Class("bg-gray-100 dark:bg-gray-900 p-2"),
h.P(g.Text(*m.Error)),
),
ui.LinkButton(g.Text("Back to Document"), fmt.Sprintf("/documents/%s", docID)),
))
}
return ui.Popover(h.Div( return ui.Popover(h.Div(
h.Class("flex flex-col gap-2"), h.Class("flex flex-col gap-2"),
h.H3( h.H3(

View File

@ -0,0 +1,25 @@
package ui
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/models"
)
func Notifications(notifications []*models.Notification) g.Node {
if len(notifications) == 0 {
return nil
}
return h.Div(
h.Class("fixed flex flex-col gap-2 bottom-0 right-0 p-2 sm:p-4 text-white dark:text-black"),
g.Group(sliceutils.Map(notifications, notificationNode)),
)
}
func notificationNode(n *models.Notification) g.Node {
return h.Div(
h.Class("bg-gray-600 dark:bg-gray-400 px-4 py-2 rounded-lg shadow-lg w-64 animate-notification"),
h.P(g.Text(n.Content)),
)
}

7
web/models/device.go Normal file
View File

@ -0,0 +1,7 @@
package models
type Device struct {
DeviceName string
LastSynced string
CreatedAt string
}

View File

@ -29,5 +29,4 @@ type DocumentMetadata struct {
Author string Author string
Description string Description string
Source metadata.Source Source metadata.Source
Error *string
} }

12
web/models/info.go Normal file
View File

@ -0,0 +1,12 @@
package models
type UserInfo struct {
Username string
IsAdmin bool
}
type ServerInfo struct {
RegistrationEnabled bool
SearchEnabled bool
Version string
}

View File

@ -0,0 +1,13 @@
package models
type NotificationType int
const (
NotificationTypeSuccess NotificationType = iota
NotificationTypeError
)
type Notification struct {
Content string
Type NotificationType
}

52
web/models/page.go Normal file
View File

@ -0,0 +1,52 @@
package models
type PageContext struct {
Route PageRoute
UserInfo *UserInfo
ServerInfo *ServerInfo
Notifications []*Notification
}
func (ctx PageContext) WithRoute(route PageRoute) PageContext {
ctx.Route = route
return ctx
}
type PageRoute string
const (
HomePage PageRoute = "home"
DocumentPage PageRoute = "document"
DocumentsPage PageRoute = "documents"
ProgressPage PageRoute = "progress"
ActivityPage PageRoute = "activity"
SearchPage PageRoute = "search"
SettingsPage PageRoute = "settings"
AdminGeneralPage PageRoute = "admin-general"
AdminImportPage PageRoute = "admin-import"
AdminUsersPage PageRoute = "admin-users"
AdminLogsPage PageRoute = "admin-logs"
)
var pageTitleMap = map[PageRoute]string{
HomePage: "Home",
DocumentPage: "Document",
DocumentsPage: "Documents",
ProgressPage: "Progress",
ActivityPage: "Activity",
SearchPage: "Search",
SettingsPage: "Settings",
AdminGeneralPage: "Admin - General",
AdminImportPage: "Admin - Import",
AdminUsersPage: "Admin - Users",
AdminLogsPage: "Admin - Logs",
}
func (p PageRoute) Title() string {
return pageTitleMap[p]
}
func (p PageRoute) Valid() bool {
_, ok := pageTitleMap[p]
return ok
}

View File

@ -9,6 +9,7 @@ import (
"reichard.io/antholume/pkg/sliceutils" "reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models" "reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
) )
var _ Page = (*Activity)(nil) var _ Page = (*Activity)(nil)
@ -17,14 +18,15 @@ type Activity struct {
Data []models.Activity Data []models.Activity
} }
func (Activity) Route() PageRoute { return ActivityPage } func (p *Activity) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
func (p Activity) Render() g.Node { ctx.WithRoute(models.ActivityPage),
return h.Div(
h.Class("overflow-x-auto"),
h.Div( h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"), h.Class("overflow-x-auto"),
ui.Table(p.buildTableConfig()), h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
ui.Table(p.buildTableConfig()),
),
), ),
) )
} }

View File

@ -13,6 +13,7 @@ import (
"reichard.io/antholume/web/components/document" "reichard.io/antholume/web/components/document"
"reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models" "reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
) )
var _ Page = (*Document)(nil) var _ Page = (*Document)(nil)
@ -22,9 +23,14 @@ type Document struct {
Search *models.DocumentMetadata Search *models.DocumentMetadata
} }
func (Document) Route() PageRoute { return DocumentPage } func (p *Document) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.DocumentPage),
p.content(),
)
}
func (p Document) Render() g.Node { func (p *Document) content() g.Node {
return h.Div( return h.Div(
h.Class("h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"), h.Class("h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"),
document.Actions(p.Data), document.Actions(p.Data),

View File

@ -9,6 +9,7 @@ import (
"reichard.io/antholume/web/components/document" "reichard.io/antholume/web/components/document"
"reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models" "reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
) )
var _ Page = (*Documents)(nil) var _ Page = (*Documents)(nil)
@ -20,15 +21,13 @@ type Documents struct {
Limit int Limit int
} }
func (Documents) Route() PageRoute { return DocumentsPage } func (p Documents) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(ctx.WithRoute(models.DocumentsPage),
func (p Documents) Render() g.Node {
return g.Group([]g.Node{
searchBar(), searchBar(),
documentGrid(p.Data), documentGrid(p.Data),
pagination(p.Previous, p.Next, p.Limit), pagination(p.Previous, p.Next, p.Limit),
uploadFAB(), uploadFAB(),
}) )
} }
func searchBar() g.Node { func searchBar() g.Node {

View File

@ -5,6 +5,8 @@ import (
h "maragu.dev/gomponents/html" h "maragu.dev/gomponents/html"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/web/components/stats" "reichard.io/antholume/web/components/stats"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
) )
var _ Page = (*Home)(nil) var _ Page = (*Home)(nil)
@ -16,9 +18,11 @@ type Home struct {
RecordInfo *database.GetDatabaseInfoRow RecordInfo *database.GetDatabaseInfoRow
} }
func (Home) Route() PageRoute { return HomePage } func (p *Home) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(ctx.WithRoute(models.HomePage), p.content())
}
func (p Home) Render() g.Node { func (p *Home) content() g.Node {
return h.Div( return h.Div(
g.Attr("class", "flex flex-col gap-4"), g.Attr("class", "flex flex-col gap-4"),
h.Div( h.Div(

View File

@ -1,35 +1,41 @@
package layout package layout
import ( import (
"errors"
"fmt"
g "maragu.dev/gomponents" g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html" h "maragu.dev/gomponents/html"
"reichard.io/antholume/web/pages" "reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
) )
type LayoutOptions struct { func Layout(ctx models.PageContext, children ...g.Node) (g.Node, error) {
SearchEnabled bool if ctx.UserInfo == nil {
IsAdmin bool return nil, errors.New("no user info")
Username string } else if ctx.ServerInfo == nil {
Version string return nil, errors.New("no server info")
} } else if !ctx.Route.Valid() {
return nil, fmt.Errorf("invalid route: %s", ctx.Route)
}
func Layout(p pages.Page, opts LayoutOptions) g.Node {
return h.Doctype( return h.Doctype(
h.HTML( h.HTML(
g.Attr("lang", "en"), g.Attr("lang", "en"),
Head(p.Route().Title()), Head(ctx.Route.Title()),
h.Body( h.Body(
g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"), g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"),
Navigation(p.Route(), &opts), Navigation(ctx),
Base(p.Render()), Base(children),
ui.Notifications(ctx.Notifications),
), ),
), ),
) ), nil
} }
func Head(routeTitle string) g.Node { func Head(routeTitle string) g.Node {
return h.Head( return h.Head(
h.Title("AnthoLume - "+routeTitle), g.El("title", g.Text("AnthoLume - "+routeTitle)),
h.Meta(g.Attr("charset", "utf-8")), h.Meta(g.Attr("charset", "utf-8")),
h.Meta(g.Attr("name", "viewport"), g.Attr("content", "width=device-width, initial-scale=0.9, user-scalable=no, viewport-fit=cover")), h.Meta(g.Attr("name", "viewport"), g.Attr("content", "width=device-width, initial-scale=0.9, user-scalable=no, viewport-fit=cover")),
h.Meta(g.Attr("name", "apple-mobile-web-app-capable"), g.Attr("content", "yes")), h.Meta(g.Attr("name", "apple-mobile-web-app-capable"), g.Attr("content", "yes")),
@ -45,13 +51,13 @@ func Head(routeTitle string) g.Node {
) )
} }
func Base(body g.Node) g.Node { func Base(body []g.Node) g.Node {
return h.Main( return h.Main(
g.Attr("class", "relative overflow-hidden"), g.Attr("class", "relative overflow-hidden"),
h.Div( h.Div(
g.Attr("id", "container"), g.Attr("id", "container"),
g.Attr("class", "h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"), g.Attr("class", "h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"),
body, g.Group(body),
), ),
) )
} }

View File

@ -6,7 +6,7 @@ import (
g "maragu.dev/gomponents" g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html" h "maragu.dev/gomponents/html"
"reichard.io/antholume/web/assets" "reichard.io/antholume/web/assets"
"reichard.io/antholume/web/pages" "reichard.io/antholume/web/models"
) )
const ( const (
@ -14,29 +14,28 @@ const (
inactive = "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" inactive = "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
) )
func Navigation(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node { func Navigation(ctx models.PageContext) g.Node {
return h.Div( return h.Div(
g.Attr("class", "flex items-center justify-between w-full h-16"), g.Attr("class", "flex items-center justify-between w-full h-16"),
Sidebar(currentRoute, opts), Sidebar(ctx),
h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(currentRoute.Title())), h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(ctx.Route.Title())),
Dropdown(opts.Username), Dropdown(ctx.UserInfo.Username),
) )
} }
func Sidebar(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node { func Sidebar(ctx models.PageContext) g.Node {
links := []g.Node{ links := []g.Node{
navLink(currentRoute, pages.HomePage, "/", "home"), navLink(ctx.Route, models.HomePage, "/", "home"),
navLink(currentRoute, pages.DocumentsPage, "/documents", "documents"), navLink(ctx.Route, models.DocumentsPage, "/documents", "documents"),
navLink(currentRoute, pages.ProgressPage, "/progress", "activity"), navLink(ctx.Route, models.ProgressPage, "/progress", "activity"),
navLink(currentRoute, pages.ActivityPage, "/activity", "activity"), navLink(ctx.Route, models.ActivityPage, "/activity", "activity"),
} }
if opts.SearchEnabled { if ctx.ServerInfo.SearchEnabled {
links = append(links, navLink(currentRoute, pages.SearchPage, "/search", "search")) links = append(links, navLink(ctx.Route, models.SearchPage, "/search", "search"))
} }
if opts.IsAdmin { if ctx.UserInfo.IsAdmin {
links = append(links, adminLinks(currentRoute)) links = append(links, adminLinks(ctx.Route))
} }
return h.Div( return h.Div(
g.Attr("id", "mobile-nav-button"), g.Attr("id", "mobile-nav-button"),
g.Attr("class", "flex flex-col z-40 relative ml-6"), g.Attr("class", "flex flex-col z-40 relative ml-6"),
@ -54,13 +53,13 @@ func Sidebar(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node {
g.Attr("target", "_blank"), g.Attr("target", "_blank"),
g.Attr("class", "flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white"), g.Attr("class", "flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white"),
assets.Icon("gitea", 20), assets.Icon("gitea", 20),
h.Span(g.Attr("class", "text-xs"), g.Text(opts.Version)), h.Span(g.Attr("class", "text-xs"), g.Text(ctx.ServerInfo.Version)),
), ),
), ),
) )
} }
func navLink(currentRoute, linkRoute pages.PageRoute, path, icon string) g.Node { func navLink(currentRoute, linkRoute models.PageRoute, path, icon string) g.Node {
class := inactive class := inactive
if currentRoute == linkRoute { if currentRoute == linkRoute {
class = active class = active
@ -73,7 +72,7 @@ func navLink(currentRoute, linkRoute pages.PageRoute, path, icon string) g.Node
) )
} }
func adminLinks(currentRoute pages.PageRoute) g.Node { func adminLinks(currentRoute models.PageRoute) g.Node {
routeID := string(currentRoute) routeID := string(currentRoute)
class := inactive class := inactive
@ -83,10 +82,10 @@ func adminLinks(currentRoute pages.PageRoute) g.Node {
children := g.If(strings.HasPrefix(routeID, "admin"), children := g.If(strings.HasPrefix(routeID, "admin"),
g.Group([]g.Node{ g.Group([]g.Node{
subNavLink(currentRoute, pages.AdminGeneralPage, "/admin"), subNavLink(currentRoute, models.AdminGeneralPage, "/admin"),
subNavLink(currentRoute, pages.AdminImportPage, "/admin/import"), subNavLink(currentRoute, models.AdminImportPage, "/admin/import"),
subNavLink(currentRoute, pages.AdminUsersPage, "/admin/users"), subNavLink(currentRoute, models.AdminUsersPage, "/admin/users"),
subNavLink(currentRoute, pages.AdminLogsPage, "/admin/logs"), subNavLink(currentRoute, models.AdminLogsPage, "/admin/logs"),
}), }),
) )
@ -102,7 +101,7 @@ func adminLinks(currentRoute pages.PageRoute) g.Node {
) )
} }
func subNavLink(currentRoute, linkRoute pages.PageRoute, path string) g.Node { func subNavLink(currentRoute, linkRoute models.PageRoute, path string) g.Node {
class := inactive class := inactive
if currentRoute == linkRoute { if currentRoute == linkRoute {
class = active class = active

35
web/pages/layout/route.go Normal file
View File

@ -0,0 +1,35 @@
package layout
type Route string
const (
HomePage Route = "home"
DocumentPage Route = "document"
DocumentsPage Route = "documents"
ProgressPage Route = "progress"
ActivityPage Route = "activity"
SearchPage Route = "search"
SettingsPage Route = "settings"
AdminGeneralPage Route = "admin-general"
AdminImportPage Route = "admin-import"
AdminUsersPage Route = "admin-users"
AdminLogsPage Route = "admin-logs"
)
var pageTitleMap = map[Route]string{
HomePage: "Home",
DocumentPage: "Document",
DocumentsPage: "Documents",
ProgressPage: "Progress",
ActivityPage: "Activity",
SearchPage: "Search",
SettingsPage: "Settings",
AdminGeneralPage: "Admin - General",
AdminImportPage: "Admin - Import",
AdminUsersPage: "Admin - Users",
AdminLogsPage: "Admin - Logs",
}
func (p Route) Title() string {
return pageTitleMap[p]
}

View File

@ -2,41 +2,9 @@ package pages
import ( import (
g "maragu.dev/gomponents" g "maragu.dev/gomponents"
"reichard.io/antholume/web/models"
) )
type PageRoute string
const (
HomePage PageRoute = "home"
DocumentPage PageRoute = "document"
DocumentsPage PageRoute = "documents"
ProgressPage PageRoute = "progress"
ActivityPage PageRoute = "activity"
SearchPage PageRoute = "search"
AdminGeneralPage PageRoute = "admin-general"
AdminImportPage PageRoute = "admin-import"
AdminUsersPage PageRoute = "admin-users"
AdminLogsPage PageRoute = "admin-logs"
)
var pageTitleMap = map[PageRoute]string{
HomePage: "Home",
DocumentPage: "Document",
DocumentsPage: "Documents",
ProgressPage: "Progress",
ActivityPage: "Activity",
SearchPage: "Search",
AdminGeneralPage: "Admin - General",
AdminImportPage: "Admin - Import",
AdminUsersPage: "Admin - Users",
AdminLogsPage: "Admin - Logs",
}
func (p PageRoute) Title() string {
return pageTitleMap[p]
}
type Page interface { type Page interface {
Route() PageRoute Generate(ctx models.PageContext) (g.Node, error)
Render() g.Node
} }

View File

@ -8,6 +8,7 @@ import (
"reichard.io/antholume/pkg/sliceutils" "reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models" "reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
) )
var _ Page = (*Progress)(nil) var _ Page = (*Progress)(nil)
@ -16,14 +17,15 @@ type Progress struct {
Data []models.Progress Data []models.Progress
} }
func (Progress) Route() PageRoute { return ProgressPage } func (p *Progress) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
func (p Progress) Render() g.Node { ctx.WithRoute(models.ProgressPage),
return h.Div(
h.Class("overflow-x-auto"),
h.Div( h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"), h.Class("overflow-x-auto"),
ui.Table(p.buildTableConfig()), h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
ui.Table(p.buildTableConfig()),
),
), ),
) )
} }

View File

@ -12,6 +12,7 @@ import (
"reichard.io/antholume/web/assets" "reichard.io/antholume/web/assets"
"reichard.io/antholume/web/components/ui" "reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models" "reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
) )
var _ Page = (*Search)(nil) var _ Page = (*Search)(nil)
@ -23,9 +24,14 @@ type Search struct {
Error string Error string
} }
func (Search) Route() PageRoute { return SearchPage } func (p Search) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.SearchPage),
p.content(),
)
}
func (p Search) Render() g.Node { func (p *Search) content() g.Node {
return h.Div( return h.Div(
h.Class("flex flex-col gap-4"), h.Class("flex flex-col gap-4"),
h.Div( h.Div(
@ -96,7 +102,7 @@ func (p Search) Render() g.Node {
) )
} }
func (p Search) tableRows() []ui.TableRow { func (p *Search) tableRows() []ui.TableRow {
return sliceutils.Map(p.Results, func(r models.SearchResult) ui.TableRow { return sliceutils.Map(p.Results, func(r models.SearchResult) ui.TableRow {
return ui.TableRow{ return ui.TableRow{
"": ui.TableCell{ "": ui.TableCell{

184
web/pages/settings.go Normal file
View File

@ -0,0 +1,184 @@
package pages
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/assets"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Settings)(nil)
type Settings struct {
Timezone string
Devices []models.Device
}
func (p *Settings) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.SettingsPage),
h.Div(
h.Class("flex flex-col md:flex-row gap-4"),
h.Div(
h.Div(
h.Class("flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
assets.Icon("user", 60),
h.P(h.Class("text-lg"), g.Text(ctx.UserInfo.Username)),
),
),
h.Div(
h.Class("flex flex-col gap-4 grow"),
p.passwordForm(),
p.timezoneForm(),
p.devicesTable(),
),
),
)
}
func (p Settings) passwordForm() g.Node {
return h.Div(
h.Class("flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
h.P(h.Class("text-lg font-semibold"), g.Text("Change Password")),
h.Form(
h.Class("flex gap-4 flex-col lg:flex-row"),
h.Action("./settings"),
h.Method("POST"),
// Current Password
h.Div(
h.Class("flex grow"),
h.Span(
h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"),
assets.Icon("password", 15),
),
h.Input(
h.Type("password"),
h.ID("password"),
h.Name("password"),
h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"),
h.Placeholder("Password"),
),
),
// New Password
h.Div(
h.Class("flex grow"),
h.Span(
h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"),
assets.Icon("password", 15),
),
h.Input(
h.Type("password"),
h.ID("new_password"),
h.Name("new_password"),
h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"),
h.Placeholder("New Password"),
),
),
// Submit Button
h.Div(
h.Class("lg:w-60"),
ui.FormButton(
g.Text("Submit"),
"",
ui.ButtonConfig{Variant: ui.ButtonVariantSecondary},
),
),
),
)
}
func (p Settings) timezoneForm() g.Node {
tzs := []string{
"Africa/Cairo",
"Africa/Johannesburg",
"Africa/Lagos",
"Africa/Nairobi",
"America/Adak",
"America/Anchorage",
"America/Buenos_Aires",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Mexico_City",
"America/New_York",
"America/Nuuk",
"America/Phoenix",
"America/Puerto_Rico",
"America/Sao_Paulo",
"America/St_Johns",
"America/Toronto",
"Asia/Dubai",
"Asia/Hong_Kong",
"Asia/Kolkata",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Tokyo",
"Atlantic/Azores",
"Australia/Melbourne",
"Australia/Sydney",
"Europe/Berlin",
"Europe/London",
"Europe/Moscow",
"Europe/Paris",
"Pacific/Auckland",
"Pacific/Honolulu",
}
return h.Div(
h.Class("flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
h.P(h.Class("text-lg font-semibold"), g.Text("Change Timezone")),
h.Form(
h.Class("flex gap-4 flex-col lg:flex-row"),
h.Action("./settings"),
h.Method("POST"),
h.Div(
h.Class("flex grow"),
h.Span(
h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"),
assets.Icon("clock", 15),
),
h.Select(
h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"),
h.ID("timezone"),
h.Name("timezone"),
g.Group(g.Map(tzs, func(tz string) g.Node {
return h.Option(
h.Value(tz),
g.If(tz == p.Timezone, h.Selected()),
g.Text(tz),
)
})),
),
),
h.Div(
h.Class("lg:w-60"),
ui.FormButton(
g.Text("Submit"),
"",
ui.ButtonConfig{Variant: ui.ButtonVariantSecondary},
),
),
),
)
}
func (p Settings) devicesTable() g.Node {
return h.Div(
h.Class("flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
h.P(h.Class("text-lg font-semibold"), g.Text("Devices")),
ui.Table(ui.TableConfig{
Columns: []string{"Name", "Last Sync", "Created"},
Rows: sliceutils.Map(p.Devices, func(d models.Device) ui.TableRow {
return ui.TableRow{
"Name": ui.TableCell{String: d.DeviceName},
"Last Sync": ui.TableCell{String: d.LastSynced},
"Created": ui.TableCell{String: d.CreatedAt},
}
}),
}),
)
}