wip
This commit is contained in:
parent
3cff965393
commit
99ccabed58
2
Makefile
2
Makefile
@ -27,7 +27,7 @@ docker_build_release_latest: build_tailwind
|
||||
--push .
|
||||
|
||||
build_tailwind:
|
||||
tailwindcss build -o ./assets/style.css --minify
|
||||
tailwindcss build -o ./assets/tailwind.css --minify
|
||||
|
||||
dev: build_tailwind
|
||||
GIN_MODE=release \
|
||||
|
46
api/api.go
46
api/api.go
@ -136,24 +136,30 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
||||
router.GET("/favicon.ico", api.appFaviconIcon)
|
||||
router.GET("/sw.js", api.appServiceWorker)
|
||||
|
||||
// Local / offline static pages (no template, no auth)
|
||||
// Web App - Offline
|
||||
router.GET("/local", api.appLocalDocuments)
|
||||
|
||||
// Reader (reader page, document progress, devices)
|
||||
// Web App - Reader
|
||||
router.GET("/reader", api.appDocumentReader)
|
||||
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
|
||||
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
||||
|
||||
// Web app
|
||||
router.GET("/", api.authWebAppMiddleware, api.appGetHome)
|
||||
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity)
|
||||
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress)
|
||||
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments)
|
||||
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument)
|
||||
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage))
|
||||
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage))
|
||||
router.GET("/login", api.appGetLogin)
|
||||
// Web App - Templates
|
||||
router.GET("/", api.authWebAppMiddleware, api.appGetHomeNew) // DONE
|
||||
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivityNew) // DONE
|
||||
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgressNew) // DONE
|
||||
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocumentsNew) // DONE
|
||||
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocumentNew) // DONE
|
||||
|
||||
// Web App - Other Routes
|
||||
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE
|
||||
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE
|
||||
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
|
||||
router.POST("/login", api.appAuthLogin) // DONE
|
||||
router.POST("/register", api.appAuthRegister) // DONE
|
||||
|
||||
// TODO
|
||||
router.GET("/login", api.appGetLogin)
|
||||
router.GET("/register", api.appGetRegister)
|
||||
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
|
||||
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
||||
@ -163,8 +169,6 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
||||
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
|
||||
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
||||
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
||||
router.POST("/login", api.appAuthLogin)
|
||||
router.POST("/register", api.appAuthRegister)
|
||||
|
||||
// Demo mode enabled configuration
|
||||
if api.cfg.DemoMode {
|
||||
@ -174,17 +178,19 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
||||
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
|
||||
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
|
||||
} else {
|
||||
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument)
|
||||
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument)
|
||||
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument)
|
||||
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocument)
|
||||
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings)
|
||||
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument) // 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/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE
|
||||
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // TODO
|
||||
}
|
||||
|
||||
// Search enabled configuration
|
||||
if api.cfg.SearchEnabled {
|
||||
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch)
|
||||
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument)
|
||||
router.GET("/search", api.authWebAppMiddleware, api.appGetSearchNew) // WIP
|
||||
|
||||
router.GET("/search-old", api.authWebAppMiddleware, api.appGetSearch) // TODO
|
||||
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,9 +138,10 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
var directories []string
|
||||
for _, item := range rAdminAction.BackupTypes {
|
||||
if item == backupCovers {
|
||||
switch item {
|
||||
case backupCovers:
|
||||
directories = append(directories, "covers")
|
||||
} else if item == backupDocuments {
|
||||
case backupDocuments:
|
||||
directories = append(directories, "documents")
|
||||
}
|
||||
}
|
||||
|
451
api/app-routes-new.go
Normal file
451
api/app-routes-new.go
Normal file
@ -0,0 +1,451 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/metadata"
|
||||
"reichard.io/antholume/pkg/formatters"
|
||||
"reichard.io/antholume/pkg/ptr"
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/search"
|
||||
"reichard.io/antholume/web/components/layout"
|
||||
"reichard.io/antholume/web/components/stats"
|
||||
"reichard.io/antholume/web/models"
|
||||
"reichard.io/antholume/web/pages"
|
||||
)
|
||||
|
||||
func (api *API) appGetHomeNew(c *gin.Context) {
|
||||
_, auth := api.getBaseTemplateVars("home", c)
|
||||
|
||||
start := time.Now()
|
||||
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
|
||||
if err != nil {
|
||||
log.Error("GetDailyReadStats DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
|
||||
|
||||
start = time.Now()
|
||||
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
|
||||
if err != nil {
|
||||
log.Error("GetDatabaseInfo DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
|
||||
|
||||
start = time.Now()
|
||||
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
|
||||
if err != nil {
|
||||
log.Error("GetUserStreaks DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
||||
|
||||
start = time.Now()
|
||||
userStatistics, err := api.db.Queries.GetUserStatistics(c)
|
||||
if err != nil {
|
||||
log.Error("GetUserStatistics DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
||||
|
||||
err = layout.Layout(
|
||||
pages.Home{
|
||||
Leaderboard: arrangeUserStatisticsNew(userStatistics),
|
||||
Streaks: streaks,
|
||||
DailyStats: dailyStats,
|
||||
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) {
|
||||
_, auth := api.getBaseTemplateVars("documents", c)
|
||||
qParams := bindQueryParams(c, 9)
|
||||
|
||||
var query *string
|
||||
if qParams.Search != nil && *qParams.Search != "" {
|
||||
search := "%" + *qParams.Search + "%"
|
||||
query = &search
|
||||
}
|
||||
|
||||
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
|
||||
UserID: auth.UserName,
|
||||
Query: query,
|
||||
Deleted: ptr.Of(false),
|
||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||
Limit: *qParams.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("GetDocumentsWithStats DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
length, err := api.db.Queries.GetDocumentsSize(c, query)
|
||||
if err != nil {
|
||||
log.Error("GetDocumentsSize DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err = api.getDocumentsWordCount(c, documents); err != nil {
|
||||
log.Error("Unable to Get Word Counts: ", err)
|
||||
}
|
||||
|
||||
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
||||
nextPage := *qParams.Page + 1
|
||||
previousPage := *qParams.Page - 1
|
||||
|
||||
err = layout.Layout(
|
||||
pages.Documents{
|
||||
Data: sliceutils.Map(documents, convertDBDocToUI),
|
||||
Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0),
|
||||
Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0),
|
||||
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) {
|
||||
_, auth := api.getBaseTemplateVars("document", c)
|
||||
|
||||
var rDocID requestDocumentID
|
||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||
log.Error("Invalid URI Bind")
|
||||
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||
return
|
||||
}
|
||||
|
||||
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||
if err != nil {
|
||||
log.Error("GetDocument DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
err = layout.Layout(
|
||||
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) {
|
||||
_, auth := api.getBaseTemplateVars("activity", c)
|
||||
qParams := bindQueryParams(c, 15)
|
||||
|
||||
activityFilter := database.GetActivityParams{
|
||||
UserID: auth.UserName,
|
||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||
Limit: *qParams.Limit,
|
||||
}
|
||||
|
||||
if qParams.Document != nil {
|
||||
activityFilter.DocFilter = true
|
||||
activityFilter.DocumentID = *qParams.Document
|
||||
}
|
||||
|
||||
activity, err := api.db.Queries.GetActivity(c, activityFilter)
|
||||
if err != nil {
|
||||
log.Error("GetActivity DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
err = layout.Layout(
|
||||
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) {
|
||||
_, auth := api.getBaseTemplateVars("progress", c)
|
||||
|
||||
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 {
|
||||
log.Error("GetProgress DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
err = layout.Layout(
|
||||
pages.Progress{
|
||||
Data: sliceutils.Map(progress, convertDBProgressToUI),
|
||||
},
|
||||
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) appIdentifyDocumentNew(c *gin.Context) {
|
||||
var rDocID requestDocumentID
|
||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||
log.Error("Invalid URI Bind")
|
||||
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||
return
|
||||
}
|
||||
|
||||
var rDocIdentify requestDocumentIdentify
|
||||
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
||||
log.Error("Invalid Form Bind")
|
||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||
return
|
||||
}
|
||||
|
||||
// Disallow Empty Strings
|
||||
if rDocIdentify.Title != nil && strings.TrimSpace(*rDocIdentify.Title) == "" {
|
||||
rDocIdentify.Title = nil
|
||||
}
|
||||
if rDocIdentify.Author != nil && strings.TrimSpace(*rDocIdentify.Author) == "" {
|
||||
rDocIdentify.Author = nil
|
||||
}
|
||||
if rDocIdentify.ISBN != nil && strings.TrimSpace(*rDocIdentify.ISBN) == "" {
|
||||
rDocIdentify.ISBN = nil
|
||||
}
|
||||
|
||||
// Validate Values
|
||||
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
|
||||
log.Error("Invalid Form")
|
||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||
return
|
||||
}
|
||||
|
||||
// Get Template Variables
|
||||
_, auth := api.getBaseTemplateVars("document", c)
|
||||
|
||||
// Get Metadata
|
||||
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
||||
Title: rDocIdentify.Title,
|
||||
Author: rDocIdentify.Author,
|
||||
ISBN10: rDocIdentify.ISBN,
|
||||
ISBN13: rDocIdentify.ISBN,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Search Metadata Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Metadata Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var errorMsg *string
|
||||
firstResult, found := sliceutils.First(metadataResults)
|
||||
if found {
|
||||
// Store First Metadata Result
|
||||
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
|
||||
DocumentID: rDocID.DocumentID,
|
||||
Title: firstResult.Title,
|
||||
Author: firstResult.Author,
|
||||
Description: firstResult.Description,
|
||||
Gbid: firstResult.SourceID,
|
||||
Olid: nil,
|
||||
Isbn10: firstResult.ISBN10,
|
||||
Isbn13: firstResult.ISBN13,
|
||||
}); err != nil {
|
||||
log.Error("AddMetadata DB Error: ", err)
|
||||
}
|
||||
} else {
|
||||
errorMsg = ptr.Of("No Metadata Found")
|
||||
}
|
||||
|
||||
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||
if err != nil {
|
||||
log.Error("GetDocument DB Error: ", err)
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
err = layout.Layout(
|
||||
pages.Document{
|
||||
Data: convertDBDocToUI(*document),
|
||||
Search: convertMetaToUI(firstResult, errorMsg),
|
||||
},
|
||||
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:
|
||||
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
||||
// - Users
|
||||
// - Metadata
|
||||
func (api *API) appGetSearchNew(c *gin.Context) {
|
||||
_, auth := api.getBaseTemplateVars("search", c)
|
||||
|
||||
var sParams searchParams
|
||||
err := c.BindQuery(&sParams)
|
||||
if err != nil {
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Only Handle Query
|
||||
var searchResults []models.SearchResult
|
||||
var searchError string
|
||||
if sParams.Query != nil && sParams.Source != nil {
|
||||
results, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
||||
if err != nil {
|
||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
||||
return
|
||||
}
|
||||
searchResults = sliceutils.Map(results, convertSearchToUI)
|
||||
} else if sParams.Query != nil || sParams.Source != nil {
|
||||
searchError = "Invailid Query"
|
||||
}
|
||||
|
||||
err = layout.Layout(
|
||||
pages.Search{
|
||||
Results: searchResults,
|
||||
Source: ptr.Deref(sParams.Source),
|
||||
Query: ptr.Deref(sParams.Query),
|
||||
Error: searchError,
|
||||
},
|
||||
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 sortItem[T cmp.Ordered](
|
||||
data []database.GetUserStatisticsRow,
|
||||
accessor func(s database.GetUserStatisticsRow) T,
|
||||
formatter func(s T) string,
|
||||
) []stats.LeaderboardItem {
|
||||
sort.SliceStable(data, func(i, j int) bool {
|
||||
return accessor(data[i]) > accessor(data[j])
|
||||
})
|
||||
|
||||
var items []stats.LeaderboardItem
|
||||
for _, s := range data {
|
||||
items = append(items, stats.LeaderboardItem{
|
||||
UserID: s.UserID,
|
||||
Value: formatter(accessor(s)),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func arrangeUserStatisticsNew(data []database.GetUserStatisticsRow) []stats.LeaderboardData {
|
||||
wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) }
|
||||
return []stats.LeaderboardData{
|
||||
{
|
||||
Name: "WPM",
|
||||
All: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.TotalWpm }, wpmFormatter),
|
||||
Year: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.YearlyWpm }, wpmFormatter),
|
||||
Month: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.MonthlyWpm }, wpmFormatter),
|
||||
Week: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.WeeklyWpm }, wpmFormatter),
|
||||
},
|
||||
{
|
||||
Name: "Words",
|
||||
All: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.TotalWordsRead }, formatters.FormatNumber),
|
||||
Year: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.YearlyWordsRead }, formatters.FormatNumber),
|
||||
Month: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.MonthlyWordsRead }, formatters.FormatNumber),
|
||||
Week: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.WeeklyWordsRead }, formatters.FormatNumber),
|
||||
},
|
||||
{
|
||||
Name: "Duration",
|
||||
All: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
||||
return time.Duration(r.TotalSeconds) * time.Second
|
||||
}, formatters.FormatDuration),
|
||||
Year: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
||||
return time.Duration(r.YearlySeconds) * time.Second
|
||||
}, formatters.FormatDuration),
|
||||
Month: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
||||
return time.Duration(r.MonthlySeconds) * time.Second
|
||||
}, formatters.FormatDuration),
|
||||
Week: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
|
||||
return time.Duration(r.WeeklySeconds) * time.Second
|
||||
}, formatters.FormatDuration),
|
||||
},
|
||||
}
|
||||
}
|
@ -654,7 +654,7 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
|
||||
templateVars, auth := api.getBaseTemplateVars("document", c)
|
||||
|
||||
// Get Metadata
|
||||
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
||||
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
||||
Title: rDocIdentify.Title,
|
||||
Author: rDocIdentify.Author,
|
||||
ISBN10: rDocIdentify.ISBN,
|
||||
@ -669,7 +669,7 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
|
||||
Title: firstResult.Title,
|
||||
Author: firstResult.Author,
|
||||
Description: firstResult.Description,
|
||||
Gbid: firstResult.ID,
|
||||
Gbid: firstResult.SourceID,
|
||||
Olid: nil,
|
||||
Isbn10: firstResult.ISBN10,
|
||||
Isbn13: firstResult.ISBN13,
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -464,9 +465,7 @@ func (api *API) rotateAllAuthHashes(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Transaction Succeeded -> Update Cache
|
||||
for user, hash := range newAuthHashCache {
|
||||
api.userAuthCache[user] = hash
|
||||
}
|
||||
maps.Copy(api.userAuthCache, newAuthHashCache)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -98,20 +98,20 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
|
||||
}
|
||||
|
||||
// Attempt Metadata
|
||||
var coverDir string = filepath.Join(api.cfg.DataPath, "covers")
|
||||
var coverFile string = "UNKNOWN"
|
||||
coverDir := filepath.Join(api.cfg.DataPath, "covers")
|
||||
coverFile := "UNKNOWN"
|
||||
|
||||
// Identify Documents & Save Covers
|
||||
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
||||
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
||||
Title: document.Title,
|
||||
Author: document.Author,
|
||||
})
|
||||
|
||||
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
|
||||
if err == nil && len(metadataResults) > 0 && metadataResults[0].SourceID != nil {
|
||||
firstResult := metadataResults[0]
|
||||
|
||||
// Save Cover
|
||||
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
|
||||
fileName, err := metadata.CacheCover(*firstResult.SourceID, coverDir, document.ID, false)
|
||||
if err == nil {
|
||||
coverFile = *fileName
|
||||
}
|
||||
@ -122,7 +122,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
|
||||
Title: firstResult.Title,
|
||||
Author: firstResult.Author,
|
||||
Description: firstResult.Description,
|
||||
Gbid: firstResult.ID,
|
||||
Gbid: firstResult.SourceID,
|
||||
Olid: nil,
|
||||
Isbn10: firstResult.ISBN10,
|
||||
Isbn13: firstResult.ISBN13,
|
||||
|
76
api/convert.go
Normal file
76
api/convert.go
Normal file
@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/metadata"
|
||||
"reichard.io/antholume/pkg/ptr"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/search"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
func convertDBDocToUI(r database.GetDocumentsWithStatsRow) models.Document {
|
||||
return models.Document{
|
||||
ID: r.ID,
|
||||
Title: ptr.Deref(r.Title),
|
||||
Author: ptr.Deref(r.Author),
|
||||
ISBN10: ptr.Deref(r.Isbn10),
|
||||
ISBN13: ptr.Deref(r.Isbn13),
|
||||
Description: ptr.Deref(r.Description),
|
||||
Percentage: r.Percentage,
|
||||
WPM: r.Wpm,
|
||||
Words: r.Words,
|
||||
TotalTimeRead: time.Duration(r.TotalTimeSeconds) * time.Second,
|
||||
TimePerPercent: time.Duration(r.SecondsPerPercent) * time.Second,
|
||||
HasFile: ptr.Deref(r.Filepath) != "",
|
||||
}
|
||||
}
|
||||
|
||||
func convertMetaToUI(m metadata.MetadataInfo, errorMsg *string) *models.DocumentMetadata {
|
||||
return &models.DocumentMetadata{
|
||||
SourceID: ptr.Deref(m.SourceID),
|
||||
ISBN10: ptr.Deref(m.ISBN10),
|
||||
ISBN13: ptr.Deref(m.ISBN13),
|
||||
Title: ptr.Deref(m.Title),
|
||||
Author: ptr.Deref(m.Author),
|
||||
Description: ptr.Deref(m.Description),
|
||||
Source: m.Source,
|
||||
Error: errorMsg,
|
||||
}
|
||||
}
|
||||
|
||||
func convertDBActivityToUI(r database.GetActivityRow) models.Activity {
|
||||
return models.Activity{
|
||||
ID: r.DocumentID,
|
||||
Author: utils.FirstNonZero(ptr.Deref(r.Author), "N/A"),
|
||||
Title: utils.FirstNonZero(ptr.Deref(r.Title), "N/A"),
|
||||
StartTime: r.StartTime,
|
||||
Duration: time.Duration(r.Duration) * time.Second,
|
||||
Percentage: r.EndPercentage,
|
||||
}
|
||||
}
|
||||
|
||||
func convertDBProgressToUI(r database.GetProgressRow) models.Progress {
|
||||
return models.Progress{
|
||||
ID: r.DocumentID,
|
||||
Author: utils.FirstNonZero(ptr.Deref(r.Author), "N/A"),
|
||||
Title: utils.FirstNonZero(ptr.Deref(r.Title), "N/A"),
|
||||
DeviceName: r.DeviceName,
|
||||
Percentage: r.Percentage,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func convertSearchToUI(r search.SearchItem) models.SearchResult {
|
||||
return models.SearchResult{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
Author: r.Author,
|
||||
Series: r.Series,
|
||||
FileType: r.FileType,
|
||||
FileSize: r.FileSize,
|
||||
UploadDate: r.UploadDate,
|
||||
}
|
||||
}
|
116
assets/index.css
Normal file
116
assets/index.css
Normal file
@ -0,0 +1,116 @@
|
||||
/* ----------------------------- */
|
||||
/* -------- PWA Styling -------- */
|
||||
/* ----------------------------- */
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
html {
|
||||
height: calc(100% + env(safe-area-inset-bottom));
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
||||
env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
main {
|
||||
height: calc(100dvh - 4rem - env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
#container {
|
||||
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
|
||||
}
|
||||
|
||||
/* No Scrollbar - IE, Edge, Firefox */
|
||||
* {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* No Scrollbar - WebKit */
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ----------------------------- */
|
||||
/* -------- CSS Button -------- */
|
||||
/* ----------------------------- */
|
||||
.css-button:checked + div {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.css-button + div {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ----------------------------- */
|
||||
/* ------- User Dropdown ------- */
|
||||
/* ----------------------------- */
|
||||
#user-dropdown-button:checked + #user-dropdown {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#user-dropdown {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ----------------------------- */
|
||||
/* ----- Mobile Navigation ----- */
|
||||
/* ----------------------------- */
|
||||
#mobile-nav-button span {
|
||||
transform-origin: 5px 0px;
|
||||
transition:
|
||||
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
opacity 0.55s ease;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:first-child {
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:nth-last-child(2) {
|
||||
transform-origin: 0% 100%;
|
||||
}
|
||||
|
||||
#mobile-nav-button input:checked ~ span {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) translate(2px, -2px);
|
||||
}
|
||||
|
||||
#mobile-nav-button input:checked ~ span:nth-last-child(3) {
|
||||
opacity: 0;
|
||||
transform: rotate(0deg) scale(0.2, 0.2);
|
||||
}
|
||||
|
||||
#mobile-nav-button input:checked ~ span:nth-last-child(2) {
|
||||
transform: rotate(-45deg) translate(0, 6px);
|
||||
}
|
||||
|
||||
#mobile-nav-button input:checked ~ div {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#mobile-nav-button input ~ div {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#menu {
|
||||
top: 0;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
transform-origin: 0% 0%;
|
||||
transform: translate(-100%, 0);
|
||||
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#menu {
|
||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
||||
}
|
||||
}
|
@ -25,7 +25,7 @@
|
||||
<title>AnthoLume - Local</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="stylesheet" href="/assets/style.css" />
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
|
||||
<!-- Libraries -->
|
||||
<script src="/assets/lib/jszip.min.js"></script>
|
||||
|
@ -17,7 +17,7 @@
|
||||
<title>AnthoLume - Reader</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="stylesheet" href="/assets/style.css" />
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
|
||||
<!-- Libraries -->
|
||||
<script src="/assets/lib/jszip.min.js"></script>
|
||||
@ -82,9 +82,13 @@
|
||||
id="top-bar"
|
||||
class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2"
|
||||
>
|
||||
<div class="max-h-[75vh] w-full flex flex-col items-center justify-around relative dark:text-white">
|
||||
<div class="h-32">
|
||||
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
||||
<div
|
||||
class="max-h-[75vh] w-full flex flex-col items-center justify-around relative dark:text-white"
|
||||
>
|
||||
<div class="h-32">
|
||||
<div
|
||||
class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4"
|
||||
>
|
||||
<a href="#">
|
||||
<svg
|
||||
width="32"
|
||||
@ -152,8 +156,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toc" class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"></div>
|
||||
</div>
|
||||
<div
|
||||
id="toc"
|
||||
class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -72,7 +72,8 @@ const PRECACHE_ASSETS = [
|
||||
// Main App Assets
|
||||
"/manifest.json",
|
||||
"/assets/index.js",
|
||||
"/assets/style.css",
|
||||
"/assets/index.css",
|
||||
"/assets/tailwind.css",
|
||||
"/assets/common.js",
|
||||
|
||||
// Library Assets
|
||||
|
1
assets/tailwind.css
Normal file
1
assets/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
@ -67,7 +67,7 @@ WITH filtered_activity AS (
|
||||
SELECT
|
||||
document_id,
|
||||
device_id,
|
||||
LOCAL_TIME(activity.start_time, users.timezone) AS start_time,
|
||||
CAST(LOCAL_TIME(activity.start_time, users.timezone) AS TEXT) AS start_time,
|
||||
title,
|
||||
author,
|
||||
duration,
|
||||
@ -246,7 +246,7 @@ SELECT
|
||||
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||
progress.document_id,
|
||||
progress.user_id,
|
||||
LOCAL_TIME(progress.created_at, users.timezone) AS created_at
|
||||
CAST(LOCAL_TIME(progress.created_at, users.timezone) AS TEXT) AS created_at
|
||||
FROM document_progress AS progress
|
||||
LEFT JOIN users ON progress.user_id = users.id
|
||||
LEFT JOIN devices ON progress.device_id = devices.id
|
||||
|
@ -193,7 +193,7 @@ WITH filtered_activity AS (
|
||||
SELECT
|
||||
document_id,
|
||||
device_id,
|
||||
LOCAL_TIME(activity.start_time, users.timezone) AS start_time,
|
||||
CAST(LOCAL_TIME(activity.start_time, users.timezone) AS TEXT) AS start_time,
|
||||
title,
|
||||
author,
|
||||
duration,
|
||||
@ -214,15 +214,15 @@ type GetActivityParams struct {
|
||||
}
|
||||
|
||||
type GetActivityRow struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
StartTime interface{} `json:"start_time"`
|
||||
Title *string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
Duration int64 `json:"duration"`
|
||||
StartPercentage float64 `json:"start_percentage"`
|
||||
EndPercentage float64 `json:"end_percentage"`
|
||||
ReadPercentage float64 `json:"read_percentage"`
|
||||
DocumentID string `json:"document_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
StartTime string `json:"start_time"`
|
||||
Title *string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
Duration int64 `json:"duration"`
|
||||
StartPercentage float64 `json:"start_percentage"`
|
||||
EndPercentage float64 `json:"end_percentage"`
|
||||
ReadPercentage float64 `json:"read_percentage"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
||||
@ -824,7 +824,7 @@ SELECT
|
||||
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||
progress.document_id,
|
||||
progress.user_id,
|
||||
LOCAL_TIME(progress.created_at, users.timezone) AS created_at
|
||||
CAST(LOCAL_TIME(progress.created_at, users.timezone) AS TEXT) AS created_at
|
||||
FROM document_progress AS progress
|
||||
LEFT JOIN users ON progress.user_id = users.id
|
||||
LEFT JOIN devices ON progress.device_id = devices.id
|
||||
@ -851,13 +851,13 @@ type GetProgressParams struct {
|
||||
}
|
||||
|
||||
type GetProgressRow struct {
|
||||
Title *string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
DocumentID string `json:"document_id"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt interface{} `json:"created_at"`
|
||||
Title *string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
DocumentID string `json:"document_id"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {
|
||||
|
1
go.mod
1
go.mod
@ -74,6 +74,7 @@ require (
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
maragu.dev/gomponents v1.1.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.17.0 // indirect
|
||||
modernc.org/libc v1.66.6 // indirect
|
||||
|
2
go.sum
2
go.sum
@ -485,6 +485,8 @@ howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U=
|
||||
maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
|
||||
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
||||
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
||||
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
||||
|
@ -28,7 +28,7 @@ type SVGBezierOpposedLine struct {
|
||||
|
||||
func GetSVGGraphData(inputData []int64, svgWidth int, svgHeight int) SVGGraphData {
|
||||
// Derive Height
|
||||
var maxHeight int = 0
|
||||
var maxHeight int
|
||||
for _, item := range inputData {
|
||||
if int(item) > maxHeight {
|
||||
maxHeight = int(item)
|
||||
@ -39,19 +39,19 @@ func GetSVGGraphData(inputData []int64, svgWidth int, svgHeight int) SVGGraphDat
|
||||
var sizePercentage float32 = 0.5
|
||||
|
||||
// Scale Ratio -> Desired Height
|
||||
var sizeRatio float32 = float32(svgHeight) * sizePercentage / float32(maxHeight)
|
||||
sizeRatio := float32(svgHeight) * sizePercentage / float32(maxHeight)
|
||||
|
||||
// Point Block Offset
|
||||
var blockOffset int = int(math.Floor(float64(svgWidth) / float64(len(inputData))))
|
||||
blockOffset := int(math.Floor(float64(svgWidth) / float64(len(inputData))))
|
||||
|
||||
// Line & Bar Points
|
||||
linePoints := []SVGGraphPoint{}
|
||||
barPoints := []SVGGraphPoint{}
|
||||
|
||||
// Bezier Fill Coordinates (Max X, Min X, Max Y)
|
||||
var maxBX int = 0
|
||||
var maxBY int = 0
|
||||
var minBX int = 0
|
||||
var maxBX int
|
||||
var maxBY int
|
||||
var minBX int
|
||||
for idx, item := range inputData {
|
||||
itemSize := int(float32(item) * sizeRatio)
|
||||
itemY := svgHeight - itemSize
|
||||
@ -98,7 +98,7 @@ func getSVGBezierOpposedLine(pointA SVGGraphPoint, pointB SVGGraphPoint) SVGBezi
|
||||
lengthY := float64(pointB.Y - pointA.Y)
|
||||
|
||||
return SVGBezierOpposedLine{
|
||||
Length: int(math.Sqrt(math.Pow(lengthX, 2) + math.Pow(lengthY, 2))),
|
||||
Length: int(math.Sqrt(lengthX*lengthX + lengthY*lengthY)),
|
||||
Angle: int(math.Atan2(lengthY, lengthX)),
|
||||
}
|
||||
}
|
||||
@ -113,15 +113,15 @@ func getSVGBezierControlPoint(currentPoint *SVGGraphPoint, prevPoint *SVGGraphPo
|
||||
}
|
||||
|
||||
// Modifiers
|
||||
var smoothingRatio float64 = 0.2
|
||||
smoothingRatio := 0.2
|
||||
var directionModifier float64 = 0
|
||||
if isReverse {
|
||||
directionModifier = math.Pi
|
||||
}
|
||||
|
||||
opposingLine := getSVGBezierOpposedLine(*prevPoint, *nextPoint)
|
||||
var lineAngle float64 = float64(opposingLine.Angle) + directionModifier
|
||||
var lineLength float64 = float64(opposingLine.Length) * smoothingRatio
|
||||
lineAngle := float64(opposingLine.Angle) + directionModifier
|
||||
lineLength := float64(opposingLine.Length) * smoothingRatio
|
||||
|
||||
// Calculate Control Point
|
||||
return SVGGraphPoint{
|
||||
@ -156,7 +156,7 @@ func getSVGBezierCurve(point SVGGraphPoint, index int, allPoints []SVGGraphPoint
|
||||
}
|
||||
|
||||
func getSVGBezierPath(allPoints []SVGGraphPoint) string {
|
||||
var bezierSVGPath string = ""
|
||||
var bezierSVGPath string
|
||||
|
||||
for index, point := range allPoints {
|
||||
if index == 0 {
|
||||
|
@ -41,9 +41,9 @@ const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/ima
|
||||
|
||||
func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||
var queryResults []gBooksQueryItem
|
||||
if metadataSearch.ID != nil {
|
||||
if metadataSearch.SourceID != nil {
|
||||
// Use GBID
|
||||
resp, err := performGBIDRequest(*metadataSearch.ID)
|
||||
resp, err := performGBIDRequest(*metadataSearch.SourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -83,15 +83,16 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||
|
||||
queryResults = resp.Items
|
||||
} else {
|
||||
return nil, errors.New("Invalid Data")
|
||||
return nil, errors.New("invalid data")
|
||||
}
|
||||
|
||||
// Normalize Data
|
||||
allMetadata := []MetadataInfo{}
|
||||
var allMetadata []MetadataInfo
|
||||
for i := range queryResults {
|
||||
item := queryResults[i] // Range Value Pointer Issue
|
||||
itemResult := MetadataInfo{
|
||||
ID: &item.ID,
|
||||
SourceID: &item.ID,
|
||||
Source: SourceGoogleBooks,
|
||||
Title: &item.Info.Title,
|
||||
Description: &item.Info.Description,
|
||||
}
|
||||
@ -130,7 +131,7 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
||||
out, err := os.Create(coverFilePath)
|
||||
if err != nil {
|
||||
log.Error("File Create Error")
|
||||
return errors.New("File Failure")
|
||||
return errors.New("file failure")
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
@ -149,7 +150,7 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
log.Error("File Copy Error")
|
||||
return errors.New("File Failure")
|
||||
return errors.New("file failure")
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -164,18 +165,13 @@ func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
||||
return nil, errors.New("API Failure")
|
||||
}
|
||||
|
||||
parsedResp := gBooksQueryResponse{}
|
||||
var parsedResp gBooksQueryResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
||||
if err != nil {
|
||||
log.Error("Google Books Query API Decode Failure")
|
||||
return nil, errors.New("API Failure")
|
||||
}
|
||||
|
||||
if len(parsedResp.Items) == 0 {
|
||||
log.Warn("No Results")
|
||||
return nil, errors.New("No Results")
|
||||
}
|
||||
|
||||
return &parsedResp, nil
|
||||
}
|
||||
|
||||
|
@ -65,7 +65,7 @@ func TestGBooksGBIDMetadata(t *testing.T) {
|
||||
|
||||
GBID := "ZxwpakTv_MIC"
|
||||
expectedURL := fmt.Sprintf(GBOOKS_GBID_INFO_URL, GBID)
|
||||
metadataResp, err := getGBooksMetadata(MetadataInfo{ID: &GBID})
|
||||
metadataResp, err := getGBooksMetadata(MetadataInfo{SourceID: &GBID})
|
||||
|
||||
assert.Nil(t, err, "should not have error")
|
||||
assert.Contains(t, hookDetails.URLs, expectedURL, "should have intercepted URL")
|
||||
|
@ -25,12 +25,12 @@ var extensionHandlerMap = map[DocumentType]MetadataHandler{
|
||||
type Source int
|
||||
|
||||
const (
|
||||
SOURCE_GBOOK Source = iota
|
||||
SOURCE_OLIB
|
||||
SourceGoogleBooks Source = iota
|
||||
SourceOpenLibrary
|
||||
)
|
||||
|
||||
type MetadataInfo struct {
|
||||
ID *string
|
||||
SourceID *string
|
||||
MD5 *string
|
||||
PartialMD5 *string
|
||||
WordCount *int64
|
||||
@ -41,6 +41,7 @@ type MetadataInfo struct {
|
||||
ISBN10 *string
|
||||
ISBN13 *string
|
||||
Type DocumentType
|
||||
Source Source
|
||||
}
|
||||
|
||||
// Downloads the Google Books cover file and saves it to the provided directory.
|
||||
@ -62,9 +63,9 @@ func CacheCover(gbid string, coverDir string, documentID string, overwrite bool)
|
||||
// Searches source for metadata based on the provided information.
|
||||
func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||
switch s {
|
||||
case SOURCE_GBOOK:
|
||||
case SourceGoogleBooks:
|
||||
return getGBooksMetadata(metadataSearch)
|
||||
case SOURCE_OLIB:
|
||||
case SourceOpenLibrary:
|
||||
return nil, errors.New("not implemented")
|
||||
default:
|
||||
return nil, errors.New("not implemented")
|
||||
|
@ -37,7 +37,7 @@ func getLibraryDownloadURL(md5 string, source Source) ([]string, error) {
|
||||
// Derive Info URL
|
||||
var infoURL string
|
||||
switch source {
|
||||
case SOURCE_LIBGEN, SOURCE_ANNAS_ARCHIVE:
|
||||
case SourceLibGen, SourceAnnasArchive:
|
||||
infoURL = "http://library.lol/fiction/" + md5
|
||||
// case SOURCE_LIBGEN_NON_FICTION:
|
||||
// infoURL = "http://library.lol/main/" + md5
|
||||
|
@ -25,8 +25,8 @@ const (
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SOURCE_ANNAS_ARCHIVE Source = "Annas Archive"
|
||||
SOURCE_LIBGEN Source = "LibGen"
|
||||
SourceAnnasArchive Source = "Annas Archive"
|
||||
SourceLibGen Source = "LibGen"
|
||||
)
|
||||
|
||||
type SearchItem struct {
|
||||
@ -44,8 +44,8 @@ type searchFunc func(query string) (searchResults []SearchItem, err error)
|
||||
type downloadFunc func(md5 string, source Source) (downloadURL []string, err error)
|
||||
|
||||
var searchDefs = map[Source]searchFunc{
|
||||
SOURCE_ANNAS_ARCHIVE: searchAnnasArchive,
|
||||
SOURCE_LIBGEN: searchLibGen,
|
||||
SourceAnnasArchive: searchAnnasArchive,
|
||||
SourceLibGen: searchLibGen,
|
||||
}
|
||||
|
||||
var downloadFuncs = []downloadFunc{
|
||||
|
@ -2,6 +2,7 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./templates/**/*.{tmpl,html,htm,svg}",
|
||||
"./web/**/*.go",
|
||||
"./assets/local/*.{html,htm,svg,js}",
|
||||
"./assets/reader/*.{html,htm,svg,js}",
|
||||
],
|
||||
|
@ -43,7 +43,7 @@
|
||||
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||
<img
|
||||
class="rounded object-fill h-32"
|
||||
src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690"
|
||||
src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.SourceID }}?fife=w480-h690"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
@ -123,7 +123,7 @@
|
||||
type="text"
|
||||
id="cover_gbid"
|
||||
name="cover_gbid"
|
||||
value="{{ .Metadata.ID }}"
|
||||
value="{{ .Metadata.SourceID }}"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
20
web/assets/embed.go
Normal file
20
web/assets/embed.go
Normal file
@ -0,0 +1,20 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
//go:embed svgs/*
|
||||
var assets embed.FS
|
||||
|
||||
func Asset(name string) g.Node {
|
||||
b, err := fs.ReadFile(assets, name)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
return g.Raw(string(b))
|
||||
}
|
18
web/assets/icons.go
Normal file
18
web/assets/icons.go
Normal file
@ -0,0 +1,18 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Icon(name string, size int) g.Node {
|
||||
return h.SVG(
|
||||
g.Attr("width", strconv.Itoa(size)),
|
||||
g.Attr("height", strconv.Itoa(size)),
|
||||
g.Attr("viewBox", "0 0 24 24"),
|
||||
g.Attr("fill", "currentColor"),
|
||||
Asset("svgs/"+name+".svg"),
|
||||
)
|
||||
}
|
2
web/assets/svgs/activity.svg
Normal file
2
web/assets/svgs/activity.svg
Normal file
@ -0,0 +1,2 @@
|
||||
<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"/>
|
5
web/assets/svgs/add.svg
Normal file
5
web/assets/svgs/add.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<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 8.25C12.4142 8.25 12.75 8.58579 12.75 9V11.25H15C15.4142 11.25 15.75 11.5858 15.75 12C15.75 12.4142 15.4142 12.75 15 12.75H12.75L12.75 15C12.75 15.4142 12.4142 15.75 12 15.75C11.5858 15.75 11.25 15.4142 11.25 15V12.75H9C8.58579 12.75 8.25 12.4142 8.25 12C8.25 11.5858 8.58579 11.25 9 11.25H11.25L11.25 9C11.25 8.58579 11.5858 8.25 12 8.25Z"
|
||||
/>
|
9
web/assets/svgs/clock.svg
Normal file
9
web/assets/svgs/clock.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<path
|
||||
d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z"
|
||||
fill="white"
|
||||
/>
|
6
web/assets/svgs/delete.svg
Normal file
6
web/assets/svgs/delete.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<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"
|
||||
/>
|
2
web/assets/svgs/documents.svg
Normal file
2
web/assets/svgs/documents.svg
Normal file
@ -0,0 +1,2 @@
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.27103 2.11151C5.46135 2.21816 5.03258 2.41324 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.82716 15.513 6.44305 15.5132 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.2092 2 7.10452 2.00172 6.27103 2.11151ZM6.75862 6.59459C6.75862 6.1468 7.12914 5.78378 7.58621 5.78378H16.4138C16.8709 5.78378 17.2414 6.1468 17.2414 6.59459C17.2414 7.04239 16.8709 7.40541 16.4138 7.40541H7.58621C7.12914 7.40541 6.75862 7.04239 6.75862 6.59459ZM7.58621 9.56757C7.12914 9.56757 6.75862 9.93058 6.75862 10.3784C6.75862 10.8262 7.12914 11.1892 7.58621 11.1892H13.1034C13.5605 11.1892 13.931 10.8262 13.931 10.3784C13.931 9.93058 13.5605 9.56757 13.1034 9.56757H7.58621Z" />
|
||||
<path d="M7.47341 17.1351H8.68965H13.1034H19.9991C19.9956 18.2657 19.9776 19.1088 19.8862 19.775C19.7773 20.5683 19.5782 20.9884 19.2728 21.2876C18.9674 21.5868 18.5387 21.7818 17.729 21.8885C16.8955 21.9983 15.7908 22 14.2069 22H9.7931C8.2092 22 7.10452 21.9983 6.27103 21.8885C5.46135 21.7818 5.03258 21.5868 4.72718 21.2876C4.42179 20.9884 4.22268 20.5683 4.11382 19.775C4.07259 19.4746 4.0463 19.1382 4.02952 18.7558C4.30088 18.0044 4.93365 17.4264 5.72738 17.218C6.01657 17.1421 6.39395 17.1351 7.47341 17.1351Z" />
|
5
web/assets/svgs/download.svg
Normal file
5
web/assets/svgs/download.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<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"
|
||||
/>
|
1
web/assets/svgs/dropdown.svg
Normal file
1
web/assets/svgs/dropdown.svg
Normal file
@ -0,0 +1 @@
|
||||
<path fill-rule="nonzero" fill-opacity="1" d="M 18.855469 9.429688 C 18.855469 9.660156 18.773438 9.863281 18.601562 10.03125 L 12.601562 16.03125 C 12.433594 16.199219 12.230469 16.285156 12 16.285156 C 11.769531 16.285156 11.566406 16.199219 11.398438 16.03125 L 5.398438 10.03125 C 5.226562 9.863281 5.144531 9.660156 5.144531 9.429688 C 5.144531 9.195312 5.226562 8.996094 5.398438 8.824219 C 5.566406 8.65625 5.769531 8.570312 6 8.570312 L 18 8.570312 C 18.230469 8.570312 18.433594 8.65625 18.601562 8.824219 C 18.773438 8.996094 18.855469 9.195312 18.855469 9.429688 Z M 18.855469 9.429688 "/>
|
9
web/assets/svgs/edit.svg
Normal file
9
web/assets/svgs/edit.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<path
|
||||
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
|
||||
/>
|
||||
<path
|
||||
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
|
||||
/>
|
||||
<path
|
||||
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
|
||||
/>
|
21
web/assets/svgs/gitea.svg
Normal file
21
web/assets/svgs/gitea.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<defs>
|
||||
<clipPath id="clip-0">
|
||||
<path clip-rule="nonzero" d="M 17.425781 0.207031 L 20.164062 0.207031 L 20.164062 18 L 17.425781 18 Z M 17.425781 0.207031 "/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-1">
|
||||
<path clip-rule="nonzero" d="M 20.054688 2.347656 L 23.929688 2.347656 L 23.929688 18 L 20.054688 18 Z M 20.054688 2.347656 "/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-2">
|
||||
<path clip-rule="nonzero" d="M 0 0.207031 L 10 0.207031 L 10 24 L 0 24 Z M 0 0.207031 "/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 14.34375 8.304688 C 13.816406 8.304688 13.425781 8.917969 13.425781 10.394531 C 13.425781 11.503906 13.683594 12.277344 14.3125 12.277344 C 14.847656 12.277344 15.210938 11.53125 15.210938 10.347656 C 15.210938 9.007812 14.886719 8.304688 14.34375 8.304688 Z M 13.292969 18.726562 C 13.167969 19.089844 13.046875 19.476562 13.046875 19.929688 C 13.046875 20.835938 13.53125 21.109375 14.199219 21.109375 C 14.753906 21.109375 15.507812 21.019531 15.507812 19.792969 C 15.507812 19.066406 15.144531 19.019531 14.683594 18.953125 Z M 16.117188 8.375 C 16.289062 8.894531 16.46875 9.621094 16.46875 10.667969 C 16.46875 13.1875 15.640625 14.664062 14.4375 14.664062 C 14.132812 14.664062 13.855469 14.570312 13.683594 14.457031 L 13.371094 15.660156 L 14.304688 15.796875 C 15.953125 16.046875 16.925781 16.160156 16.925781 19.179688 C 16.925781 21.789062 15.964844 23.265625 14.304688 23.265625 C 12.578125 23.265625 11.917969 22.222656 11.917969 20.429688 C 11.917969 19.40625 12.109375 18.863281 12.445312 18.113281 C 12.128906 17.796875 12.023438 17.230469 12.023438 16.613281 C 12.023438 16.117188 12.128906 15.660156 12.300781 15.230469 C 12.472656 14.796875 12.664062 14.367188 12.894531 13.867188 C 12.425781 13.324219 12.074219 12.140625 12.074219 10.460938 C 12.074219 7.851562 12.796875 6.058594 14.257812 6.058594 C 14.667969 6.058594 14.914062 6.148438 15.132812 6.285156 L 16.992188 6.285156 L 16.992188 8.214844 L 16.117188 8.375 "/>
|
||||
<g clip-path="url(#clip-0)">
|
||||
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 18.671875 4.246094 C 18.128906 4.246094 17.8125 3.5 17.8125 2.203125 C 17.8125 0.910156 18.128906 0.207031 18.671875 0.207031 C 19.226562 0.207031 19.539062 0.910156 19.539062 2.203125 C 19.539062 3.5 19.226562 4.246094 18.671875 4.246094 Z M 17.441406 17.890625 L 17.441406 16.097656 L 17.925781 15.941406 C 18.0625 15.894531 18.082031 15.828125 18.082031 15.484375 L 18.082031 8.808594 C 18.082031 8.5625 18.050781 8.402344 17.957031 8.335938 L 17.441406 7.902344 L 17.546875 6.0625 L 19.519531 6.0625 L 19.519531 15.484375 C 19.519531 15.851562 19.53125 15.894531 19.671875 15.941406 L 20.160156 16.097656 L 20.160156 17.890625 L 17.441406 17.890625 "/>
|
||||
</g>
|
||||
<g clip-path="url(#clip-1)">
|
||||
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 23.929688 17.011719 C 23.519531 17.488281 22.921875 17.917969 22.375 17.917969 C 21.242188 17.917969 20.8125 16.832031 20.8125 14.261719 L 20.8125 8.316406 C 20.8125 8.179688 20.8125 8.089844 20.734375 8.089844 L 20.066406 8.089844 L 20.066406 6.066406 C 20.90625 5.839844 21.242188 4.839844 21.347656 2.367188 L 22.253906 2.367188 L 22.253906 5.589844 C 22.253906 5.75 22.253906 5.820312 22.328125 5.820312 L 23.671875 5.820312 L 23.671875 8.089844 L 22.253906 8.089844 L 22.253906 13.515625 C 22.253906 14.855469 22.386719 15.375 22.902344 15.375 C 23.167969 15.375 23.445312 15.21875 23.671875 15.011719 L 23.929688 17.011719 "/>
|
||||
</g>
|
||||
<g clip-path="url(#clip-2)">
|
||||
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 9.800781 11.054688 L 5.4375 0.671875 C 5.1875 0.0742188 4.78125 0.0742188 4.527344 0.671875 L 3.625 2.828125 L 4.773438 5.5625 C 5.046875 5.339844 5.351562 5.507812 5.558594 6 C 5.765625 6.492188 5.835938 7.222656 5.738281 7.882812 L 6.847656 10.515625 C 7.125 10.289062 7.429688 10.457031 7.636719 10.949219 C 7.78125 11.289062 7.863281 11.753906 7.863281 12.238281 C 7.863281 12.722656 7.78125 13.183594 7.636719 13.527344 C 7.492188 13.867188 7.300781 14.058594 7.097656 14.058594 C 6.894531 14.058594 6.699219 13.867188 6.554688 13.527344 C 6.335938 13.003906 6.269531 12.222656 6.386719 11.542969 L 5.355469 9.085938 L 5.355469 15.554688 C 5.429688 15.640625 5.5 15.757812 5.558594 15.898438 C 5.855469 16.609375 5.855469 17.765625 5.558594 18.476562 C 5.257812 19.1875 4.773438 19.1875 4.476562 18.476562 C 4.332031 18.132812 4.25 17.671875 4.25 17.1875 C 4.25 16.703125 4.332031 16.242188 4.476562 15.898438 C 4.546875 15.726562 4.632812 15.59375 4.726562 15.5 L 4.726562 8.972656 C 4.632812 8.882812 4.546875 8.746094 4.476562 8.574219 C 4.257812 8.054688 4.191406 7.265625 4.3125 6.582031 L 3.179688 3.886719 L 0.1875 11.003906 C -0.0625 11.601562 -0.0625 12.574219 0.1875 13.171875 L 4.550781 23.550781 C 4.800781 24.148438 5.207031 24.148438 5.457031 23.550781 L 9.800781 13.21875 C 10.050781 12.621094 10.050781 11.652344 9.800781 11.054688 "/>
|
||||
</g>
|
1
web/assets/svgs/home.svg
Normal file
1
web/assets/svgs/home.svg
Normal file
@ -0,0 +1 @@
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.5192 7.82274C2 8.77128 2 9.91549 2 12.2039V13.725C2 17.6258 2 19.5763 3.17157 20.7881C4.34315 22 6.22876 22 10 22H14C17.7712 22 19.6569 22 20.8284 20.7881C22 19.5763 22 17.6258 22 13.725V12.2039C22 9.91549 22 8.77128 21.4808 7.82274C20.9616 6.87421 20.0131 6.28551 18.116 5.10812L16.116 3.86687C14.1106 2.62229 13.1079 2 12 2C10.8921 2 9.88939 2.62229 7.88403 3.86687L5.88403 5.10813C3.98695 6.28551 3.0384 6.87421 2.5192 7.82274ZM11.25 18C11.25 18.4142 11.5858 18.75 12 18.75C12.4142 18.75 12.75 18.4142 12.75 18V15C12.75 14.5858 12.4142 14.25 12 14.25C11.5858 14.25 11.25 14.5858 11.25 15V18Z" />
|
5
web/assets/svgs/import.svg
Normal file
5
web/assets/svgs/import.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.06935 5.00839C2 5.37595 2 5.81722 2 6.69975V13.75C2 17.5212 2 19.4069 3.17157 20.5784C4.34315 21.75 6.22876 21.75 10 21.75H14C17.7712 21.75 19.6569 21.75 20.8284 20.5784C22 19.4069 22 17.5212 22 13.75V11.5479C22 8.91554 22 7.59935 21.2305 6.74383C21.1598 6.66514 21.0849 6.59024 21.0062 6.51946C20.1506 5.75 18.8345 5.75 16.2021 5.75H15.8284C14.6747 5.75 14.0979 5.75 13.5604 5.59678C13.2651 5.5126 12.9804 5.39471 12.7121 5.24543C12.2237 4.97367 11.8158 4.56578 11 3.75L10.4497 3.19975C10.1763 2.92633 10.0396 2.78961 9.89594 2.67051C9.27652 2.15704 8.51665 1.84229 7.71557 1.76738C7.52976 1.75 7.33642 1.75 6.94975 1.75C6.06722 1.75 5.62595 1.75 5.25839 1.81935C3.64031 2.12464 2.37464 3.39031 2.06935 5.00839ZM12 11C12.4142 11 12.75 11.3358 12.75 11.75V13H14C14.4142 13 14.75 13.3358 14.75 13.75C14.75 14.1642 14.4142 14.5 14 14.5H12.75V15.75C12.75 16.1642 12.4142 16.5 12 16.5C11.5858 16.5 11.25 16.1642 11.25 15.75V14.5H10C9.58579 14.5 9.25 14.1642 9.25 13.75C9.25 13.3358 9.58579 13 10 13H11.25V11.75C11.25 11.3358 11.5858 11 12 11Z"
|
||||
/>
|
5
web/assets/svgs/info.svg
Normal file
5
web/assets/svgs/info.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<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 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75ZM12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z"
|
||||
/>
|
36
web/assets/svgs/loading.svg
Normal file
36
web/assets/svgs/loading.svg
Normal file
@ -0,0 +1,36 @@
|
||||
<style>
|
||||
.spinner_l9ve {
|
||||
animation: spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite;
|
||||
}
|
||||
.spinner_cMYp {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.spinner_gHR3 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
@keyframes spinner_rcyq {
|
||||
0% {
|
||||
transform: translate(12px, 12px) scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
class="spinner_l9ve"
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||
transform="translate(12, 12) scale(0)"
|
||||
/>
|
||||
<path
|
||||
class="spinner_l9ve spinner_cMYp"
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||
transform="translate(12, 12) scale(0)"
|
||||
/>
|
||||
<path
|
||||
class="spinner_l9ve spinner_gHR3"
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||
transform="translate(12, 12) scale(0)"
|
||||
/>
|
1
web/assets/svgs/password.svg
Normal file
1
web/assets/svgs/password.svg
Normal file
@ -0,0 +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 "/>
|
5
web/assets/svgs/search.svg
Normal file
5
web/assets/svgs/search.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<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 12ZM9 11.5C9 10.1193 10.1193 9 11.5 9C12.8807 9 14 10.1193 14 11.5C14 12.8807 12.8807 14 11.5 14C10.1193 14 9 12.8807 9 11.5ZM11.5 7C9.01472 7 7 9.01472 7 11.5C7 13.9853 9.01472 16 11.5 16C12.3805 16 13.202 15.7471 13.8957 15.31L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L15.31 13.8957C15.7471 13.202 16 12.3805 16 11.5C16 9.01472 13.9853 7 11.5 7Z"
|
||||
/>
|
6
web/assets/svgs/search2.svg
Normal file
6
web/assets/svgs/search2.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<rect width="24" height="24" fill="none" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C11.8487 18 13.551 17.3729 14.9056 16.3199L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L16.3199 14.9056C17.3729 13.551 18 11.8487 18 10C18 5.58172 14.4183 2 10 2Z"
|
||||
/>
|
5
web/assets/svgs/settings.svg
Normal file
5
web/assets/svgs/settings.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.2788 2.15224C13.9085 2 13.439 2 12.5 2C11.561 2 11.0915 2 10.7212 2.15224C10.2274 2.35523 9.83509 2.74458 9.63056 3.23463C9.53719 3.45834 9.50065 3.7185 9.48635 4.09799C9.46534 4.65568 9.17716 5.17189 8.69017 5.45093C8.20318 5.72996 7.60864 5.71954 7.11149 5.45876C6.77318 5.2813 6.52789 5.18262 6.28599 5.15102C5.75609 5.08178 5.22018 5.22429 4.79616 5.5472C4.47814 5.78938 4.24339 6.1929 3.7739 6.99993C3.30441 7.80697 3.06967 8.21048 3.01735 8.60491C2.94758 9.1308 3.09118 9.66266 3.41655 10.0835C3.56506 10.2756 3.77377 10.437 4.0977 10.639C4.57391 10.936 4.88032 11.4419 4.88029 12C4.88026 12.5581 4.57386 13.0639 4.0977 13.3608C3.77372 13.5629 3.56497 13.7244 3.41645 13.9165C3.09108 14.3373 2.94749 14.8691 3.01725 15.395C3.06957 15.7894 3.30432 16.193 3.7738 17C4.24329 17.807 4.47804 18.2106 4.79606 18.4527C5.22008 18.7756 5.75599 18.9181 6.28589 18.8489C6.52778 18.8173 6.77305 18.7186 7.11133 18.5412C7.60852 18.2804 8.2031 18.27 8.69012 18.549C9.17714 18.8281 9.46533 19.3443 9.48635 19.9021C9.50065 20.2815 9.53719 20.5417 9.63056 20.7654C9.83509 21.2554 10.2274 21.6448 10.7212 21.8478C11.0915 22 11.561 22 12.5 22C13.439 22 13.9085 22 14.2788 21.8478C14.7726 21.6448 15.1649 21.2554 15.3694 20.7654C15.4628 20.5417 15.4994 20.2815 15.5137 19.902C15.5347 19.3443 15.8228 18.8281 16.3098 18.549C16.7968 18.2699 17.3914 18.2804 17.8886 18.5412C18.2269 18.7186 18.4721 18.8172 18.714 18.8488C19.2439 18.9181 19.7798 18.7756 20.2038 18.4527C20.5219 18.2105 20.7566 17.807 21.2261 16.9999C21.6956 16.1929 21.9303 15.7894 21.9827 15.395C22.0524 14.8691 21.9088 14.3372 21.5835 13.9164C21.4349 13.7243 21.2262 13.5628 20.9022 13.3608C20.4261 13.0639 20.1197 12.558 20.1197 11.9999C20.1197 11.4418 20.4261 10.9361 20.9022 10.6392C21.2263 10.4371 21.435 10.2757 21.5836 10.0835C21.9089 9.66273 22.0525 9.13087 21.9828 8.60497C21.9304 8.21055 21.6957 7.80703 21.2262 7C20.7567 6.19297 20.522 5.78945 20.2039 5.54727C19.7799 5.22436 19.244 5.08185 18.7141 5.15109C18.4722 5.18269 18.2269 5.28136 17.8887 5.4588C17.3915 5.71959 16.7969 5.73002 16.3099 5.45096C15.8229 5.17191 15.5347 4.65566 15.5136 4.09794C15.4993 3.71848 15.4628 3.45833 15.3694 3.23463C15.1649 2.74458 14.7726 2.35523 14.2788 2.15224ZM12.5 15C14.1695 15 15.5228 13.6569 15.5228 12C15.5228 10.3431 14.1695 9 12.5 9C10.8305 9 9.47716 10.3431 9.47716 12C9.47716 13.6569 10.8305 15 12.5 15Z"
|
||||
/>
|
8
web/assets/svgs/upload.svg
Normal file
8
web/assets/svgs/upload.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
|
||||
/>
|
1
web/assets/svgs/user.svg
Normal file
1
web/assets/svgs/user.svg
Normal file
@ -0,0 +1 @@
|
||||
<path fill-rule="nonzero" fill-opacity="1" d="M 20.398438 17.933594 C 20.199219 16.550781 19.808594 15.398438 19.226562 14.484375 C 18.640625 13.570312 17.816406 13.039062 16.753906 12.898438 C 16.15625 13.558594 15.445312 14.074219 14.617188 14.445312 C 13.792969 14.816406 12.917969 15 12 15 C 11.082031 15 10.207031 14.816406 9.382812 14.445312 C 8.554688 14.074219 7.84375 13.558594 7.246094 12.898438 C 6.183594 13.039062 5.359375 13.570312 4.773438 14.484375 C 4.191406 15.398438 3.800781 16.550781 3.601562 17.933594 C 4.550781 19.273438 5.757812 20.332031 7.230469 21.113281 C 8.707031 21.894531 10.292969 22.285156 12 22.285156 C 13.707031 22.285156 15.292969 21.894531 16.769531 21.113281 C 18.242188 20.332031 19.449219 19.273438 20.398438 17.933594 Z M 17.144531 8.570312 C 17.144531 7.152344 16.640625 5.941406 15.636719 4.933594 C 14.632812 3.929688 13.417969 3.429688 12 3.429688 C 10.582031 3.429688 9.367188 3.929688 8.363281 4.933594 C 7.359375 5.941406 6.855469 7.152344 6.855469 8.570312 C 6.855469 9.992188 7.359375 11.203125 8.363281 12.207031 C 9.367188 13.210938 10.582031 13.714844 12 13.714844 C 13.417969 13.714844 14.632812 13.210938 15.636719 12.207031 C 16.640625 11.203125 17.144531 9.992188 17.144531 8.570312 Z M 24 12 C 24 13.625 23.683594 15.175781 23.050781 16.652344 C 22.414062 18.132812 21.566406 19.410156 20.496094 20.484375 C 19.429688 21.558594 18.15625 22.414062 16.675781 23.050781 C 15.191406 23.683594 13.632812 24 12 24 C 10.375 24 8.820312 23.683594 7.339844 23.050781 C 5.855469 22.414062 4.582031 21.5625 3.507812 20.492188 C 2.4375 19.417969 1.585938 18.144531 0.949219 16.660156 C 0.316406 15.179688 0 13.625 0 12 C 0 10.375 0.316406 8.820312 0.949219 7.339844 C 1.585938 5.855469 2.4375 4.582031 3.507812 3.507812 C 4.582031 2.4375 5.855469 1.585938 7.339844 0.949219 C 8.820312 0.316406 10.375 0 12 0 C 13.625 0 15.179688 0.316406 16.660156 0.949219 C 18.144531 1.585938 19.417969 2.4375 20.492188 3.507812 C 21.5625 4.582031 22.414062 5.855469 23.050781 7.339844 C 23.683594 8.820312 24 10.375 24 12 Z M 24 12 "/>
|
134
web/components/document/actions.go
Normal file
134
web/components/document/actions.go
Normal file
@ -0,0 +1,134 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
func Actions(d models.Document) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col float-left gap-2 w-44 md:w-60 lg:w-80 mr-4 relative"),
|
||||
|
||||
// Cover
|
||||
ui.AnchoredPopover(
|
||||
h.Img(
|
||||
h.Class("rounded object-fill w-full"),
|
||||
h.Src(fmt.Sprintf("/documents/%s/cover", d.ID)),
|
||||
),
|
||||
editCoverPopover(d.ID),
|
||||
),
|
||||
|
||||
// Read
|
||||
ui.LinkButton(g.Text("Read"), fmt.Sprintf("/reader#id=%s&type=REMOTE", d.ID)),
|
||||
|
||||
// Actions
|
||||
h.Div(
|
||||
h.Class("flex flex-col justify-between z-20 gap-2 relative"),
|
||||
|
||||
h.Div(
|
||||
h.Class("flex grow align-center justify-between my-auto text-gray-500 dark:text-gray-500"),
|
||||
|
||||
ui.AnchoredPopover(
|
||||
ui.SpanButton(assets.Icon("delete", 28), ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
|
||||
deletePopover(d.ID),
|
||||
),
|
||||
|
||||
ui.LinkButton(
|
||||
assets.Icon("activity", 28),
|
||||
fmt.Sprintf("../activity?document=%s", d.ID),
|
||||
ui.ButtonConfig{Variant: ui.ButtonVariantGhost},
|
||||
),
|
||||
|
||||
ui.AnchoredPopover(
|
||||
ui.SpanButton(assets.Icon("search", 28), ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
|
||||
searchPopover(d),
|
||||
),
|
||||
|
||||
ui.LinkButton(
|
||||
assets.Icon("download", 28),
|
||||
fmt.Sprintf("./%s/file", d.ID),
|
||||
ui.ButtonConfig{
|
||||
Variant: ui.ButtonVariantGhost,
|
||||
Disabled: !d.HasFile,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func editCoverPopover(docID string) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col gap-2"),
|
||||
h.Form(
|
||||
h.Class("flex flex-col gap-2 w-[19rem] text-black dark:text-white text-sm"),
|
||||
h.Method("POST"),
|
||||
g.Attr("enctype", "multipart/form-data"),
|
||||
h.Action(fmt.Sprintf("./%s/edit", docID)),
|
||||
h.Input(h.Type("file"), h.ID("cover_file"), h.Name("cover_file")),
|
||||
ui.FormButton(g.Text("Upload Cover"), ""),
|
||||
),
|
||||
h.Form(
|
||||
h.Class("flex flex-col gap-2 w-[19rem] text-black dark:text-white text-sm"),
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("./%s/edit", docID)),
|
||||
h.Input(
|
||||
h.ID("remove_cover"),
|
||||
h.Name("remove_cover"),
|
||||
h.Class("hidden"),
|
||||
h.Type("checkbox"),
|
||||
h.Checked(),
|
||||
),
|
||||
ui.FormButton(g.Text("Remove Cover"), ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func deletePopover(id string) g.Node {
|
||||
return h.Form(
|
||||
h.Class("text-black dark:text-white text-sm w-24"),
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("./%s/delete", id)),
|
||||
ui.FormButton(g.Text("Delete"), ""),
|
||||
)
|
||||
}
|
||||
|
||||
func searchPopover(d models.Document) g.Node {
|
||||
return h.Form(
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("./%s/identify", d.ID)),
|
||||
h.Class("flex flex-col gap-2 text-black dark:text-white text-sm"),
|
||||
h.Input(
|
||||
h.ID("title"),
|
||||
h.Name("title"),
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Title"),
|
||||
h.Value(d.Title),
|
||||
),
|
||||
h.Input(
|
||||
h.ID("author"),
|
||||
h.Name("author"),
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Author"),
|
||||
h.Value(d.Author),
|
||||
),
|
||||
h.Input(
|
||||
h.ID("isbn"),
|
||||
h.Name("isbn"),
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("ISBN 10 / ISBN 13"),
|
||||
h.Value(utils.FirstNonZero(d.ISBN13, d.ISBN10)),
|
||||
),
|
||||
ui.FormButton(g.Text("Identify"), ""),
|
||||
)
|
||||
}
|
54
web/components/document/card.go
Normal file
54
web/components/document/card.go
Normal file
@ -0,0 +1,54 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/formatters"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
func Card(d models.Document) g.Node {
|
||||
return h.Div(
|
||||
h.Class("w-full relative"),
|
||||
h.Div(
|
||||
h.Class("flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"),
|
||||
h.Div(
|
||||
h.Class("min-w-fit my-auto h-48 relative"),
|
||||
h.A(
|
||||
h.Href("./documents/"+d.ID),
|
||||
h.Img(
|
||||
h.Src("./documents/"+d.ID+"/cover"),
|
||||
h.Class("rounded object-cover h-full"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("flex flex-col justify-around dark:text-white w-full text-sm"),
|
||||
ui.KeyValue(g.Text("Title"), g.Text(d.Title)),
|
||||
ui.KeyValue(g.Text("Author"), g.Text(d.Author)),
|
||||
ui.KeyValue(g.Text("Progress"), g.Text(fmt.Sprintf("%.2f%%", d.Percentage))),
|
||||
ui.KeyValue(g.Text("Time Read"), g.Text(formatters.FormatDuration(d.TotalTimeRead))),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"),
|
||||
ui.LinkButton(
|
||||
assets.Icon("activity", 24),
|
||||
"./activity?document="+d.ID,
|
||||
ui.ButtonConfig{Variant: ui.ButtonVariantGhost},
|
||||
),
|
||||
ui.LinkButton(
|
||||
assets.Icon("download", 24),
|
||||
"./documents/"+d.ID+"/file",
|
||||
ui.ButtonConfig{
|
||||
Variant: ui.ButtonVariantGhost,
|
||||
Disabled: !d.HasFile,
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
104
web/components/document/identify_popover.go
Normal file
104
web/components/document/identify_popover.go
Normal file
@ -0,0 +1,104 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
func IdentifyPopover(docID string, m *models.DocumentMetadata) g.Node {
|
||||
if m == 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(
|
||||
h.Class("flex flex-col gap-2"),
|
||||
h.H3(
|
||||
h.Class("text-lg font-bold text-center"),
|
||||
g.Text("Metadata Results"),
|
||||
),
|
||||
h.Form(
|
||||
h.ID("metadata-save"),
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("/documents/%s/edit", docID)),
|
||||
h.Class("text-black dark:text-white border-b dark:border-black"),
|
||||
h.Dl(
|
||||
h.Div(
|
||||
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Cover")),
|
||||
h.Dd(
|
||||
h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"),
|
||||
h.Img(
|
||||
h.Class("rounded object-fill h-32"),
|
||||
h.Src(fmt.Sprintf("https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690", m.SourceID)),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Title")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.Title, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Author")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.Author, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("ISBN 10")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.ISBN10, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("ISBN 13")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.ISBN13, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Description")),
|
||||
h.Dd(
|
||||
h.Class("max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2"),
|
||||
g.Text(utils.FirstNonZero(m.Description, "N/A")),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("hidden"),
|
||||
h.Input(h.Type("text"), h.ID("title"), h.Name("title"), h.Value(m.Title)),
|
||||
h.Input(h.Type("text"), h.ID("author"), h.Name("author"), h.Value(m.Author)),
|
||||
h.Input(h.Type("text"), h.ID("description"), h.Name("description"), h.Value(m.Description)),
|
||||
h.Input(h.Type("text"), h.ID("isbn_10"), h.Name("isbn_10"), h.Value(m.ISBN10)),
|
||||
h.Input(h.Type("text"), h.ID("isbn_13"), h.Name("isbn_13"), h.Value(m.ISBN13)),
|
||||
h.Input(h.Type("text"), h.ID("cover_gbid"), h.Name("cover_gbid"), h.Value(m.SourceID)),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("flex justify-end"),
|
||||
h.Div(
|
||||
h.Class("flex gap-4 w-48"),
|
||||
ui.LinkButton(g.Text("Cancel"), fmt.Sprintf("/documents/%s", docID)),
|
||||
ui.FormButton(g.Text("Save"), "metadata-save"),
|
||||
),
|
||||
),
|
||||
))
|
||||
}
|
23
web/components/forms/edit.go
Normal file
23
web/components/forms/edit.go
Normal file
@ -0,0 +1,23 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
)
|
||||
|
||||
func Edit(key, val, url string) g.Node {
|
||||
return h.Form(
|
||||
h.Class("flex flex-col gap-2 text-black dark:text-white text-sm"),
|
||||
h.Method("POST"),
|
||||
h.Action(url),
|
||||
h.Input(
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.ID(key),
|
||||
h.Name(key),
|
||||
h.Value(val),
|
||||
),
|
||||
ui.FormButton(g.Text("Save"), ""),
|
||||
)
|
||||
}
|
57
web/components/layout/layout.go
Normal file
57
web/components/layout/layout.go
Normal file
@ -0,0 +1,57 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/web/pages"
|
||||
)
|
||||
|
||||
type LayoutOptions struct {
|
||||
SearchEnabled bool
|
||||
IsAdmin bool
|
||||
Username string
|
||||
Version string
|
||||
}
|
||||
|
||||
func Layout(p pages.Page, opts LayoutOptions) g.Node {
|
||||
return h.Doctype(
|
||||
h.HTML(
|
||||
g.Attr("lang", "en"),
|
||||
Head(p.Route().Title()),
|
||||
h.Body(
|
||||
g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"),
|
||||
Navigation(p.Route(), &opts),
|
||||
Base(p.Render()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Head(routeTitle string) g.Node {
|
||||
return h.Head(
|
||||
h.Title("AnthoLume - "+routeTitle),
|
||||
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", "apple-mobile-web-app-capable"), g.Attr("content", "yes")),
|
||||
h.Meta(g.Attr("name", "apple-mobile-web-app-status-bar-style"), g.Attr("content", "black-translucent")),
|
||||
h.Meta(g.Attr("name", "theme-color"), g.Attr("content", "#F3F4F6"), g.Attr("media", "(prefers-color-scheme: light)")),
|
||||
h.Meta(g.Attr("name", "theme-color"), g.Attr("content", "#1F2937"), g.Attr("media", "(prefers-color-scheme: dark)")),
|
||||
h.Link(g.Attr("rel", "manifest"), g.Attr("href", "/manifest.json")),
|
||||
h.Link(g.Attr("rel", "stylesheet"), g.Attr("href", "/assets/index.css")),
|
||||
h.Link(g.Attr("rel", "stylesheet"), g.Attr("href", "/assets/tailwind.css")),
|
||||
h.Script(g.Attr("src", "/assets/lib/idb-keyval.min.js")),
|
||||
h.Script(g.Attr("src", "/assets/common.js")),
|
||||
h.Script(g.Attr("src", "/assets/index.js")),
|
||||
)
|
||||
}
|
||||
|
||||
func Base(body g.Node) g.Node {
|
||||
return h.Main(
|
||||
g.Attr("class", "relative overflow-hidden"),
|
||||
h.Div(
|
||||
g.Attr("id", "container"),
|
||||
g.Attr("class", "h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"),
|
||||
body,
|
||||
),
|
||||
)
|
||||
}
|
168
web/components/layout/navigation.go
Normal file
168
web/components/layout/navigation.go
Normal file
@ -0,0 +1,168 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/pages"
|
||||
)
|
||||
|
||||
const (
|
||||
active = "border-purple-500 dark:text-white"
|
||||
inactive = "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
|
||||
)
|
||||
|
||||
func Navigation(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("class", "flex items-center justify-between w-full h-16"),
|
||||
Sidebar(currentRoute, opts),
|
||||
h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(currentRoute.Title())),
|
||||
Dropdown(opts.Username),
|
||||
)
|
||||
}
|
||||
|
||||
func Sidebar(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node {
|
||||
links := []g.Node{
|
||||
navLink(currentRoute, pages.HomePage, "/", "home"),
|
||||
navLink(currentRoute, pages.DocumentsPage, "/documents", "documents"),
|
||||
navLink(currentRoute, pages.ProgressPage, "/progress", "activity"),
|
||||
navLink(currentRoute, pages.ActivityPage, "/activity", "activity"),
|
||||
}
|
||||
if opts.SearchEnabled {
|
||||
links = append(links, navLink(currentRoute, pages.SearchPage, "/search", "search"))
|
||||
}
|
||||
if opts.IsAdmin {
|
||||
links = append(links, adminLinks(currentRoute))
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
g.Attr("id", "mobile-nav-button"),
|
||||
g.Attr("class", "flex flex-col z-40 relative ml-6"),
|
||||
hamburgerIcon(),
|
||||
h.Div(
|
||||
g.Attr("id", "menu"),
|
||||
g.Attr("class", "fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg"),
|
||||
h.Div(
|
||||
g.Attr("class", "h-16 flex justify-end lg:justify-around"),
|
||||
h.P(g.Attr("class", "text-xl font-bold text-right my-auto pr-8 lg:pr-0"), g.Text("AnthoLume")),
|
||||
),
|
||||
h.Div(links...),
|
||||
h.A(
|
||||
g.Attr("href", "https://gitea.va.reichard.io/evan/AnthoLume"),
|
||||
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"),
|
||||
assets.Icon("gitea", 20),
|
||||
h.Span(g.Attr("class", "text-xs"), g.Text(opts.Version)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func navLink(currentRoute, linkRoute pages.PageRoute, path, icon string) g.Node {
|
||||
class := inactive
|
||||
if currentRoute == linkRoute {
|
||||
class = active
|
||||
}
|
||||
return h.A(
|
||||
g.Attr("class", "flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 "+class),
|
||||
h.Href(path),
|
||||
assets.Icon(icon, 20),
|
||||
h.Span(g.Attr("class", "mx-4 text-sm font-normal"), g.Text(linkRoute.Title())),
|
||||
)
|
||||
}
|
||||
|
||||
func adminLinks(currentRoute pages.PageRoute) g.Node {
|
||||
routeID := string(currentRoute)
|
||||
|
||||
class := inactive
|
||||
if strings.HasPrefix(routeID, "admin") {
|
||||
class = active
|
||||
}
|
||||
|
||||
children := g.If(strings.HasPrefix(routeID, "admin"),
|
||||
g.Group([]g.Node{
|
||||
subNavLink(currentRoute, pages.AdminGeneralPage, "/admin"),
|
||||
subNavLink(currentRoute, pages.AdminImportPage, "/admin/import"),
|
||||
subNavLink(currentRoute, pages.AdminUsersPage, "/admin/users"),
|
||||
subNavLink(currentRoute, pages.AdminLogsPage, "/admin/logs"),
|
||||
}),
|
||||
)
|
||||
|
||||
return h.Div(
|
||||
g.Attr("class", "flex flex-col gap-4 p-2 pl-6 my-2 transition-colors duration-200 border-l-4 "+class),
|
||||
h.A(
|
||||
g.Attr("href", "/admin"),
|
||||
g.Attr("class", "flex justify-start w-full"),
|
||||
assets.Icon("settings", 20),
|
||||
h.Span(g.Attr("class", "mx-4 text-sm font-normal"), g.Text("Admin")),
|
||||
),
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
func subNavLink(currentRoute, linkRoute pages.PageRoute, path string) g.Node {
|
||||
class := inactive
|
||||
if currentRoute == linkRoute {
|
||||
class = active
|
||||
}
|
||||
|
||||
pageTitle := linkRoute.Title()
|
||||
if splitString := strings.Split(pageTitle, " - "); len(splitString) > 1 {
|
||||
pageTitle = splitString[1]
|
||||
}
|
||||
|
||||
return h.A(
|
||||
g.Attr("class", class),
|
||||
g.Attr("href", path),
|
||||
g.Attr("style", "padding-left:1.75em"),
|
||||
h.Span(g.Attr("class", "mx-4 text-sm font-normal"), g.Text(pageTitle)),
|
||||
)
|
||||
}
|
||||
|
||||
func hamburgerIcon() g.Node {
|
||||
return g.Group([]g.Node{
|
||||
h.Input(g.Attr("type", "checkbox"), g.Attr("class", "absolute lg:hidden z-50 -top-2 w-7 h-7 opacity-0 cursor-pointer")),
|
||||
h.Span(g.Attr("class", "lg:hidden bg-black dark:bg-white w-7 h-0.5 z-40 mt-0.5")),
|
||||
h.Span(g.Attr("class", "lg:hidden bg-black dark:bg-white w-7 h-0.5 z-40 mt-1")),
|
||||
h.Span(g.Attr("class", "lg:hidden bg-black dark:bg-white w-7 h-0.5 z-40 mt-1")),
|
||||
})
|
||||
}
|
||||
|
||||
func Dropdown(username string) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("class", "relative flex items-center justify-end w-full p-4"),
|
||||
h.Input(g.Attr("type", "checkbox"), g.Attr("id", "user-dropdown-button"), g.Attr("class", "hidden")),
|
||||
h.Div(
|
||||
g.Attr("id", "user-dropdown"),
|
||||
g.Attr("class", "transition duration-200 z-20 absolute right-4 top-16 pt-4"),
|
||||
h.Div(
|
||||
g.Attr("class", "w-40 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5"),
|
||||
h.Div(
|
||||
g.Attr("class", "py-1"),
|
||||
dropdownItem("/settings", "Settings"),
|
||||
dropdownItem("/local", "Offline"),
|
||||
dropdownItem("/logout", "Logout"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", "user-dropdown-button"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-center gap-2 text-md py-4 cursor-pointer"),
|
||||
assets.Icon("user", 20),
|
||||
h.Span(g.Text(username)),
|
||||
assets.Icon("dropdown", 20),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func dropdownItem(href, text string) g.Node {
|
||||
return h.A(
|
||||
g.Attr("href", href),
|
||||
g.Attr("class", "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"),
|
||||
g.Text(text),
|
||||
)
|
||||
}
|
35
web/components/stats/info_card.go
Normal file
35
web/components/stats/info_card.go
Normal file
@ -0,0 +1,35 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type InfoCardData struct {
|
||||
Title string
|
||||
Size int64
|
||||
Link string
|
||||
}
|
||||
|
||||
func InfoCard(d InfoCardData) g.Node {
|
||||
cardContent := h.Div(
|
||||
g.Attr("class", "flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex flex-col justify-around w-full text-sm"),
|
||||
h.P(g.Attr("class", "text-2xl font-bold"), g.Text(fmt.Sprint(d.Size))),
|
||||
h.P(g.Attr("class", "text-sm text-gray-400"), g.Text(d.Title)),
|
||||
),
|
||||
)
|
||||
|
||||
if d.Link == "" {
|
||||
return h.Div(g.Attr("class", "w-full"), cardContent)
|
||||
}
|
||||
|
||||
return h.A(
|
||||
g.Attr("class", "w-full"),
|
||||
h.Href(d.Link),
|
||||
cardContent,
|
||||
)
|
||||
}
|
130
web/components/stats/leaderboard_card.go
Normal file
130
web/components/stats/leaderboard_card.go
Normal file
@ -0,0 +1,130 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type LeaderboardItem struct {
|
||||
UserID string
|
||||
Value string
|
||||
}
|
||||
|
||||
type LeaderboardData struct {
|
||||
Name string
|
||||
All []LeaderboardItem
|
||||
Year []LeaderboardItem
|
||||
Month []LeaderboardItem
|
||||
Week []LeaderboardItem
|
||||
}
|
||||
|
||||
func LeaderboardCard(l LeaderboardData) g.Node {
|
||||
orderedItems := map[string][]LeaderboardItem{
|
||||
"All": l.All,
|
||||
"Year": l.Year,
|
||||
"Month": l.Month,
|
||||
"Week": l.Week,
|
||||
}
|
||||
|
||||
var allNodes []g.Node
|
||||
for key, items := range orderedItems {
|
||||
// Get Top Reader Nodes
|
||||
topReaders := items[:min(len(items), 3)]
|
||||
var topReaderNodes []g.Node
|
||||
for idx, reader := range topReaders {
|
||||
border := ""
|
||||
if idx > 0 {
|
||||
border = " border-t border-gray-200"
|
||||
}
|
||||
topReaderNodes = append(topReaderNodes, h.Div(
|
||||
g.Attr("class", "flex items-center justify-between pt-2 pb-2 text-sm"+border),
|
||||
h.Div(h.P(g.Text(reader.UserID))),
|
||||
h.Div(g.Attr("class", "flex items-end font-bold"), g.Text(reader.Value)),
|
||||
))
|
||||
}
|
||||
|
||||
allNodes = append(allNodes, g.Group([]g.Node{
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end my-6 space-x-2 hidden peer-checked/"+key+":block"),
|
||||
g.If(len(items) == 0,
|
||||
h.P(g.Attr("class", "text-5xl font-bold text-black dark:text-white"), g.Text("N/A")),
|
||||
),
|
||||
g.If(len(items) > 0,
|
||||
h.P(g.Attr("class", "text-5xl font-bold text-black dark:text-white"), g.Text(items[0].UserID)),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "hidden dark:text-white peer-checked/"+key+":block"),
|
||||
g.Group(topReaderNodes),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
g.Attr("class", "w-full"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"),
|
||||
h.Div(
|
||||
h.Div(
|
||||
g.Attr("class", "flex justify-between"),
|
||||
h.P(
|
||||
g.Attr("class", "text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"),
|
||||
g.Textf("%s Leaderboard", l.Name),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex gap-2 text-xs text-gray-400 items-center"),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("all-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("all"),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("year-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("year"),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("month-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("month"),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("week-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("week"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("all-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/All"),
|
||||
g.Attr("checked", ""),
|
||||
),
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("year-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/Year"),
|
||||
),
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("month-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/Month"),
|
||||
),
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("week-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/Week"),
|
||||
),
|
||||
g.Group(allNodes),
|
||||
),
|
||||
)
|
||||
}
|
61
web/components/stats/monthly_chart.go
Normal file
61
web/components/stats/monthly_chart.go
Normal file
@ -0,0 +1,61 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/graph"
|
||||
)
|
||||
|
||||
func MonthlyChart(dailyStats []database.GetDailyReadStatsRow) g.Node {
|
||||
graphData := buildSVGGraphData(dailyStats, 800, 70)
|
||||
return h.Div(
|
||||
g.Attr("class", "relative"),
|
||||
h.SVG(
|
||||
g.Attr("viewBox", fmt.Sprintf("26 0 755 %d", graphData.Height)),
|
||||
g.Attr("preserveAspectRatio", "none"),
|
||||
g.Attr("width", "100%"),
|
||||
g.Attr("height", "6em"),
|
||||
g.El("path",
|
||||
g.Attr("fill", "#316BBE"),
|
||||
g.Attr("fill-opacity", "0.5"),
|
||||
g.Attr("stroke", "none"),
|
||||
g.Attr("d", graphData.BezierPath+" "+graphData.BezierFill),
|
||||
),
|
||||
g.El("path",
|
||||
g.Attr("fill", "none"),
|
||||
g.Attr("stroke", "#316BBE"),
|
||||
g.Attr("d", graphData.BezierPath),
|
||||
),
|
||||
),
|
||||
|
||||
h.Div(
|
||||
g.Attr("class", "flex absolute w-full h-full top-0"),
|
||||
g.Attr("style", "width: calc(100%*31/30); transform: translateX(-50%); left: 50%"),
|
||||
g.Group(g.Map(dailyStats, func(d database.GetDailyReadStatsRow) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("onclick", ""),
|
||||
g.Attr("class", "opacity-0 hover:opacity-100 w-full"),
|
||||
g.Attr("style", "background: linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex flex-col items-center p-2 rounded absolute top-3 dark:text-white text-xs pointer-events-none"),
|
||||
g.Attr("style", "transform: translateX(-50%); background-color: rgba(128, 128, 128, 0.2); left: 50%"),
|
||||
h.Span(g.Text(d.Date)),
|
||||
h.Span(g.Textf("%d minutes", d.MinutesRead)),
|
||||
),
|
||||
)
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// buildSVGGraphData builds SVGGraphData from the provided stats, width and height.
|
||||
func buildSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
|
||||
var intData []int64
|
||||
for _, item := range inputData {
|
||||
intData = append(intData, item.MinutesRead)
|
||||
}
|
||||
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
||||
}
|
65
web/components/stats/streak_card.go
Normal file
65
web/components/stats/streak_card.go
Normal file
@ -0,0 +1,65 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/database"
|
||||
)
|
||||
|
||||
func StreakCard(s database.UserStreak) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("class", "w-full"),
|
||||
h.Div(
|
||||
g.Attr("class", "relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"),
|
||||
h.P(
|
||||
g.Attr("class", "text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"),
|
||||
g.If(s.Window == "WEEK", g.Text("Weekly Read Streak")),
|
||||
g.If(s.Window != "WEEK", g.Text("Daily Read Streak")),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end my-6 space-x-2"),
|
||||
h.P(
|
||||
g.Attr("class", "text-5xl font-bold text-black dark:text-white"),
|
||||
g.Textf("%d", s.CurrentStreak),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "dark:text-white"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"),
|
||||
h.Div(
|
||||
h.P(
|
||||
g.If(s.Window == "WEEK", g.Text("Current Weekly Streak")),
|
||||
g.If(s.Window != "WEEK", g.Text("Current Daily Streak")),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end text-sm text-gray-400"),
|
||||
g.Textf("%s ➞ %s", s.CurrentStreakStartDate, s.CurrentStreakEndDate),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end font-bold"),
|
||||
g.Textf("%d", s.CurrentStreak),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-center justify-between pb-2 mb-2 text-sm"),
|
||||
h.Div(
|
||||
h.P(
|
||||
g.If(s.Window == "WEEK", g.Text("Best Weekly Streak")),
|
||||
g.If(s.Window != "WEEK", g.Text("Best Daily Streak")),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end text-sm text-gray-400"),
|
||||
g.Textf("%s ➞ %s", s.MaxStreakStartDate, s.MaxStreakEndDate),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end font-bold"),
|
||||
g.Textf("%d", s.MaxStreak),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
99
web/components/ui/button.go
Normal file
99
web/components/ui/button.go
Normal file
@ -0,0 +1,99 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
)
|
||||
|
||||
type ButtonVariant string
|
||||
|
||||
const (
|
||||
ButtonVariantPrimary ButtonVariant = "primary"
|
||||
ButtonVariantSecondary ButtonVariant = "secondary"
|
||||
ButtonVariantGhost ButtonVariant = "ghost"
|
||||
)
|
||||
|
||||
type buttonAs int
|
||||
|
||||
const (
|
||||
buttonAsLink buttonAs = iota
|
||||
buttonAsForm
|
||||
buttonAsSpan
|
||||
)
|
||||
|
||||
type ButtonConfig struct {
|
||||
Variant ButtonVariant
|
||||
Disabled bool
|
||||
|
||||
as buttonAs
|
||||
value string
|
||||
}
|
||||
|
||||
// LinkButton creates a button that links to a url. The default variant is ButtonVariantPrimary.
|
||||
func LinkButton(content g.Node, url string, cfg ...ButtonConfig) g.Node {
|
||||
config := buildButtonConfig(cfg, buttonAsLink, url)
|
||||
return button(content, config)
|
||||
}
|
||||
|
||||
// FormButton creates a button that is a form. The default variant is ButtonVariantPrimary.
|
||||
func FormButton(content g.Node, formName string, cfg ...ButtonConfig) g.Node {
|
||||
config := buildButtonConfig(cfg, buttonAsForm, formName)
|
||||
return button(content, config)
|
||||
}
|
||||
|
||||
// SpanButton creates a button that has no target (i.e. span). The default variant is ButtonVariantPrimary.
|
||||
func SpanButton(content g.Node, cfg ...ButtonConfig) g.Node {
|
||||
config := buildButtonConfig(cfg, buttonAsSpan, "")
|
||||
return button(content, config)
|
||||
}
|
||||
|
||||
func button(content g.Node, config ButtonConfig) g.Node {
|
||||
classes := config.getClasses()
|
||||
if config.as == buttonAsSpan || config.Disabled {
|
||||
return h.Span(content, h.Class(classes))
|
||||
} else if config.as == buttonAsLink {
|
||||
return h.A(h.Class(classes), h.Href(config.value), content)
|
||||
}
|
||||
|
||||
return h.Button(
|
||||
content,
|
||||
h.Type("submit"),
|
||||
h.Class(classes),
|
||||
g.If(config.value != "", h.FormAttr(config.value)),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ButtonConfig) getClasses() string {
|
||||
baseClass := "transition duration-100 ease-in font-medium text-center inline-block"
|
||||
|
||||
var variantClass string
|
||||
switch c.Variant {
|
||||
case ButtonVariantPrimary:
|
||||
variantClass = "h-full w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||
case ButtonVariantSecondary:
|
||||
variantClass = "h-full w-full px-2 py-1 text-white bg-black shadow-md hover:text-black hover:bg-white"
|
||||
case ButtonVariantGhost:
|
||||
variantClass = "text-gray-500 hover:text-gray-800 dark:hover:text-gray-100"
|
||||
}
|
||||
|
||||
classes := baseClass + " " + variantClass
|
||||
|
||||
if c.Disabled {
|
||||
classes += " opacity-40 pointer-events-none"
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
func buildButtonConfig(cfg []ButtonConfig, as buttonAs, val string) ButtonConfig {
|
||||
c, found := sliceutils.First(cfg)
|
||||
if !found {
|
||||
c = ButtonConfig{Variant: ButtonVariantPrimary}
|
||||
}
|
||||
c.Variant = utils.FirstNonZero(c.Variant, ButtonVariantPrimary)
|
||||
c.as = as
|
||||
c.value = val
|
||||
return c
|
||||
}
|
24
web/components/ui/kv.go
Normal file
24
web/components/ui/kv.go
Normal file
@ -0,0 +1,24 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
// KeyValue is a basic vertical key/value pair component
|
||||
func KeyValue(key, val g.Node) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col"),
|
||||
h.Div(h.Class("text-gray-500"), key),
|
||||
h.Div(h.Class("font-medium text-black dark:text-white"), val),
|
||||
)
|
||||
}
|
||||
|
||||
// HKeyValue is a basic horizontal key/value pair component
|
||||
func HKeyValue(key, val g.Node) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex gap-2"),
|
||||
h.Div(h.Class("text-gray-500"), key),
|
||||
h.Div(h.Class("font-medium text-black dark:text-white"), val),
|
||||
)
|
||||
}
|
99
web/components/ui/popover.go
Normal file
99
web/components/ui/popover.go
Normal file
@ -0,0 +1,99 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/ptr"
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
)
|
||||
|
||||
type PopoverPosition string
|
||||
|
||||
const (
|
||||
// ---- Cornered ----
|
||||
|
||||
// PopoverTopLeft PopoverPosition = "left-0 top-0 origin-bottom-right -translate-x-full -translate-y-full"
|
||||
// PopoverTopRight PopoverPosition = "right-0 top-0 origin-bottom-left translate-x-full -translate-y-full"
|
||||
// PopoverBottomLeft PopoverPosition = "left-0 bottom-0 origin-top-right -translate-x-full translate-y-full"
|
||||
// PopoverBottomRight PopoverPosition = "right-0 bottom-0 origin-top-left translate-x-full translate-y-full"
|
||||
|
||||
// ---- Flush ----
|
||||
|
||||
PopoverTopLeft PopoverPosition = "right-0 -top-1 origin-bottom-right -translate-y-full"
|
||||
PopoverTopRight PopoverPosition = "left-0 -top-1 origin-bottom-left -translate-y-full"
|
||||
PopoverBottomLeft PopoverPosition = "right-0 -bottom-1 origin-top-right translate-y-full"
|
||||
PopoverBottomRight PopoverPosition = "left-0 -bottom-1 origin-top-left translate-y-full"
|
||||
|
||||
// ---- Centered ----
|
||||
|
||||
PopoverTopCenter PopoverPosition = "left-1/2 top-0 origin-bottom -translate-x-1/2 -translate-y-full"
|
||||
PopoverBottomCenter PopoverPosition = "left-1/2 bottom-0 origin-top -translate-x-1/2 translate-y-full"
|
||||
PopoverLeftCenter PopoverPosition = "left-0 top-1/2 origin-right -translate-x-full -translate-y-1/2"
|
||||
PopoverRightCenter PopoverPosition = "right-0 top-1/2 origin-left translate-x-full -translate-y-1/2"
|
||||
PopoverCenter PopoverPosition = "left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2"
|
||||
)
|
||||
|
||||
type PopoverConfig struct {
|
||||
Position PopoverPosition
|
||||
Classes string
|
||||
Dim *bool
|
||||
}
|
||||
|
||||
// AnchoredPopover creates a popover with content anchored to the anchor node.
|
||||
// The default position is PopoverBottomRight.
|
||||
func AnchoredPopover(anchor, content g.Node, cfg ...PopoverConfig) g.Node {
|
||||
// Get Popover Config
|
||||
c, _ := sliceutils.First(cfg)
|
||||
c.Position = utils.FirstNonZero(c.Position, PopoverBottomRight)
|
||||
if c.Dim == nil {
|
||||
c.Dim = ptr.Of(false)
|
||||
}
|
||||
|
||||
popoverID := uuid.NewString()
|
||||
return h.Div(
|
||||
h.Class("relative"),
|
||||
h.Label(
|
||||
h.Class("cursor-pointer"),
|
||||
h.For(popoverID),
|
||||
anchor,
|
||||
),
|
||||
h.Input(
|
||||
h.ID(popoverID),
|
||||
h.Class("hidden css-button"),
|
||||
h.Type("checkbox"),
|
||||
),
|
||||
Popover(content, c),
|
||||
)
|
||||
}
|
||||
|
||||
func Popover(content g.Node, cfg ...PopoverConfig) g.Node {
|
||||
// Get Popover Config
|
||||
c, _ := sliceutils.First(cfg)
|
||||
c.Position = utils.FirstNonZero(c.Position, PopoverCenter)
|
||||
if c.Dim == nil {
|
||||
c.Dim = ptr.Of(true)
|
||||
}
|
||||
|
||||
wrappedContent := h.Div(h.Class(c.getClasses()), content)
|
||||
if !ptr.Deref(c.Dim) {
|
||||
return wrappedContent
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
h.Div(h.Class("fixed top-0 left-0 bg-black z-40 opacity-50 w-screen h-screen")),
|
||||
wrappedContent,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *PopoverConfig) getClasses() string {
|
||||
return strings.Join([]string{
|
||||
"absolute z-50 p-2 transition-all duration-200 rounded shadow-lg",
|
||||
"bg-gray-200 dark:bg-gray-600 shadow-gray-500 dark:shadow-gray-900",
|
||||
c.Classes,
|
||||
string(c.Position),
|
||||
}, " ")
|
||||
}
|
64
web/components/ui/table.go
Normal file
64
web/components/ui/table.go
Normal file
@ -0,0 +1,64 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type TableRow map[string]TableCell
|
||||
|
||||
type TableCell struct {
|
||||
String string
|
||||
Value g.Node
|
||||
}
|
||||
|
||||
type TableConfig struct {
|
||||
Columns []string
|
||||
Rows []TableRow
|
||||
}
|
||||
|
||||
func Table(cfg TableConfig) g.Node {
|
||||
return h.Table(
|
||||
h.Class("min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"),
|
||||
h.THead(
|
||||
h.Class("text-gray-800 dark:text-gray-400"),
|
||||
h.Tr(
|
||||
g.Map(cfg.Columns, func(col string) g.Node {
|
||||
return h.Th(
|
||||
h.Class("p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"),
|
||||
g.Text(col),
|
||||
)
|
||||
})...,
|
||||
),
|
||||
),
|
||||
h.TBody(
|
||||
h.Class("text-black dark:text-white"),
|
||||
g.If(len(cfg.Rows) == 0,
|
||||
h.Tr(
|
||||
h.Td(
|
||||
h.Class("text-center p-3"),
|
||||
g.Attr("colspan", fmt.Sprintf("%d", len(cfg.Columns))),
|
||||
g.Text("No Results"),
|
||||
),
|
||||
),
|
||||
),
|
||||
g.Map(cfg.Rows, func(row TableRow) g.Node {
|
||||
return h.Tr(
|
||||
g.Map(cfg.Columns, func(col string) g.Node {
|
||||
cell, ok := row[col]
|
||||
content := cell.Value
|
||||
if !ok || content == nil {
|
||||
content = g.Text(cell.String)
|
||||
}
|
||||
return h.Td(
|
||||
h.Class("p-3 border-b border-gray-200"),
|
||||
content,
|
||||
)
|
||||
})...,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
12
web/models/activity.go
Normal file
12
web/models/activity.go
Normal file
@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Activity struct {
|
||||
ID string
|
||||
Author string
|
||||
Title string
|
||||
StartTime string
|
||||
Duration time.Duration
|
||||
Percentage float64
|
||||
}
|
33
web/models/document.go
Normal file
33
web/models/document.go
Normal file
@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"reichard.io/antholume/metadata"
|
||||
)
|
||||
|
||||
type Document struct {
|
||||
ID string
|
||||
ISBN10 string
|
||||
ISBN13 string
|
||||
Title string
|
||||
Author string
|
||||
Description string
|
||||
Percentage float64
|
||||
WPM int64
|
||||
Words *int64
|
||||
TotalTimeRead time.Duration
|
||||
TimePerPercent time.Duration
|
||||
HasFile bool
|
||||
}
|
||||
|
||||
type DocumentMetadata struct {
|
||||
SourceID string
|
||||
ISBN10 string
|
||||
ISBN13 string
|
||||
Title string
|
||||
Author string
|
||||
Description string
|
||||
Source metadata.Source
|
||||
Error *string
|
||||
}
|
10
web/models/progress.go
Normal file
10
web/models/progress.go
Normal file
@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
type Progress struct {
|
||||
ID string
|
||||
Author string
|
||||
Title string
|
||||
DeviceName string
|
||||
Percentage float64
|
||||
CreatedAt string
|
||||
}
|
11
web/models/search.go
Normal file
11
web/models/search.go
Normal file
@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
type SearchResult struct {
|
||||
ID string
|
||||
Title string
|
||||
Author string
|
||||
Series string
|
||||
FileType string
|
||||
FileSize string
|
||||
UploadDate string
|
||||
}
|
57
web/pages/activity.go
Normal file
57
web/pages/activity.go
Normal file
@ -0,0 +1,57 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/formatters"
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
var _ Page = (*Activity)(nil)
|
||||
|
||||
type Activity struct {
|
||||
Data []models.Activity
|
||||
}
|
||||
|
||||
func (Activity) Route() PageRoute { return ActivityPage }
|
||||
|
||||
func (p Activity) Render() g.Node {
|
||||
return h.Div(
|
||||
h.Class("overflow-x-auto"),
|
||||
h.Div(
|
||||
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
|
||||
ui.Table(p.buildTableConfig()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Activity) buildTableConfig() ui.TableConfig {
|
||||
return ui.TableConfig{
|
||||
Columns: []string{"Document", "Time", "Duration", "Percent"},
|
||||
Rows: sliceutils.Map(p.Data, toActivityTableRow),
|
||||
}
|
||||
}
|
||||
|
||||
func toActivityTableRow(r models.Activity) ui.TableRow {
|
||||
return ui.TableRow{
|
||||
"Document": ui.TableCell{
|
||||
Value: h.A(
|
||||
h.Href(fmt.Sprintf("./documents/%s", r.ID)),
|
||||
g.Text(fmt.Sprintf("%s - %s", r.Author, r.Title)),
|
||||
),
|
||||
},
|
||||
"Time": ui.TableCell{
|
||||
String: r.StartTime,
|
||||
},
|
||||
"Duration": ui.TableCell{
|
||||
String: formatters.FormatDuration(r.Duration),
|
||||
},
|
||||
"Percent": ui.TableCell{
|
||||
String: fmt.Sprintf("%.2f%%", r.Percentage),
|
||||
},
|
||||
}
|
||||
}
|
129
web/pages/document.go
Normal file
129
web/pages/document.go
Normal file
@ -0,0 +1,129 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/formatters"
|
||||
"reichard.io/antholume/pkg/ptr"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/components/document"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
var _ Page = (*Document)(nil)
|
||||
|
||||
type Document struct {
|
||||
Data models.Document
|
||||
Search *models.DocumentMetadata
|
||||
}
|
||||
|
||||
func (Document) Route() PageRoute { return DocumentPage }
|
||||
|
||||
func (p Document) Render() g.Node {
|
||||
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"),
|
||||
document.Actions(p.Data),
|
||||
|
||||
// Details
|
||||
h.Div(
|
||||
h.Class("grid sm:grid-cols-2 justify-between gap-3 pb-3"),
|
||||
|
||||
editableKeyValue(
|
||||
p.Data.ID,
|
||||
"Title",
|
||||
p.Data.Title,
|
||||
"title",
|
||||
),
|
||||
editableKeyValue(
|
||||
p.Data.ID,
|
||||
"Author",
|
||||
p.Data.Author,
|
||||
"author",
|
||||
),
|
||||
popoverKeyValue(
|
||||
"Time Read",
|
||||
formatters.FormatDuration(p.Data.TotalTimeRead),
|
||||
"info",
|
||||
p.detailsPopover(),
|
||||
),
|
||||
|
||||
ui.KeyValue(
|
||||
g.Text("Progress"),
|
||||
g.Text(fmt.Sprintf("%.2f%%", p.Data.Percentage)),
|
||||
),
|
||||
ui.KeyValue(
|
||||
g.Text("ISBN-10"),
|
||||
g.Text(utils.FirstNonZero(p.Data.ISBN10, "N/A")),
|
||||
),
|
||||
ui.KeyValue(
|
||||
g.Text("ISBN-13"),
|
||||
g.Text(utils.FirstNonZero(p.Data.ISBN13, "N/A")),
|
||||
),
|
||||
),
|
||||
|
||||
editableKeyValue(
|
||||
p.Data.ID,
|
||||
"Description",
|
||||
p.Data.Description,
|
||||
"description",
|
||||
ui.PopoverConfig{Classes: "w-full"},
|
||||
),
|
||||
|
||||
document.IdentifyPopover(p.Data.ID, p.Search),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Document) detailsPopover() g.Node {
|
||||
totalTimeLeft := time.Duration((100.0 - p.Data.Percentage) * float64(p.Data.TimePerPercent))
|
||||
percentPerHour := 1.0 / p.Data.TimePerPercent.Hours()
|
||||
return h.Div(
|
||||
statKV("WPM", fmt.Sprint(p.Data.WPM)),
|
||||
statKV("Words", formatters.FormatNumber(ptr.Deref(p.Data.Words))),
|
||||
statKV("Hourly Rate", fmt.Sprintf("%.1f%%", percentPerHour)),
|
||||
statKV("Time Remaining", formatters.FormatDuration(totalTimeLeft)),
|
||||
)
|
||||
}
|
||||
|
||||
func popoverKeyValue(title, value, icon string, popover g.Node, popoverCfg ...ui.PopoverConfig) g.Node {
|
||||
return ui.KeyValue(
|
||||
ui.AnchoredPopover(
|
||||
h.Div(
|
||||
h.Class("inline-flex gap-2 items-center"),
|
||||
h.P(g.Text(title)),
|
||||
ui.SpanButton(assets.Icon(icon, 18), ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
|
||||
),
|
||||
popover,
|
||||
popoverCfg...,
|
||||
),
|
||||
g.Text(value),
|
||||
)
|
||||
}
|
||||
|
||||
func editableKeyValue(id, title, currentValue, formKey string, popoverCfg ...ui.PopoverConfig) g.Node {
|
||||
currentValue = utils.FirstNonZero(currentValue, "N/A")
|
||||
editPopover := h.Form(
|
||||
h.Class("flex flex-col gap-2"),
|
||||
h.Action(fmt.Sprintf("./%s/edit", id)),
|
||||
h.Method("POST"),
|
||||
h.Textarea(
|
||||
h.ID(formKey),
|
||||
h.Name(formKey),
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
g.Text(currentValue),
|
||||
),
|
||||
ui.FormButton(g.Text("Save"), ""),
|
||||
)
|
||||
return popoverKeyValue(title, currentValue, "edit", editPopover, popoverCfg...)
|
||||
}
|
||||
|
||||
func statKV(title, val string) g.Node {
|
||||
return ui.HKeyValue(
|
||||
h.P(h.Class("text-xs w-24 text-gray-400"), g.Text(title)),
|
||||
h.P(h.Class("text-xs text-nowrap"), g.Text(val)),
|
||||
)
|
||||
}
|
121
web/pages/documents.go
Normal file
121
web/pages/documents.go
Normal file
@ -0,0 +1,121 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/components/document"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
var _ Page = (*Documents)(nil)
|
||||
|
||||
type Documents struct {
|
||||
Data []models.Document
|
||||
Previous int
|
||||
Next int
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (Documents) Route() PageRoute { return DocumentsPage }
|
||||
|
||||
func (p Documents) Render() g.Node {
|
||||
return g.Group([]g.Node{
|
||||
searchBar(),
|
||||
documentGrid(p.Data),
|
||||
pagination(p.Previous, p.Next, p.Limit),
|
||||
uploadFAB(),
|
||||
})
|
||||
}
|
||||
|
||||
func searchBar() g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
|
||||
h.Form(
|
||||
h.Action("./documents"),
|
||||
h.Method("GET"),
|
||||
h.Class("flex gap-4 flex-col lg:flex-row"),
|
||||
h.Div(
|
||||
h.Class("flex flex-col w-full grow"),
|
||||
h.Div(
|
||||
h.Class("flex relative"),
|
||||
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("search2", 15),
|
||||
),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.ID("search"),
|
||||
h.Name("search"),
|
||||
h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 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("Search Author / Title"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("lg:w-60"),
|
||||
ui.FormButton(g.Text("Search"), "", ui.ButtonConfig{Variant: ui.ButtonVariantSecondary}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func documentGrid(docs []models.Document) g.Node {
|
||||
return h.Div(
|
||||
h.Class("grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"),
|
||||
g.Map(docs, func(d models.Document) g.Node { return document.Card(d) }),
|
||||
)
|
||||
}
|
||||
|
||||
func pagination(prev, next int, limit int) g.Node {
|
||||
link := func(page int, label string) g.Node {
|
||||
return h.A(
|
||||
h.Href(fmt.Sprintf("./documents?page=%d&limit=%d", page, limit)),
|
||||
h.Class("bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"),
|
||||
g.Text(label),
|
||||
)
|
||||
}
|
||||
return h.Div(
|
||||
h.Class("w-full flex gap-4 justify-center mt-4 text-black dark:text-white"),
|
||||
g.If(prev > 0, link(prev, "◄")),
|
||||
g.If(next > 0, link(next, "►")),
|
||||
)
|
||||
}
|
||||
|
||||
func uploadFAB() g.Node {
|
||||
return h.Div(
|
||||
h.Class("fixed bottom-6 right-6 rounded-full flex items-center justify-center"),
|
||||
h.Input(h.Type("checkbox"), h.ID("upload-file-button"), h.Class("hidden css-button")),
|
||||
h.Div(
|
||||
h.Class("absolute right-0 z-10 bottom-0 rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2"),
|
||||
h.Form(
|
||||
h.Method("POST"),
|
||||
g.Attr("enctype", "multipart/form-data"),
|
||||
h.Action("./documents"),
|
||||
h.Class("flex flex-col gap-2"),
|
||||
h.Input(
|
||||
h.Type("file"),
|
||||
h.Accept(".epub"),
|
||||
h.ID("document_file"),
|
||||
h.Name("document_file"),
|
||||
),
|
||||
ui.FormButton(g.Text("Upload File"), ""),
|
||||
),
|
||||
h.Label(
|
||||
h.For("upload-file-button"),
|
||||
h.Div(
|
||||
h.Class("w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"),
|
||||
g.Text("Cancel Upload"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Label(
|
||||
h.For("upload-file-button"),
|
||||
h.Class("w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"),
|
||||
assets.Icon("upload", 34),
|
||||
),
|
||||
)
|
||||
}
|
66
web/pages/home.go
Normal file
66
web/pages/home.go
Normal file
@ -0,0 +1,66 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/web/components/stats"
|
||||
)
|
||||
|
||||
var _ Page = (*Home)(nil)
|
||||
|
||||
type Home struct {
|
||||
Leaderboard []stats.LeaderboardData
|
||||
Streaks []database.UserStreak
|
||||
DailyStats []database.GetDailyReadStatsRow
|
||||
RecordInfo *database.GetDatabaseInfoRow
|
||||
}
|
||||
|
||||
func (Home) Route() PageRoute { return HomePage }
|
||||
|
||||
func (p Home) Render() g.Node {
|
||||
return h.Div(
|
||||
g.Attr("class", "flex flex-col gap-4"),
|
||||
h.Div(
|
||||
g.Attr("class", "w-full"),
|
||||
h.Div(
|
||||
g.Attr("class", "relative w-full bg-white shadow-lg dark:bg-gray-700 rounded"),
|
||||
h.P(
|
||||
g.Attr("class", "absolute top-3 left-5 text-sm font-semibold border-b border-gray-200 w-max dark:border-gray-500"),
|
||||
g.Text("Daily Read Totals"),
|
||||
),
|
||||
stats.MonthlyChart(p.DailyStats),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "grid grid-cols-2 gap-4 md:grid-cols-4"),
|
||||
stats.InfoCard(stats.InfoCardData{
|
||||
Title: "Documents",
|
||||
Size: p.RecordInfo.DocumentsSize,
|
||||
Link: "./documents",
|
||||
}),
|
||||
stats.InfoCard(stats.InfoCardData{
|
||||
Title: "Activity Records",
|
||||
Size: p.RecordInfo.ActivitySize,
|
||||
Link: "./activity",
|
||||
}),
|
||||
stats.InfoCard(stats.InfoCardData{
|
||||
Title: "Progress Records",
|
||||
Size: p.RecordInfo.ProgressSize,
|
||||
Link: "./progress",
|
||||
}),
|
||||
stats.InfoCard(stats.InfoCardData{
|
||||
Title: "Devices",
|
||||
Size: p.RecordInfo.DevicesSize,
|
||||
}),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "grid grid-cols-1 gap-4 md:grid-cols-2"),
|
||||
g.Map(p.Streaks, stats.StreakCard),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"),
|
||||
g.Map(p.Leaderboard, stats.LeaderboardCard),
|
||||
),
|
||||
)
|
||||
}
|
42
web/pages/page.go
Normal file
42
web/pages/page.go
Normal file
@ -0,0 +1,42 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
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 {
|
||||
Route() PageRoute
|
||||
Render() g.Node
|
||||
}
|
56
web/pages/progress.go
Normal file
56
web/pages/progress.go
Normal file
@ -0,0 +1,56 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
var _ Page = (*Progress)(nil)
|
||||
|
||||
type Progress struct {
|
||||
Data []models.Progress
|
||||
}
|
||||
|
||||
func (Progress) Route() PageRoute { return ProgressPage }
|
||||
|
||||
func (p Progress) Render() g.Node {
|
||||
return h.Div(
|
||||
h.Class("overflow-x-auto"),
|
||||
h.Div(
|
||||
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
|
||||
ui.Table(p.buildTableConfig()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Progress) buildTableConfig() ui.TableConfig {
|
||||
return ui.TableConfig{
|
||||
Columns: []string{"Document", "Device Name", "Percentage", "Created At"},
|
||||
Rows: sliceutils.Map(p.Data, toProgressTableRow),
|
||||
}
|
||||
}
|
||||
|
||||
func toProgressTableRow(r models.Progress) ui.TableRow {
|
||||
return ui.TableRow{
|
||||
"Document": ui.TableCell{
|
||||
Value: h.A(
|
||||
h.Href(fmt.Sprintf("./documents/%s", r.ID)),
|
||||
g.Text(fmt.Sprintf("%s - %s", r.Author, r.Title)),
|
||||
),
|
||||
},
|
||||
"Device Name": ui.TableCell{
|
||||
String: r.DeviceName,
|
||||
},
|
||||
"Percentage": ui.TableCell{
|
||||
String: fmt.Sprintf("%.2f%%", r.Percentage),
|
||||
},
|
||||
"Created At": ui.TableCell{
|
||||
String: r.CreatedAt,
|
||||
},
|
||||
}
|
||||
}
|
129
web/pages/search.go
Normal file
129
web/pages/search.go
Normal file
@ -0,0 +1,129 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/search"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
var _ Page = (*Search)(nil)
|
||||
|
||||
type Search struct {
|
||||
Query string
|
||||
Source search.Source
|
||||
Results []models.SearchResult
|
||||
Error string
|
||||
}
|
||||
|
||||
func (Search) Route() PageRoute { return SearchPage }
|
||||
|
||||
func (p Search) Render() g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col gap-4"),
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700"),
|
||||
h.Form(
|
||||
h.Class("flex gap-4 flex-col lg:flex-row"),
|
||||
h.Action("./search"),
|
||||
h.Div(
|
||||
h.Class("flex w-full"),
|
||||
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("search2", 15),
|
||||
),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.ID("query"),
|
||||
h.Name("query"),
|
||||
h.Value(p.Query),
|
||||
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("Query"),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("flex relative min-w-[12em]"),
|
||||
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("documents", 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("source"),
|
||||
h.Name("source"),
|
||||
h.Option(
|
||||
h.Value("LibGen"),
|
||||
g.If(p.Source == search.SourceLibGen, h.Selected()),
|
||||
g.Text("Library Genesis"),
|
||||
),
|
||||
h.Option(
|
||||
h.Value("Annas Archive"),
|
||||
g.If(p.Source == search.SourceAnnasArchive, h.Selected()),
|
||||
g.Text("Annas Archive"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("lg:w-60"),
|
||||
ui.FormButton(
|
||||
g.Text("Search"),
|
||||
"",
|
||||
ui.ButtonConfig{Variant: ui.ButtonVariantSecondary},
|
||||
),
|
||||
),
|
||||
),
|
||||
g.If(
|
||||
p.Error != "",
|
||||
h.Span(h.Class("text-red-400 text-xs"), g.Text(p.Error)),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
|
||||
ui.Table(
|
||||
ui.TableConfig{
|
||||
Columns: []string{"", "Document", "Series", "Type", "Size", "Date"},
|
||||
Rows: p.tableRows(),
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (p Search) tableRows() []ui.TableRow {
|
||||
return sliceutils.Map(p.Results, func(r models.SearchResult) ui.TableRow {
|
||||
return ui.TableRow{
|
||||
"": ui.TableCell{
|
||||
Value: h.Form(
|
||||
h.Action("./search"),
|
||||
h.Method("POST"),
|
||||
h.Input(h.Type("hidden"), h.Name("source"), h.Value(string(p.Source))),
|
||||
h.Input(h.Type("hidden"), h.Name("title"), h.Value(r.Title)),
|
||||
h.Input(h.Type("hidden"), h.Name("author"), h.Value(r.Author)),
|
||||
ui.FormButton(assets.Icon("download", 24), "", ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
|
||||
),
|
||||
},
|
||||
"Document": ui.TableCell{
|
||||
String: fmt.Sprintf("%s - %s", r.Author, r.Title),
|
||||
},
|
||||
"Series": ui.TableCell{
|
||||
String: utils.FirstNonZero(r.Series, "N/A"),
|
||||
},
|
||||
"Type": ui.TableCell{
|
||||
String: utils.FirstNonZero(r.FileType, "N/A"),
|
||||
},
|
||||
"Size": ui.TableCell{
|
||||
String: utils.FirstNonZero(r.FileSize, "N/A"),
|
||||
},
|
||||
"Date": ui.TableCell{
|
||||
String: utils.FirstNonZero(r.UploadDate, "N/A"),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user