Compare commits
4 Commits
27e651c4f5
...
gocomponen
| Author | SHA1 | Date | |
|---|---|---|---|
| c20aa88574 | |||
| de909b0af7 | |||
| c4f4dcf51e | |||
| 99e55ff568 |
6
.golangci.toml
Normal file
6
.golangci.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#:schema https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
||||||
|
version = "2"
|
||||||
|
|
||||||
|
[[linters.exclusions.rules]]
|
||||||
|
linters = [ "errcheck" ]
|
||||||
|
source = "^\\s*defer\\s+"
|
||||||
31
AGENTS.md
31
AGENTS.md
@@ -1,31 +0,0 @@
|
|||||||
# AnthoLume - Agent Context
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
### Generated Files
|
|
||||||
- **NEVER edit generated files directly** - Always edit the source and regenerate
|
|
||||||
- Go backend API: Edit `api/v1/openapi.yaml` then run:
|
|
||||||
- `go generate ./api/v1/generate.go`
|
|
||||||
- `cd frontend && bun run generate:api`
|
|
||||||
- Examples of generated files:
|
|
||||||
- `api/v1/api.gen.go`
|
|
||||||
- `frontend/src/generated/**/*.ts`
|
|
||||||
|
|
||||||
### Database Access
|
|
||||||
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
|
|
||||||
- Define queries in `database/query.sql` and regenerate via `sqlc generate`
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Use `fmt.Errorf("message: %w", err)` for wrapping errors
|
|
||||||
- Do NOT use `github.com/pkg/errors`
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
- **Package manager**: bun (not npm)
|
|
||||||
- **Icons**: Use custom icon components in `src/icons/` (not external icon libraries)
|
|
||||||
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
|
|
||||||
- **Format**: `cd frontend && bun run format` (and `format:fix`)
|
|
||||||
- **Generate API client**: `cd frontend && bun run generate:api`
|
|
||||||
|
|
||||||
## Regeneration
|
|
||||||
- Go backend: `go generate ./api/v1/generate.go`
|
|
||||||
- TS client: `cd frontend && bun run generate:api`
|
|
||||||
2
Makefile
2
Makefile
@@ -27,7 +27,7 @@ docker_build_release_latest: build_tailwind
|
|||||||
--push .
|
--push .
|
||||||
|
|
||||||
build_tailwind:
|
build_tailwind:
|
||||||
tailwindcss build -o ./assets/style.css --minify
|
tailwindcss build -o ./assets/tailwind.css --minify
|
||||||
|
|
||||||
dev: build_tailwind
|
dev: build_tailwind
|
||||||
GIN_MODE=release \
|
GIN_MODE=release \
|
||||||
|
|||||||
73
api/api.go
73
api/api.go
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/gin-contrib/sessions/cookie"
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/antholume/config"
|
"reichard.io/antholume/config"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
@@ -113,11 +114,6 @@ func (api *API) Start() error {
|
|||||||
return api.httpServer.ListenAndServe()
|
return api.httpServer.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the underlying http.Handler for the Gin router
|
|
||||||
func (api *API) Handler() http.Handler {
|
|
||||||
return api.httpServer.Handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) Stop() error {
|
func (api *API) Stop() error {
|
||||||
// Stop server
|
// Stop server
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -140,35 +136,43 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
|||||||
router.GET("/favicon.ico", api.appFaviconIcon)
|
router.GET("/favicon.ico", api.appFaviconIcon)
|
||||||
router.GET("/sw.js", api.appServiceWorker)
|
router.GET("/sw.js", api.appServiceWorker)
|
||||||
|
|
||||||
// Local / offline static pages (no template, no auth)
|
// Web App - Offline
|
||||||
router.GET("/local", api.appLocalDocuments)
|
router.GET("/local", api.appLocalDocuments)
|
||||||
|
|
||||||
// Reader (reader page, document progress, devices)
|
// Web App - Reader
|
||||||
router.GET("/reader", api.appDocumentReader)
|
router.GET("/reader", api.appDocumentReader)
|
||||||
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
|
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
|
||||||
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
||||||
|
|
||||||
// Web app
|
// Web App - Templates
|
||||||
router.GET("/", api.authWebAppMiddleware, api.appGetHome)
|
router.GET("/", api.authWebAppMiddleware, api.appGetHome) // DONE
|
||||||
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity)
|
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity) // DONE
|
||||||
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress)
|
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress) // DONE
|
||||||
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments)
|
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments) // DONE
|
||||||
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument)
|
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument) // DONE
|
||||||
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage))
|
|
||||||
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage))
|
// 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) // DONE
|
||||||
|
router.POST("/login", api.appAuthLogin) // DONE
|
||||||
|
router.POST("/register", api.appAuthRegister) // DONE
|
||||||
|
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings) // DONE
|
||||||
|
|
||||||
|
// TODO
|
||||||
router.GET("/login", api.appGetLogin)
|
router.GET("/login", api.appGetLogin)
|
||||||
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
|
|
||||||
router.GET("/register", api.appGetRegister)
|
router.GET("/register", api.appGetRegister)
|
||||||
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
|
|
||||||
|
// DONE
|
||||||
|
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
||||||
|
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
||||||
|
|
||||||
|
// TODO - WIP
|
||||||
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
||||||
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
|
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
|
||||||
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
|
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
|
||||||
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
|
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
|
||||||
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
|
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
|
// Demo mode enabled configuration
|
||||||
if api.cfg.DemoMode {
|
if api.cfg.DemoMode {
|
||||||
@@ -178,17 +182,18 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
|||||||
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
|
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
|
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
} else {
|
} else {
|
||||||
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument)
|
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument) // DONE
|
||||||
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument)
|
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE
|
||||||
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument)
|
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE
|
||||||
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocument)
|
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE
|
||||||
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings)
|
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // DONE
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search enabled configuration
|
// Search enabled configuration
|
||||||
if api.cfg.SearchEnabled {
|
if api.cfg.SearchEnabled {
|
||||||
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch)
|
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch) // DONE
|
||||||
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument)
|
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +302,7 @@ func (api *API) loadTemplates(
|
|||||||
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
|
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
|
||||||
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
|
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to read template dir %s: %w", templateDirectory, err)
|
return errors.Wrap(err, fmt.Sprintf("unable to read template dir: %s", templateDirectory))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Templates
|
// Generate Templates
|
||||||
@@ -309,7 +314,7 @@ func (api *API) loadTemplates(
|
|||||||
// Read Template
|
// Read Template
|
||||||
b, err := fs.ReadFile(api.assets, templatePath)
|
b, err := fs.ReadFile(api.assets, templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to read template %s: %w", templateName, err)
|
return errors.Wrap(err, fmt.Sprintf("unable to read template: %s", templateName))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone? (Pages - Don't Stomp)
|
// Clone? (Pages - Don't Stomp)
|
||||||
@@ -320,7 +325,7 @@ func (api *API) loadTemplates(
|
|||||||
// Parse Template
|
// Parse Template
|
||||||
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
|
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to parse template %s: %w", templateName, err)
|
return errors.Wrap(err, fmt.Sprintf("unable to parse template: %s", templateName))
|
||||||
}
|
}
|
||||||
|
|
||||||
allTemplates[templateName] = baseTemplate
|
allTemplates[templateName] = baseTemplate
|
||||||
@@ -358,13 +363,13 @@ func loggingMiddleware(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get username
|
// Get username
|
||||||
var auth authData
|
var auth *authData
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth = data.(authData)
|
auth = data.(*authData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log user
|
// Log user
|
||||||
if auth.UserName != "" {
|
if auth != nil && auth.UserName != "" {
|
||||||
logData["user"] = auth.UserName
|
logData["user"] = auth.UserName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ import (
|
|||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/itchyny/gojq"
|
"github.com/itchyny/gojq"
|
||||||
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
"reichard.io/antholume/utils"
|
"reichard.io/antholume/utils"
|
||||||
|
"reichard.io/antholume/web/models"
|
||||||
|
"reichard.io/antholume/web/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminAction string
|
type adminAction string
|
||||||
@@ -95,21 +98,31 @@ type importResult struct {
|
|||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appPerformAdminAction(c *gin.Context) {
|
func (api *API) appGetAdmin(c *gin.Context) {
|
||||||
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
api.renderPage(c, &pages.AdminGeneral{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appPerformAdminAction(c *gin.Context) {
|
||||||
var rAdminAction requestAdminAction
|
var rAdminAction requestAdminAction
|
||||||
if err := c.ShouldBind(&rAdminAction); err != nil {
|
if err := c.ShouldBind(&rAdminAction); err != nil {
|
||||||
log.Error("Invalid Form Bind: ", err)
|
log.Error("invalid or missing form values")
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allNotifications []*models.Notification
|
||||||
switch rAdminAction.Action {
|
switch rAdminAction.Action {
|
||||||
|
case adminRestore:
|
||||||
|
api.processRestoreFile(rAdminAction, c)
|
||||||
|
return
|
||||||
|
case adminBackup:
|
||||||
|
api.processBackup(c, rAdminAction.BackupTypes)
|
||||||
|
return
|
||||||
case adminMetadataMatch:
|
case adminMetadataMatch:
|
||||||
// TODO
|
allNotifications = append(allNotifications, &models.Notification{
|
||||||
// 1. Documents xref most recent metadata table?
|
Type: models.NotificationTypeError,
|
||||||
// 2. Select all / deselect?
|
Content: "Metadata match not implemented",
|
||||||
|
})
|
||||||
case adminCacheTables:
|
case adminCacheTables:
|
||||||
go func() {
|
go func() {
|
||||||
err := api.db.CacheTempTables(c)
|
err := api.db.CacheTempTables(c)
|
||||||
@@ -117,49 +130,14 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
|
|||||||
log.Error("Unable to cache temp tables: ", err)
|
log.Error("Unable to cache temp tables: ", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
case adminRestore:
|
|
||||||
api.processRestoreFile(rAdminAction, c)
|
|
||||||
return
|
|
||||||
case adminBackup:
|
|
||||||
// Vacuum
|
|
||||||
_, err := api.db.DB.ExecContext(c, "VACUUM;")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to vacuum DB: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Headers
|
allNotifications = append(allNotifications, &models.Notification{
|
||||||
c.Header("Content-type", "application/octet-stream")
|
Type: models.NotificationTypeSuccess,
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
|
Content: "Initiated table cache",
|
||||||
|
|
||||||
// Stream Backup ZIP Archive
|
|
||||||
c.Stream(func(w io.Writer) bool {
|
|
||||||
var directories []string
|
|
||||||
for _, item := range rAdminAction.BackupTypes {
|
|
||||||
if item == backupCovers {
|
|
||||||
directories = append(directories, "covers")
|
|
||||||
} else if item == backupDocuments {
|
|
||||||
directories = append(directories, "documents")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := api.createBackup(c, w, directories)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Backup Error: ", err)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/admin", templateVars)
|
api.renderPage(c, &pages.AdminGeneral{}, allNotifications...)
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetAdmin(c *gin.Context) {
|
|
||||||
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
|
||||||
c.HTML(http.StatusOK, "page/admin", templateVars)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetAdminLogs(c *gin.Context) {
|
func (api *API) appGetAdminLogs(c *gin.Context) {
|
||||||
@@ -532,6 +510,40 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
|
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) processBackup(c *gin.Context, backupTypes []backupType) {
|
||||||
|
// Vacuum
|
||||||
|
_, err := api.db.DB.ExecContext(c, "VACUUM;")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to vacuum DB: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Headers
|
||||||
|
c.Header("Content-type", "application/octet-stream")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
|
||||||
|
|
||||||
|
// Stream Backup ZIP Archive
|
||||||
|
c.Stream(func(w io.Writer) bool {
|
||||||
|
var directories []string
|
||||||
|
for _, item := range backupTypes {
|
||||||
|
switch item {
|
||||||
|
case backupCovers:
|
||||||
|
directories = append(directories, "covers")
|
||||||
|
case backupDocuments:
|
||||||
|
directories = append(directories, "documents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := api.createBackup(c, w, directories)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Backup Error: ", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
||||||
// Validate Type & Derive Extension on MIME
|
// Validate Type & Derive Extension on MIME
|
||||||
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
||||||
@@ -721,7 +733,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
|
|||||||
// Vacuum DB
|
// Vacuum DB
|
||||||
_, err := api.db.DB.ExecContext(ctx, "VACUUM;")
|
_, err := api.db.DB.ExecContext(ctx, "VACUUM;")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Unable to vacuum database: %w", err)
|
return errors.Wrap(err, "Unable to vacuum database")
|
||||||
}
|
}
|
||||||
|
|
||||||
ar := zip.NewWriter(w)
|
ar := zip.NewWriter(w)
|
||||||
@@ -788,14 +800,14 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ar.Close()
|
_ = ar.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
|
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
|
||||||
allUsers, err := api.db.Queries.GetUsers(ctx)
|
allUsers, err := api.db.Queries.GetUsers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("GetUsers DB Error: %w", err)
|
return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAdmin := false
|
hasAdmin := false
|
||||||
@@ -872,7 +884,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
|
|||||||
} else {
|
} else {
|
||||||
user, err := api.db.Queries.GetUser(ctx, user)
|
user, err := api.db.Queries.GetUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("GetUser DB Error: %w", err)
|
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
|
||||||
}
|
}
|
||||||
updateParams.Admin = user.Admin
|
updateParams.Admin = user.Admin
|
||||||
}
|
}
|
||||||
@@ -910,7 +922,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
|
|||||||
// Update User
|
// Update User
|
||||||
_, err := api.db.Queries.UpdateUser(ctx, updateParams)
|
_, err := api.db.Queries.UpdateUser(ctx, updateParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("UpdateUser DB Error: %w", err)
|
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -942,7 +954,7 @@ func (api *API) deleteUser(ctx context.Context, user string) error {
|
|||||||
// Delete User
|
// Delete User
|
||||||
_, err = api.db.Queries.DeleteUser(ctx, user)
|
_, err = api.db.Queries.DeleteUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("DeleteUser DB Error: %w", err)
|
return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
513
api/app-routes-new.go
Normal file
513
api/app-routes-new.go
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
argon2 "github.com/alexedwards/argon2id"
|
||||||
|
"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/stats"
|
||||||
|
"reichard.io/antholume/web/models"
|
||||||
|
"reichard.io/antholume/web/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (api *API) appGetHome(c *gin.Context) {
|
||||||
|
_, auth := api.getBaseTemplateVars("home", c)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get daily read stats")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get daily read stats: %s", 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.WithError(err).Error("failed to get database info")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get database info: %s", 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.WithError(err).Error("failed to get user streaks")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user streaks: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
userStatistics, err := api.db.Queries.GetUserStatistics(c)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get user statistics")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user statistics: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Home{
|
||||||
|
Leaderboard: arrangeUserStatistic(userStatistics),
|
||||||
|
Streaks: streaks,
|
||||||
|
DailyStats: dailyStats,
|
||||||
|
RecordInfo: &databaseInfo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetDocuments(c *gin.Context) {
|
||||||
|
qParams, err := bindQueryParams(c, 9)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind query params")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var query *string
|
||||||
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
|
search := "%" + *qParams.Search + "%"
|
||||||
|
query = &search
|
||||||
|
}
|
||||||
|
|
||||||
|
_, auth := api.getBaseTemplateVars("documents", c)
|
||||||
|
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.WithError(err).Error("failed to get documents with stats")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get documents with stats: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
length, err := api.db.Queries.GetDocumentsSize(c, query)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get document sizes")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document sizes: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = api.getDocumentsWordCount(c, documents); err != nil {
|
||||||
|
log.WithError(err).Error("failed to get word counts")
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
||||||
|
nextPage := *qParams.Page + 1
|
||||||
|
previousPage := *qParams.Page - 1
|
||||||
|
|
||||||
|
api.renderPage(c, 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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetDocument(c *gin.Context) {
|
||||||
|
var rDocID requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind URI")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get document")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Document{Data: convertDBDocToUI(*document)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetActivity(c *gin.Context) {
|
||||||
|
qParams, err := bindQueryParams(c, 15)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind query params")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, auth := api.getBaseTemplateVars("activity", c)
|
||||||
|
activity, err := api.db.Queries.GetActivity(c, database.GetActivityParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
|
Limit: *qParams.Limit,
|
||||||
|
DocFilter: qParams.Document != nil,
|
||||||
|
DocumentID: ptr.Deref(qParams.Document),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get activity")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get activity: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Activity{Data: sliceutils.Map(activity, convertDBActivityToUI)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetProgress(c *gin.Context) {
|
||||||
|
qParams, err := bindQueryParams(c, 15)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind query params")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, auth := api.getBaseTemplateVars("progress", c)
|
||||||
|
progress, err := api.db.Queries.GetProgress(c, database.GetProgressParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
|
Limit: *qParams.Limit,
|
||||||
|
DocFilter: qParams.Document != nil,
|
||||||
|
DocumentID: ptr.Deref(qParams.Document),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get progress")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get progress: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Progress{Data: sliceutils.Map(progress, convertDBProgressToUI)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appIdentifyDocumentNew(c *gin.Context) {
|
||||||
|
var rDocID requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind URI")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rDocIdentify requestDocumentIdentify
|
||||||
|
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind form")
|
||||||
|
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 or missing form values")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Metadata
|
||||||
|
var searchResult *models.DocumentMetadata
|
||||||
|
var allNotifications []*models.Notification
|
||||||
|
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
||||||
|
Title: rDocIdentify.Title,
|
||||||
|
Author: rDocIdentify.Author,
|
||||||
|
ISBN10: rDocIdentify.ISBN,
|
||||||
|
ISBN13: rDocIdentify.ISBN,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to search metadata")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to search metadata: %s", err))
|
||||||
|
return
|
||||||
|
} else if firstResult, found := sliceutils.First(metadataResults); found {
|
||||||
|
searchResult = convertMetaToUI(firstResult)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
Isbn10: firstResult.ISBN10,
|
||||||
|
Isbn13: firstResult.ISBN13,
|
||||||
|
}); err != nil {
|
||||||
|
log.WithError(err).Error("failed to add metadata")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allNotifications = append(allNotifications, &models.Notification{
|
||||||
|
Type: models.NotificationTypeError,
|
||||||
|
Content: "No Metadata Found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Auth
|
||||||
|
_, auth := api.getBaseTemplateVars("document", c)
|
||||||
|
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get document")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Document{
|
||||||
|
Data: convertDBDocToUI(*document),
|
||||||
|
Search: searchResult,
|
||||||
|
}, allNotifications...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs:
|
||||||
|
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
||||||
|
// - Users
|
||||||
|
// - Metadata
|
||||||
|
func (api *API) appGetSearch(c *gin.Context) {
|
||||||
|
var sParams searchParams
|
||||||
|
if err := c.BindQuery(&sParams); err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind form")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
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 {
|
||||||
|
log.WithError(err).Error("failed to search book")
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Search{
|
||||||
|
Results: searchResults,
|
||||||
|
Source: ptr.Deref(sParams.Source),
|
||||||
|
Query: ptr.Deref(sParams.Query),
|
||||||
|
Error: searchError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetSettings(c *gin.Context) {
|
||||||
|
_, auth := api.getBaseTemplateVars("settings", c)
|
||||||
|
|
||||||
|
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get user")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get devices")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Settings{
|
||||||
|
Timezone: ptr.Deref(user.Timezone),
|
||||||
|
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appEditSettings(c *gin.Context) {
|
||||||
|
var rUserSettings requestSettingsEdit
|
||||||
|
if err := c.ShouldBind(&rUserSettings); err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind form")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Something Exists
|
||||||
|
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
|
||||||
|
log.Error("invalid or missing form values")
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, auth := api.getBaseTemplateVars("settings", c)
|
||||||
|
|
||||||
|
newUserSettings := database.UpdateUserParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Admin: auth.IsAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set New Password
|
||||||
|
var allNotifications []*models.Notification
|
||||||
|
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
|
||||||
|
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
|
||||||
|
if _, err := api.authorizeCredentials(c, auth.UserName, password); err != nil {
|
||||||
|
allNotifications = append(allNotifications, &models.Notification{
|
||||||
|
Type: models.NotificationTypeError,
|
||||||
|
Content: "Invalid Password",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
|
||||||
|
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
||||||
|
if err != nil {
|
||||||
|
allNotifications = append(allNotifications, &models.Notification{
|
||||||
|
Type: models.NotificationTypeError,
|
||||||
|
Content: "Unknown Error",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
allNotifications = append(allNotifications, &models.Notification{
|
||||||
|
Type: models.NotificationTypeSuccess,
|
||||||
|
Content: "Password Updated",
|
||||||
|
})
|
||||||
|
newUserSettings.Password = &hashedPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Time Offset
|
||||||
|
if rUserSettings.Timezone != nil {
|
||||||
|
allNotifications = append(allNotifications, &models.Notification{
|
||||||
|
Type: models.NotificationTypeSuccess,
|
||||||
|
Content: "Time Offset Updated",
|
||||||
|
})
|
||||||
|
newUserSettings.Timezone = rUserSettings.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update User
|
||||||
|
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to update user")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to update user: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get User
|
||||||
|
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get user")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Devices
|
||||||
|
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to get devices")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.renderPage(c, &pages.Settings{
|
||||||
|
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
|
||||||
|
Timezone: ptr.Deref(user.Timezone),
|
||||||
|
}, allNotifications...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) renderPage(c *gin.Context, page pages.Page, notifications ...*models.Notification) {
|
||||||
|
// Get Authentication Data
|
||||||
|
auth, err := getAuthData(c)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to acquire auth data")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to acquire auth data: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Page
|
||||||
|
pageNode, err := page.Generate(models.PageContext{
|
||||||
|
UserInfo: &models.UserInfo{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
},
|
||||||
|
ServerInfo: &models.ServerInfo{
|
||||||
|
RegistrationEnabled: api.cfg.RegistrationEnabled,
|
||||||
|
SearchEnabled: api.cfg.SearchEnabled,
|
||||||
|
Version: api.cfg.Version,
|
||||||
|
},
|
||||||
|
Notifications: notifications,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to generate page")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to generate page: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Page
|
||||||
|
err = pageNode.Render(c.Writer)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to render page")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to render page: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 arrangeUserStatistic(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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,28 +2,22 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
argon2 "github.com/alexedwards/argon2id"
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
"reichard.io/antholume/pkg/ptr"
|
|
||||||
"reichard.io/antholume/search"
|
"reichard.io/antholume/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,242 +95,6 @@ func (api *API) appDocumentReader(c *gin.Context) {
|
|||||||
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
|
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appGetDocuments(c *gin.Context) {
|
|
||||||
templateVars, 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
|
|
||||||
|
|
||||||
if nextPage <= totalPages {
|
|
||||||
templateVars["NextPage"] = nextPage
|
|
||||||
}
|
|
||||||
|
|
||||||
if previousPage >= 0 {
|
|
||||||
templateVars["PreviousPage"] = previousPage
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["PageLimit"] = *qParams.Limit
|
|
||||||
templateVars["Data"] = documents
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/documents", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetDocument(c *gin.Context) {
|
|
||||||
templateVars, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = document
|
|
||||||
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/document", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetProgress(c *gin.Context) {
|
|
||||||
templateVars, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = progress
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/progress", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetActivity(c *gin.Context) {
|
|
||||||
templateVars, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = activity
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/activity", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetHome(c *gin.Context) {
|
|
||||||
templateVars, auth := api.getBaseTemplateVars("home", c)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
graphData, 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))
|
|
||||||
|
|
||||||
templateVars["Data"] = gin.H{
|
|
||||||
"Streaks": streaks,
|
|
||||||
"GraphData": graphData,
|
|
||||||
"DatabaseInfo": databaseInfo,
|
|
||||||
"UserStatistics": arrangeUserStatistics(userStatistics),
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/home", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetSettings(c *gin.Context) {
|
|
||||||
templateVars, auth := api.getBaseTemplateVars("settings", c)
|
|
||||||
|
|
||||||
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetUser DB Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetDevices DB Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = gin.H{
|
|
||||||
"Timezone": *user.Timezone,
|
|
||||||
"Devices": devices,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/settings", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tabs:
|
|
||||||
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
|
||||||
// - Users
|
|
||||||
// - Metadata
|
|
||||||
func (api *API) appGetSearch(c *gin.Context) {
|
|
||||||
templateVars, _ := 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
|
|
||||||
if sParams.Query != nil && sParams.Source != nil {
|
|
||||||
// Search
|
|
||||||
searchResults, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
|
||||||
if err != nil {
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = searchResults
|
|
||||||
templateVars["Source"] = *sParams.Source
|
|
||||||
} else if sParams.Query != nil || sParams.Source != nil {
|
|
||||||
templateVars["SearchErrorMessage"] = "Invalid Query"
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/search", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appGetLogin(c *gin.Context) {
|
func (api *API) appGetLogin(c *gin.Context) {
|
||||||
templateVars, _ := api.getBaseTemplateVars("login", c)
|
templateVars, _ := api.getBaseTemplateVars("login", c)
|
||||||
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
|
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
|
||||||
@@ -617,85 +375,6 @@ func (api *API) appDeleteDocument(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "../")
|
c.Redirect(http.StatusFound, "../")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appIdentifyDocument(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
|
|
||||||
templateVars, auth := api.getBaseTemplateVars("document", c)
|
|
||||||
|
|
||||||
// Get Metadata
|
|
||||||
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
|
||||||
Title: rDocIdentify.Title,
|
|
||||||
Author: rDocIdentify.Author,
|
|
||||||
ISBN10: rDocIdentify.ISBN,
|
|
||||||
ISBN13: rDocIdentify.ISBN,
|
|
||||||
})
|
|
||||||
if err == nil && len(metadataResults) > 0 {
|
|
||||||
firstResult := metadataResults[0]
|
|
||||||
|
|
||||||
// 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.ID,
|
|
||||||
Olid: nil,
|
|
||||||
Isbn10: firstResult.ISBN10,
|
|
||||||
Isbn13: firstResult.ISBN13,
|
|
||||||
}); err != nil {
|
|
||||||
log.Error("AddMetadata DB Error: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Metadata"] = firstResult
|
|
||||||
} else {
|
|
||||||
log.Warn("Metadata Error")
|
|
||||||
templateVars["MetadataError"] = "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
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = document
|
|
||||||
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/document", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appSaveNewDocument(c *gin.Context) {
|
func (api *API) appSaveNewDocument(c *gin.Context) {
|
||||||
var rDocAdd requestDocumentAdd
|
var rDocAdd requestDocumentAdd
|
||||||
if err := c.ShouldBind(&rDocAdd); err != nil {
|
if err := c.ShouldBind(&rDocAdd); err != nil {
|
||||||
@@ -833,84 +512,6 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) appEditSettings(c *gin.Context) {
|
|
||||||
var rUserSettings requestSettingsEdit
|
|
||||||
if err := c.ShouldBind(&rUserSettings); err != nil {
|
|
||||||
log.Error("Invalid Form Bind")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Something Exists
|
|
||||||
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
|
|
||||||
log.Error("Missing Form Values")
|
|
||||||
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars, auth := api.getBaseTemplateVars("settings", c)
|
|
||||||
|
|
||||||
newUserSettings := database.UpdateUserParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Admin: auth.IsAdmin,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set New Password
|
|
||||||
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
|
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
|
|
||||||
data := api.authorizeCredentials(c, auth.UserName, password)
|
|
||||||
if data == nil {
|
|
||||||
templateVars["PasswordErrorMessage"] = "Invalid Password"
|
|
||||||
} else {
|
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
|
|
||||||
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
|
||||||
if err != nil {
|
|
||||||
templateVars["PasswordErrorMessage"] = "Unknown Error"
|
|
||||||
} else {
|
|
||||||
templateVars["PasswordMessage"] = "Password Updated"
|
|
||||||
newUserSettings.Password = &hashedPassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Time Offset
|
|
||||||
if rUserSettings.Timezone != nil {
|
|
||||||
templateVars["TimeOffsetMessage"] = "Time Offset Updated"
|
|
||||||
newUserSettings.Timezone = rUserSettings.Timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update User
|
|
||||||
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("UpdateUser DB Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get User
|
|
||||||
user, err := api.db.Queries.GetUser(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetUser DB Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Devices
|
|
||||||
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetDevices DB Error: ", err)
|
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = gin.H{
|
|
||||||
"Timezone": *user.Timezone,
|
|
||||||
"Devices": devices,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/settings", templateVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) appDemoModeError(c *gin.Context) {
|
func (api *API) appDemoModeError(c *gin.Context) {
|
||||||
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
||||||
}
|
}
|
||||||
@@ -958,10 +559,10 @@ func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, authData) {
|
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *authData) {
|
||||||
var auth authData
|
var auth *authData
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth = data.(authData)
|
auth = data.(*authData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
@@ -975,12 +576,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
|
|||||||
}, auth
|
}, auth
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
|
func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) {
|
||||||
var qParams queryParams
|
var qParams queryParams
|
||||||
err := c.BindQuery(&qParams)
|
err := c.BindQuery(&qParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
return nil, err
|
||||||
return qParams
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if qParams.Limit == nil {
|
if qParams.Limit == nil {
|
||||||
@@ -995,7 +595,7 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
|
|||||||
qParams.Page = &oneValue
|
qParams.Page = &oneValue
|
||||||
}
|
}
|
||||||
|
|
||||||
return qParams
|
return &qParams, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
||||||
@@ -1018,80 +618,3 @@ func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
|||||||
"Message": errorMessage,
|
"Message": errorMessage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H {
|
|
||||||
// Item Sorter
|
|
||||||
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]any {
|
|
||||||
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
|
|
||||||
sort.SliceStable(sortedData, less)
|
|
||||||
|
|
||||||
newData := make([]map[string]any, 0)
|
|
||||||
for _, item := range sortedData {
|
|
||||||
v := reflect.Indirect(reflect.ValueOf(item))
|
|
||||||
|
|
||||||
var value string
|
|
||||||
if strings.Contains(key, "Wpm") {
|
|
||||||
rawVal := v.FieldByName(key).Float()
|
|
||||||
value = fmt.Sprintf("%.2f WPM", rawVal)
|
|
||||||
} else if strings.Contains(key, "Seconds") {
|
|
||||||
rawVal := v.FieldByName(key).Int()
|
|
||||||
value = niceSeconds(rawVal)
|
|
||||||
} else if strings.Contains(key, "Words") {
|
|
||||||
rawVal := v.FieldByName(key).Int()
|
|
||||||
value = niceNumbers(rawVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
newData = append(newData, map[string]any{
|
|
||||||
"UserID": item.UserID,
|
|
||||||
"Value": value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return newData
|
|
||||||
}
|
|
||||||
|
|
||||||
return gin.H{
|
|
||||||
"WPM": gin.H{
|
|
||||||
"All": sortItem(userStatistics, "TotalWpm", func(i, j int) bool {
|
|
||||||
return userStatistics[i].TotalWpm > userStatistics[j].TotalWpm
|
|
||||||
}),
|
|
||||||
"Year": sortItem(userStatistics, "YearlyWpm", func(i, j int) bool {
|
|
||||||
return userStatistics[i].YearlyWpm > userStatistics[j].YearlyWpm
|
|
||||||
}),
|
|
||||||
"Month": sortItem(userStatistics, "MonthlyWpm", func(i, j int) bool {
|
|
||||||
return userStatistics[i].MonthlyWpm > userStatistics[j].MonthlyWpm
|
|
||||||
}),
|
|
||||||
"Week": sortItem(userStatistics, "WeeklyWpm", func(i, j int) bool {
|
|
||||||
return userStatistics[i].WeeklyWpm > userStatistics[j].WeeklyWpm
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Duration": gin.H{
|
|
||||||
"All": sortItem(userStatistics, "TotalSeconds", func(i, j int) bool {
|
|
||||||
return userStatistics[i].TotalSeconds > userStatistics[j].TotalSeconds
|
|
||||||
}),
|
|
||||||
"Year": sortItem(userStatistics, "YearlySeconds", func(i, j int) bool {
|
|
||||||
return userStatistics[i].YearlySeconds > userStatistics[j].YearlySeconds
|
|
||||||
}),
|
|
||||||
"Month": sortItem(userStatistics, "MonthlySeconds", func(i, j int) bool {
|
|
||||||
return userStatistics[i].MonthlySeconds > userStatistics[j].MonthlySeconds
|
|
||||||
}),
|
|
||||||
"Week": sortItem(userStatistics, "WeeklySeconds", func(i, j int) bool {
|
|
||||||
return userStatistics[i].WeeklySeconds > userStatistics[j].WeeklySeconds
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Words": gin.H{
|
|
||||||
"All": sortItem(userStatistics, "TotalWordsRead", func(i, j int) bool {
|
|
||||||
return userStatistics[i].TotalWordsRead > userStatistics[j].TotalWordsRead
|
|
||||||
}),
|
|
||||||
"Year": sortItem(userStatistics, "YearlyWordsRead", func(i, j int) bool {
|
|
||||||
return userStatistics[i].YearlyWordsRead > userStatistics[j].YearlyWordsRead
|
|
||||||
}),
|
|
||||||
"Month": sortItem(userStatistics, "MonthlyWordsRead", func(i, j int) bool {
|
|
||||||
return userStatistics[i].MonthlyWordsRead > userStatistics[j].MonthlyWordsRead
|
|
||||||
}),
|
|
||||||
"Week": sortItem(userStatistics, "WeeklyWordsRead", func(i, j int) bool {
|
|
||||||
return userStatistics[i].WeeklyWordsRead > userStatistics[j].WeeklyWordsRead
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
69
api/auth.go
69
api/auth.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,31 +30,31 @@ type authKOHeader struct {
|
|||||||
AuthKey string `header:"x-auth-key"`
|
AuthKey string `header:"x-auth-key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) {
|
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (*authData, error) {
|
||||||
user, err := api.db.Queries.GetUser(ctx, username)
|
user, err := api.db.Queries.GetUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update auth cache
|
// Update Auth Cache
|
||||||
api.userAuthCache[user.ID] = *user.AuthHash
|
api.userAuthCache[user.ID] = *user.AuthHash
|
||||||
|
|
||||||
return &authData{
|
return &authData{
|
||||||
UserName: user.ID,
|
UserName: user.ID,
|
||||||
IsAdmin: user.Admin,
|
IsAdmin: user.Admin,
|
||||||
AuthHash: *user.AuthHash,
|
AuthHash: *user.AuthHash,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authKOMiddleware(c *gin.Context) {
|
func (api *API) authKOMiddleware(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session First
|
// Check Session First
|
||||||
if auth, ok := api.getSession(c, session); ok {
|
if auth, ok := api.authorizeSession(c, session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -64,21 +65,25 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
var rHeader authKOHeader
|
var rHeader authKOHeader
|
||||||
if err := c.ShouldBindHeader(&rHeader); err != nil {
|
if err := c.ShouldBindHeader(&rHeader); err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind auth headers")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
|
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
|
||||||
|
log.Error("invalid authentication headers")
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
|
authData, err := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
|
||||||
if authData == nil {
|
if err != nil {
|
||||||
|
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to authorize credentials")
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := api.setSession(session, *authData); err != nil {
|
if err := api.setSession(session, authData); err != nil {
|
||||||
|
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to set session")
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,14 +100,16 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate Auth Fields
|
// Validate Auth Fields
|
||||||
if !hasAuth || user == "" || rawPassword == "" {
|
if !hasAuth || user == "" || rawPassword == "" {
|
||||||
|
log.Error("invalid authorization headers")
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Auth
|
// Validate Auth
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
authData := api.authorizeCredentials(c, user, password)
|
authData, err := api.authorizeCredentials(c, user, password)
|
||||||
if authData == nil {
|
if err != nil {
|
||||||
|
log.WithField("user", user).WithError(err).Error("failed to authorize credentials")
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,7 +123,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session
|
// Check Session
|
||||||
if auth, ok := api.getSession(c, session); ok {
|
if auth, ok := api.authorizeSession(c, session); ok {
|
||||||
c.Set("Authorization", auth)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -129,7 +136,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
auth := data.(authData)
|
auth := data.(*authData)
|
||||||
if auth.IsAdmin {
|
if auth.IsAdmin {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -154,8 +161,9 @@ func (api *API) appAuthLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// MD5 - KOSync Compatiblity
|
// MD5 - KOSync Compatiblity
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
authData := api.authorizeCredentials(c, username, password)
|
authData, err := api.authorizeCredentials(c, username, password)
|
||||||
if authData == nil {
|
if err != nil {
|
||||||
|
log.WithField("user", username).WithError(err).Error("failed to authorize credentials")
|
||||||
templateVars["Error"] = "Invalid Credentials"
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
return
|
return
|
||||||
@@ -163,7 +171,7 @@ func (api *API) appAuthLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// Set Session
|
// Set Session
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
if err := api.setSession(session, *authData); err != nil {
|
if err := api.setSession(session, authData); err != nil {
|
||||||
templateVars["Error"] = "Invalid Credentials"
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
return
|
return
|
||||||
@@ -252,7 +260,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set session
|
// Set session
|
||||||
auth := authData{
|
auth := &authData{
|
||||||
UserName: user.ID,
|
UserName: user.ID,
|
||||||
IsAdmin: user.Admin,
|
IsAdmin: user.Admin,
|
||||||
AuthHash: *user.AuthHash,
|
AuthHash: *user.AuthHash,
|
||||||
@@ -348,35 +356,40 @@ func (api *API) koAuthRegister(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) {
|
func (api *API) authorizeSession(ctx context.Context, session sessions.Session) (*authData, bool) {
|
||||||
// Get Session
|
// Get Session
|
||||||
authorizedUser := session.Get("authorizedUser")
|
authorizedUser := session.Get("authorizedUser")
|
||||||
isAdmin := session.Get("isAdmin")
|
isAdmin := session.Get("isAdmin")
|
||||||
expiresAt := session.Get("expiresAt")
|
expiresAt := session.Get("expiresAt")
|
||||||
authHash := session.Get("authHash")
|
authHash := session.Get("authHash")
|
||||||
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Auth Object
|
// Create Auth Object
|
||||||
auth = authData{
|
auth := &authData{
|
||||||
UserName: authorizedUser.(string),
|
UserName: authorizedUser.(string),
|
||||||
IsAdmin: isAdmin.(bool),
|
IsAdmin: isAdmin.(bool),
|
||||||
AuthHash: authHash.(string),
|
AuthHash: authHash.(string),
|
||||||
}
|
}
|
||||||
|
logger := log.WithField("user", auth.UserName)
|
||||||
|
|
||||||
// Validate Auth Hash
|
// Validate Auth Hash
|
||||||
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
|
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
|
||||||
if err != nil || correctAuthHash != auth.AuthHash {
|
if err != nil {
|
||||||
return
|
logger.WithError(err).Error("failed to get auth hash")
|
||||||
|
return nil, false
|
||||||
|
} else if correctAuthHash != auth.AuthHash {
|
||||||
|
logger.Warn("user auth hash mismatch")
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
||||||
log.Info("Refreshing Session")
|
logger.Info("refreshing session")
|
||||||
if err := api.setSession(session, auth); err != nil {
|
if err := api.setSession(session, auth); err != nil {
|
||||||
log.Error("unable to get session")
|
logger.WithError(err).Error("failed to refresh session")
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +397,7 @@ func (api *API) getSession(ctx context.Context, session sessions.Session) (auth
|
|||||||
return auth, true
|
return auth, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) setSession(session sessions.Session, auth authData) error {
|
func (api *API) setSession(session sessions.Session, auth *authData) error {
|
||||||
// Set Session Cookie
|
// Set Session Cookie
|
||||||
session.Set("authorizedUser", auth.UserName)
|
session.Set("authorizedUser", auth.UserName)
|
||||||
session.Set("isAdmin", auth.IsAdmin)
|
session.Set("isAdmin", auth.IsAdmin)
|
||||||
@@ -464,9 +477,7 @@ func (api *API) rotateAllAuthHashes(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transaction Succeeded -> Update Cache
|
// Transaction Succeeded -> Update Cache
|
||||||
for user, hash := range newAuthHashCache {
|
maps.Copy(api.userAuthCache, newAuthHashCache)
|
||||||
api.userAuthCache[user] = hash
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,20 +98,20 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attempt Metadata
|
// Attempt Metadata
|
||||||
var coverDir string = filepath.Join(api.cfg.DataPath, "covers")
|
coverDir := filepath.Join(api.cfg.DataPath, "covers")
|
||||||
var coverFile string = "UNKNOWN"
|
coverFile := "UNKNOWN"
|
||||||
|
|
||||||
// Identify Documents & Save Covers
|
// Identify Documents & Save Covers
|
||||||
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
|
||||||
Title: document.Title,
|
Title: document.Title,
|
||||||
Author: document.Author,
|
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]
|
firstResult := metadataResults[0]
|
||||||
|
|
||||||
// Save Cover
|
// 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 {
|
if err == nil {
|
||||||
coverFile = *fileName
|
coverFile = *fileName
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
|
|||||||
Title: firstResult.Title,
|
Title: firstResult.Title,
|
||||||
Author: firstResult.Author,
|
Author: firstResult.Author,
|
||||||
Description: firstResult.Description,
|
Description: firstResult.Description,
|
||||||
Gbid: firstResult.ID,
|
Gbid: firstResult.SourceID,
|
||||||
Olid: nil,
|
Olid: nil,
|
||||||
Isbn10: firstResult.ISBN10,
|
Isbn10: firstResult.ISBN10,
|
||||||
Isbn13: firstResult.ISBN13,
|
Isbn13: firstResult.ISBN13,
|
||||||
|
|||||||
83
api/convert.go
Normal file
83
api/convert.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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) *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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 convertDBDeviceToUI(r database.GetDevicesRow) models.Device {
|
||||||
|
return models.Device{
|
||||||
|
DeviceName: r.DeviceName,
|
||||||
|
LastSynced: r.LastSynced,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertSearchToUI(r search.SearchItem) models.SearchResult {
|
||||||
|
return models.SearchResult{
|
||||||
|
ID: r.ID,
|
||||||
|
Title: r.Title,
|
||||||
|
Author: r.Author,
|
||||||
|
Series: r.Series,
|
||||||
|
FileType: r.FileType,
|
||||||
|
FileSize: r.FileSize,
|
||||||
|
UploadDate: r.UploadDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,13 +62,19 @@ func (api *API) opdsEntry(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) opdsDocuments(c *gin.Context) {
|
func (api *API) opdsDocuments(c *gin.Context) {
|
||||||
var auth authData
|
auth, err := getAuthData(c)
|
||||||
if data, _ := c.Get("Authorization"); data != nil {
|
if err != nil {
|
||||||
auth = data.(authData)
|
log.WithError(err).Error("failed to acquire auth data")
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Potential URL Parameters (Default Pagination - 100)
|
// Potential URL Parameters (Default Pagination - 100)
|
||||||
qParams := bindQueryParams(c, 100)
|
qParams, err := bindQueryParams(c, 100)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to bind query params")
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Possible Query
|
// Possible Query
|
||||||
var query *string
|
var query *string
|
||||||
@@ -86,7 +92,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetDocumentsWithStats DB Error:", err)
|
log.WithError(err).Error("failed to get documents with stats")
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
11
api/utils.go
11
api/utils.go
@@ -8,11 +8,22 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/graph"
|
"reichard.io/antholume/graph"
|
||||||
"reichard.io/antholume/metadata"
|
"reichard.io/antholume/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func getAuthData(ctx *gin.Context) (*authData, error) {
|
||||||
|
if data, ok := ctx.Get("Authorization"); ok {
|
||||||
|
var auth *authData
|
||||||
|
if auth, ok = data.(*authData); ok {
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("could not acquire auth data")
|
||||||
|
}
|
||||||
|
|
||||||
// getTimeZones returns a string slice of IANA timezones.
|
// getTimeZones returns a string slice of IANA timezones.
|
||||||
func getTimeZones() []string {
|
func getTimeZones() []string {
|
||||||
return []string{
|
return []string{
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET /activity
|
|
||||||
func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObject) (GetActivityResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
docFilter := false
|
|
||||||
if request.Params.DocFilter != nil {
|
|
||||||
docFilter = *request.Params.DocFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
documentID := ""
|
|
||||||
if request.Params.DocumentId != nil {
|
|
||||||
documentID = *request.Params.DocumentId
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := int64(0)
|
|
||||||
if request.Params.Offset != nil {
|
|
||||||
offset = *request.Params.Offset
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := int64(100)
|
|
||||||
if request.Params.Limit != nil {
|
|
||||||
limit = *request.Params.Limit
|
|
||||||
}
|
|
||||||
|
|
||||||
activities, err := s.db.Queries.GetActivity(ctx, database.GetActivityParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
DocFilter: docFilter,
|
|
||||||
DocumentID: documentID,
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
apiActivities := make([]Activity, len(activities))
|
|
||||||
for i, a := range activities {
|
|
||||||
// Convert StartTime from interface{} to string
|
|
||||||
startTimeStr := ""
|
|
||||||
if a.StartTime != nil {
|
|
||||||
if str, ok := a.StartTime.(string); ok {
|
|
||||||
startTimeStr = str
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiActivities[i] = Activity{
|
|
||||||
DocumentId: a.DocumentID,
|
|
||||||
DeviceId: a.DeviceID,
|
|
||||||
StartTime: startTimeStr,
|
|
||||||
Title: a.Title,
|
|
||||||
Author: a.Author,
|
|
||||||
Duration: a.Duration,
|
|
||||||
StartPercentage: float32(a.StartPercentage),
|
|
||||||
EndPercentage: float32(a.EndPercentage),
|
|
||||||
ReadPercentage: float32(a.ReadPercentage),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ActivityResponse{
|
|
||||||
Activities: apiActivities,
|
|
||||||
}
|
|
||||||
return GetActivity200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
1032
api/v1/admin.go
1032
api/v1/admin.go
File diff suppressed because it is too large
Load Diff
3868
api/v1/api.gen.go
3868
api/v1/api.gen.go
File diff suppressed because it is too large
Load Diff
268
api/v1/auth.go
268
api/v1/auth.go
@@ -1,268 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/md5"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
argon2 "github.com/alexedwards/argon2id"
|
|
||||||
"github.com/gorilla/sessions"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// POST /auth/login
|
|
||||||
func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) {
|
|
||||||
if request.Body == nil {
|
|
||||||
return Login400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req := *request.Body
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
return Login400JSONResponse{Code: 400, Message: "Invalid credentials"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MD5 - KOSync compatibility
|
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
|
|
||||||
|
|
||||||
// Verify credentials
|
|
||||||
user, err := s.db.Queries.GetUser(ctx, req.Username)
|
|
||||||
if err != nil {
|
|
||||||
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
|
|
||||||
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
|
|
||||||
return Login500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return Login200JSONResponse{
|
|
||||||
Username: user.ID,
|
|
||||||
IsAdmin: user.Admin,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /auth/register
|
|
||||||
func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error) {
|
|
||||||
if !s.cfg.RegistrationEnabled {
|
|
||||||
return Register403JSONResponse{Code: 403, Message: "Registration is disabled"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Body == nil {
|
|
||||||
return Register400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req := *request.Body
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
return Register400JSONResponse{Code: 400, Message: "Invalid user or password"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUsers, err := s.db.Queries.GetUsers(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return Register500JSONResponse{Code: 500, Message: "Failed to create user"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
isAdmin := len(currentUsers) == 0
|
|
||||||
if err := s.createUser(ctx, req.Username, &req.Password, &isAdmin); err != nil {
|
|
||||||
return Register400JSONResponse{Code: 400, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.db.Queries.GetUser(ctx, req.Username)
|
|
||||||
if err != nil {
|
|
||||||
return Register500JSONResponse{Code: 500, Message: "Failed to load created user"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
|
|
||||||
return Register500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return Register201JSONResponse{
|
|
||||||
Username: user.ID,
|
|
||||||
IsAdmin: user.Admin,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /auth/logout
|
|
||||||
func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) {
|
|
||||||
_, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
r := s.getRequestFromContext(ctx)
|
|
||||||
w := s.getResponseWriterFromContext(ctx)
|
|
||||||
|
|
||||||
if r == nil || w == nil {
|
|
||||||
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.getCookieSession(r)
|
|
||||||
if err != nil {
|
|
||||||
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Values = make(map[any]any)
|
|
||||||
|
|
||||||
if err := session.Save(r, w); err != nil {
|
|
||||||
return Logout401JSONResponse{Code: 401, Message: "Failed to logout"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return Logout200Response{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /auth/me
|
|
||||||
func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetMe401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetMe200JSONResponse{
|
|
||||||
Username: auth.UserName,
|
|
||||||
IsAdmin: auth.IsAdmin,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) saveUserSession(ctx context.Context, username string, isAdmin bool, authHash string) error {
|
|
||||||
r := s.getRequestFromContext(ctx)
|
|
||||||
w := s.getResponseWriterFromContext(ctx)
|
|
||||||
if r == nil || w == nil {
|
|
||||||
return fmt.Errorf("internal context error")
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.getCookieSession(r)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Values["authorizedUser"] = username
|
|
||||||
session.Values["isAdmin"] = isAdmin
|
|
||||||
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
|
|
||||||
session.Values["authHash"] = authHash
|
|
||||||
|
|
||||||
if err := session.Save(r, w); err != nil {
|
|
||||||
return fmt.Errorf("failed to create session")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getCookieSession(r *http.Request) (*sessions.Session, error) {
|
|
||||||
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
|
|
||||||
if s.cfg.CookieEncKey != "" {
|
|
||||||
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
|
|
||||||
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := store.Get(r, "token")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Options.SameSite = http.SameSiteLaxMode
|
|
||||||
session.Options.HttpOnly = true
|
|
||||||
session.Options.Secure = s.cfg.CookieSecure
|
|
||||||
|
|
||||||
return session, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSessionFromContext extracts authData from context
|
|
||||||
func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
|
|
||||||
auth, ok := ctx.Value("auth").(authData)
|
|
||||||
if !ok {
|
|
||||||
return authData{}, false
|
|
||||||
}
|
|
||||||
return auth, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAdmin checks if a user has admin privileges
|
|
||||||
func (s *Server) isAdmin(ctx context.Context) bool {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return auth.IsAdmin
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRequestFromContext extracts the HTTP request from context
|
|
||||||
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
|
|
||||||
r, ok := ctx.Value("request").(*http.Request)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// getResponseWriterFromContext extracts the response writer from context
|
|
||||||
func (s *Server) getResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
|
|
||||||
w, ok := ctx.Value("response").(http.ResponseWriter)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSession retrieves auth data from the session cookie
|
|
||||||
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
|
|
||||||
// Get session from cookie store
|
|
||||||
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
|
|
||||||
if s.cfg.CookieEncKey != "" {
|
|
||||||
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
|
|
||||||
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
|
|
||||||
} else {
|
|
||||||
log.Error("invalid cookie encryption key (must be 16 or 32 bytes)")
|
|
||||||
return authData{}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := store.Get(r, "token")
|
|
||||||
if err != nil {
|
|
||||||
return authData{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get session values
|
|
||||||
authorizedUser := session.Values["authorizedUser"]
|
|
||||||
isAdmin := session.Values["isAdmin"]
|
|
||||||
expiresAt := session.Values["expiresAt"]
|
|
||||||
authHash := session.Values["authHash"]
|
|
||||||
|
|
||||||
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
|
||||||
return authData{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
auth = authData{
|
|
||||||
UserName: authorizedUser.(string),
|
|
||||||
IsAdmin: isAdmin.(bool),
|
|
||||||
AuthHash: authHash.(string),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate auth hash
|
|
||||||
ctx := r.Context()
|
|
||||||
correctAuthHash, err := s.getUserAuthHash(ctx, auth.UserName)
|
|
||||||
if err != nil || correctAuthHash != auth.AuthHash {
|
|
||||||
return authData{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// getUserAuthHash retrieves the user's auth hash from DB or cache
|
|
||||||
func (s *Server) getUserAuthHash(ctx context.Context, username string) (string, error) {
|
|
||||||
user, err := s.db.Queries.GetUser(ctx, username)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return *user.AuthHash, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// authData represents authenticated user information
|
|
||||||
type authData struct {
|
|
||||||
UserName string
|
|
||||||
IsAdmin bool
|
|
||||||
AuthHash string
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/md5"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
|
|
||||||
argon2 "github.com/alexedwards/argon2id"
|
|
||||||
"reichard.io/antholume/config"
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuthTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
db *database.DBManager
|
|
||||||
cfg *config.Config
|
|
||||||
srv *Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) setupConfig() *config.Config {
|
|
||||||
return &config.Config{
|
|
||||||
ListenPort: "8080",
|
|
||||||
DBType: "memory",
|
|
||||||
DBName: "test",
|
|
||||||
ConfigPath: "/tmp",
|
|
||||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
|
||||||
CookieEncKey: "0123456789abcdef",
|
|
||||||
CookieSecure: false,
|
|
||||||
CookieHTTPOnly: true,
|
|
||||||
Version: "test",
|
|
||||||
DemoMode: false,
|
|
||||||
RegistrationEnabled: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuth(t *testing.T) {
|
|
||||||
suite.Run(t, new(AuthTestSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) SetupTest() {
|
|
||||||
suite.cfg = suite.setupConfig()
|
|
||||||
suite.db = database.NewMgr(suite.cfg)
|
|
||||||
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) createTestUser(username, password string) {
|
|
||||||
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
|
|
||||||
|
|
||||||
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
authHash := "test-auth-hash"
|
|
||||||
|
|
||||||
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
|
|
||||||
ID: username,
|
|
||||||
Pass: &hashedPassword,
|
|
||||||
AuthHash: &authHash,
|
|
||||||
Admin: true,
|
|
||||||
})
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
|
|
||||||
reqBody := LoginRequest{
|
|
||||||
Username: username,
|
|
||||||
Password: password,
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(reqBody)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code, "login should return 200")
|
|
||||||
|
|
||||||
var resp LoginResponse
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
|
||||||
suite.Require().Len(cookies, 1, "should have session cookie")
|
|
||||||
|
|
||||||
return cookies[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPILogin() {
|
|
||||||
suite.createTestUser("testuser", "testpass")
|
|
||||||
|
|
||||||
reqBody := LoginRequest{
|
|
||||||
Username: "testuser",
|
|
||||||
Password: "testpass",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp LoginResponse
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
suite.Equal("testuser", resp.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
|
|
||||||
reqBody := LoginRequest{
|
|
||||||
Username: "testuser",
|
|
||||||
Password: "wrongpass",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPIRegister() {
|
|
||||||
reqBody := LoginRequest{
|
|
||||||
Username: "newuser",
|
|
||||||
Password: "newpass",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusCreated, w.Code)
|
|
||||||
|
|
||||||
var resp LoginResponse
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
suite.Equal("newuser", resp.Username)
|
|
||||||
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
|
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
|
||||||
suite.Require().NotEmpty(cookies, "register should set a session cookie")
|
|
||||||
|
|
||||||
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.True(user.Admin)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPIRegisterDisabled() {
|
|
||||||
suite.cfg.RegistrationEnabled = false
|
|
||||||
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
|
||||||
|
|
||||||
reqBody := LoginRequest{
|
|
||||||
Username: "newuser",
|
|
||||||
Password: "newpass",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(reqBody)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusForbidden, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPILogout() {
|
|
||||||
suite.createTestUser("testuser", "testpass")
|
|
||||||
cookie := suite.login("testuser", "testpass")
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPIGetMe() {
|
|
||||||
suite.createTestUser("testuser", "testpass")
|
|
||||||
cookie := suite.login("testuser", "testpass")
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp UserData
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
suite.Equal("testuser", resp.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPIGetMeUnauthenticated() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
|
||||||
}
|
|
||||||
@@ -1,882 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
"reichard.io/antholume/metadata"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET /documents
|
|
||||||
func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetDocuments401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
page := int64(1)
|
|
||||||
if request.Params.Page != nil {
|
|
||||||
page = *request.Params.Page
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := int64(9)
|
|
||||||
if request.Params.Limit != nil {
|
|
||||||
limit = *request.Params.Limit
|
|
||||||
}
|
|
||||||
|
|
||||||
search := ""
|
|
||||||
if request.Params.Search != nil {
|
|
||||||
search = "%" + *request.Params.Search + "%"
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.db.Queries.GetDocumentsWithStats(
|
|
||||||
ctx,
|
|
||||||
database.GetDocumentsWithStatsParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Query: &search,
|
|
||||||
Deleted: ptrOf(false),
|
|
||||||
Offset: (page - 1) * limit,
|
|
||||||
Limit: limit,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
total := int64(len(rows))
|
|
||||||
var nextPage *int64
|
|
||||||
var previousPage *int64
|
|
||||||
if page*limit < total {
|
|
||||||
nextPage = ptrOf(page + 1)
|
|
||||||
}
|
|
||||||
if page > 1 {
|
|
||||||
previousPage = ptrOf(page - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDocuments := make([]Document, len(rows))
|
|
||||||
wordCounts := make([]WordCount, 0, len(rows))
|
|
||||||
for i, row := range rows {
|
|
||||||
apiDocuments[i] = Document{
|
|
||||||
Id: row.ID,
|
|
||||||
Title: *row.Title,
|
|
||||||
Author: *row.Author,
|
|
||||||
Description: row.Description,
|
|
||||||
Isbn10: row.Isbn10,
|
|
||||||
Isbn13: row.Isbn13,
|
|
||||||
Words: row.Words,
|
|
||||||
Filepath: row.Filepath,
|
|
||||||
Percentage: ptrOf(float32(row.Percentage)),
|
|
||||||
TotalTimeSeconds: ptrOf(row.TotalTimeSeconds),
|
|
||||||
Wpm: ptrOf(float32(row.Wpm)),
|
|
||||||
SecondsPerPercent: ptrOf(row.SecondsPerPercent),
|
|
||||||
LastRead: parseInterfaceTime(row.LastRead),
|
|
||||||
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
|
|
||||||
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
|
|
||||||
Deleted: false, // Default, should be overridden if available
|
|
||||||
}
|
|
||||||
if row.Words != nil {
|
|
||||||
wordCounts = append(wordCounts, WordCount{
|
|
||||||
DocumentId: row.ID,
|
|
||||||
Count: *row.Words,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := DocumentsResponse{
|
|
||||||
Documents: apiDocuments,
|
|
||||||
Total: total,
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
NextPage: nextPage,
|
|
||||||
PreviousPage: previousPage,
|
|
||||||
Search: request.Params.Search,
|
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
WordCounts: wordCounts,
|
|
||||||
}
|
|
||||||
return GetDocuments200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /documents/{id}
|
|
||||||
func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use GetDocumentsWithStats to get document with stats
|
|
||||||
docs, err := s.db.Queries.GetDocumentsWithStats(
|
|
||||||
ctx,
|
|
||||||
database.GetDocumentsWithStatsParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
ID: &request.Id,
|
|
||||||
Deleted: ptrOf(false),
|
|
||||||
Offset: 0,
|
|
||||||
Limit: 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil || len(docs) == 0 {
|
|
||||||
return GetDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
doc := docs[0]
|
|
||||||
|
|
||||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
DocumentID: request.Id,
|
|
||||||
})
|
|
||||||
var progress *Progress
|
|
||||||
if err == nil {
|
|
||||||
progress = &Progress{
|
|
||||||
UserId: &progressRow.UserID,
|
|
||||||
DocumentId: &progressRow.DocumentID,
|
|
||||||
DeviceName: &progressRow.DeviceName,
|
|
||||||
Percentage: &progressRow.Percentage,
|
|
||||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDoc := Document{
|
|
||||||
Id: doc.ID,
|
|
||||||
Title: *doc.Title,
|
|
||||||
Author: *doc.Author,
|
|
||||||
Description: doc.Description,
|
|
||||||
Isbn10: doc.Isbn10,
|
|
||||||
Isbn13: doc.Isbn13,
|
|
||||||
Words: doc.Words,
|
|
||||||
Filepath: doc.Filepath,
|
|
||||||
Percentage: ptrOf(float32(doc.Percentage)),
|
|
||||||
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
|
||||||
Wpm: ptrOf(float32(doc.Wpm)),
|
|
||||||
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
|
||||||
LastRead: parseInterfaceTime(doc.LastRead),
|
|
||||||
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
|
|
||||||
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
|
|
||||||
Deleted: false, // Default, should be overridden if available
|
|
||||||
}
|
|
||||||
|
|
||||||
response := DocumentResponse{
|
|
||||||
Document: apiDoc,
|
|
||||||
Progress: progress,
|
|
||||||
}
|
|
||||||
return GetDocument200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /documents/{id}
|
|
||||||
func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return EditDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Body == nil {
|
|
||||||
return EditDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate document exists and get current state
|
|
||||||
currentDoc, err := s.db.Queries.GetDocument(ctx, request.Id)
|
|
||||||
if err != nil {
|
|
||||||
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate at least one editable field is provided
|
|
||||||
if request.Body.Title == nil &&
|
|
||||||
request.Body.Author == nil &&
|
|
||||||
request.Body.Description == nil &&
|
|
||||||
request.Body.Isbn10 == nil &&
|
|
||||||
request.Body.Isbn13 == nil &&
|
|
||||||
request.Body.CoverGbid == nil {
|
|
||||||
return EditDocument400JSONResponse{Code: 400, Message: "No editable fields provided"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle cover via Google Books ID
|
|
||||||
var coverFileName *string
|
|
||||||
if request.Body.CoverGbid != nil {
|
|
||||||
coverDir := filepath.Join(s.cfg.DataPath, "covers")
|
|
||||||
fileName, err := metadata.CacheCoverWithContext(ctx, *request.Body.CoverGbid, coverDir, request.Id, true)
|
|
||||||
if err == nil {
|
|
||||||
coverFileName = fileName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update document with provided editable fields only
|
|
||||||
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
|
||||||
ID: request.Id,
|
|
||||||
Title: request.Body.Title,
|
|
||||||
Author: request.Body.Author,
|
|
||||||
Description: request.Body.Description,
|
|
||||||
Isbn10: request.Body.Isbn10,
|
|
||||||
Isbn13: request.Body.Isbn13,
|
|
||||||
Coverfile: coverFileName,
|
|
||||||
// Preserve existing values for non-editable fields
|
|
||||||
Md5: currentDoc.Md5,
|
|
||||||
Basepath: currentDoc.Basepath,
|
|
||||||
Filepath: currentDoc.Filepath,
|
|
||||||
Words: currentDoc.Words,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("UpsertDocument DB Error:", err)
|
|
||||||
return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use GetDocumentsWithStats to get document with stats for the response
|
|
||||||
docs, err := s.db.Queries.GetDocumentsWithStats(
|
|
||||||
ctx,
|
|
||||||
database.GetDocumentsWithStatsParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
ID: &request.Id,
|
|
||||||
Deleted: ptrOf(false),
|
|
||||||
Offset: 0,
|
|
||||||
Limit: 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil || len(docs) == 0 {
|
|
||||||
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
doc := docs[0]
|
|
||||||
|
|
||||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
DocumentID: request.Id,
|
|
||||||
})
|
|
||||||
var progress *Progress
|
|
||||||
if err == nil {
|
|
||||||
progress = &Progress{
|
|
||||||
UserId: &progressRow.UserID,
|
|
||||||
DocumentId: &progressRow.DocumentID,
|
|
||||||
DeviceName: &progressRow.DeviceName,
|
|
||||||
Percentage: &progressRow.Percentage,
|
|
||||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDoc := Document{
|
|
||||||
Id: doc.ID,
|
|
||||||
Title: *doc.Title,
|
|
||||||
Author: *doc.Author,
|
|
||||||
Description: doc.Description,
|
|
||||||
Isbn10: doc.Isbn10,
|
|
||||||
Isbn13: doc.Isbn13,
|
|
||||||
Words: doc.Words,
|
|
||||||
Filepath: doc.Filepath,
|
|
||||||
Percentage: ptrOf(float32(doc.Percentage)),
|
|
||||||
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
|
||||||
Wpm: ptrOf(float32(doc.Wpm)),
|
|
||||||
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
|
||||||
LastRead: parseInterfaceTime(doc.LastRead),
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
Deleted: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
response := DocumentResponse{
|
|
||||||
Document: apiDoc,
|
|
||||||
Progress: progress,
|
|
||||||
}
|
|
||||||
return EditDocument200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
|
|
||||||
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
|
||||||
// Derive New FileName
|
|
||||||
var newFileName string
|
|
||||||
if metadataInfo.Author != nil && *metadataInfo.Author != "" {
|
|
||||||
newFileName = newFileName + *metadataInfo.Author
|
|
||||||
} else {
|
|
||||||
newFileName = newFileName + "Unknown"
|
|
||||||
}
|
|
||||||
if metadataInfo.Title != nil && *metadataInfo.Title != "" {
|
|
||||||
newFileName = newFileName + " - " + *metadataInfo.Title
|
|
||||||
} else {
|
|
||||||
newFileName = newFileName + " - Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove Slashes
|
|
||||||
fileName := strings.ReplaceAll(newFileName, "/", "")
|
|
||||||
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseInterfaceTime converts an interface{} to time.Time for SQLC queries
|
|
||||||
func parseInterfaceTime(t any) *time.Time {
|
|
||||||
if t == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch v := t.(type) {
|
|
||||||
case string:
|
|
||||||
parsed, err := time.Parse(time.RFC3339, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &parsed
|
|
||||||
case time.Time:
|
|
||||||
return &v
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveNoCover serves the default no-cover image from assets
|
|
||||||
func (s *Server) serveNoCover() (fs.File, string, int64, error) {
|
|
||||||
// Try to open the no-cover image from assets
|
|
||||||
file, err := s.assets.Open("assets/images/no-cover.jpg")
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file info
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return file, "image/jpeg", info.Size(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openFileReader opens a file and returns it as an io.ReaderCloser
|
|
||||||
func openFileReader(path string) (*os.File, error) {
|
|
||||||
return os.Open(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /documents/{id}/cover
|
|
||||||
func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error) {
|
|
||||||
// Authentication is handled by middleware, which also adds auth data to context
|
|
||||||
// This endpoint just serves the cover image
|
|
||||||
|
|
||||||
// Validate Document Exists in DB
|
|
||||||
document, err := s.db.Queries.GetDocument(ctx, request.Id)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetDocument DB Error:", err)
|
|
||||||
return GetDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverFile fs.File
|
|
||||||
var contentType string
|
|
||||||
var contentLength int64
|
|
||||||
var needMetadataFetch bool
|
|
||||||
|
|
||||||
// Handle Identified Document
|
|
||||||
if document.Coverfile != nil {
|
|
||||||
if *document.Coverfile == "UNKNOWN" {
|
|
||||||
// Serve no-cover image
|
|
||||||
file, ct, size, err := s.serveNoCover()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to open no-cover image:", err)
|
|
||||||
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
|
|
||||||
}
|
|
||||||
coverFile = file
|
|
||||||
contentType = ct
|
|
||||||
contentLength = size
|
|
||||||
needMetadataFetch = true
|
|
||||||
} else {
|
|
||||||
// Derive Path
|
|
||||||
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
|
|
||||||
|
|
||||||
// Validate File Exists
|
|
||||||
fileInfo, err := os.Stat(coverPath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
log.Error("Cover file should but doesn't exist: ", err)
|
|
||||||
// Serve no-cover image
|
|
||||||
file, ct, size, err := s.serveNoCover()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to open no-cover image:", err)
|
|
||||||
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
|
|
||||||
}
|
|
||||||
coverFile = file
|
|
||||||
contentType = ct
|
|
||||||
contentLength = size
|
|
||||||
needMetadataFetch = true
|
|
||||||
} else {
|
|
||||||
// Open the cover file
|
|
||||||
file, err := openFileReader(coverPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to open cover file:", err)
|
|
||||||
return GetDocumentCover500JSONResponse{Code: 500, Message: "Failed to open cover"}, nil
|
|
||||||
}
|
|
||||||
coverFile = file
|
|
||||||
contentLength = fileInfo.Size()
|
|
||||||
|
|
||||||
// Determine content type based on file extension
|
|
||||||
contentType = "image/jpeg"
|
|
||||||
if strings.HasSuffix(coverPath, ".png") {
|
|
||||||
contentType = "image/png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
needMetadataFetch = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt Metadata fetch if needed
|
|
||||||
var cachedCoverFile string = "UNKNOWN"
|
|
||||||
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
|
|
||||||
|
|
||||||
if needMetadataFetch {
|
|
||||||
// Create context with timeout for metadata service calls
|
|
||||||
metadataCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Identify Documents & Save Covers
|
|
||||||
metadataResults, err := metadata.SearchMetadataWithContext(metadataCtx, metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
|
||||||
Title: document.Title,
|
|
||||||
Author: document.Author,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
|
|
||||||
firstResult := metadataResults[0]
|
|
||||||
|
|
||||||
// Save Cover
|
|
||||||
fileName, err := metadata.CacheCoverWithContext(metadataCtx, *firstResult.ID, coverDir, document.ID, false)
|
|
||||||
if err == nil {
|
|
||||||
cachedCoverFile = *fileName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store First Metadata Result
|
|
||||||
if _, err = s.db.Queries.AddMetadata(ctx, database.AddMetadataParams{
|
|
||||||
DocumentID: document.ID,
|
|
||||||
Title: firstResult.Title,
|
|
||||||
Author: firstResult.Author,
|
|
||||||
Description: firstResult.Description,
|
|
||||||
Gbid: firstResult.ID,
|
|
||||||
Olid: nil,
|
|
||||||
Isbn10: firstResult.ISBN10,
|
|
||||||
Isbn13: firstResult.ISBN13,
|
|
||||||
}); err != nil {
|
|
||||||
log.Error("AddMetadata DB Error:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert Document
|
|
||||||
if _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
|
||||||
ID: document.ID,
|
|
||||||
Coverfile: &cachedCoverFile,
|
|
||||||
}); err != nil {
|
|
||||||
log.Warn("UpsertDocument DB Error:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cover file if we got a new cover
|
|
||||||
if cachedCoverFile != "UNKNOWN" {
|
|
||||||
coverPath := filepath.Join(coverDir, cachedCoverFile)
|
|
||||||
fileInfo, err := os.Stat(coverPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to stat cached cover:", err)
|
|
||||||
// Keep the no-cover image
|
|
||||||
} else {
|
|
||||||
file, err := openFileReader(coverPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to open cached cover:", err)
|
|
||||||
// Keep the no-cover image
|
|
||||||
} else {
|
|
||||||
_ = coverFile.Close() // Close the previous file
|
|
||||||
coverFile = file
|
|
||||||
contentLength = fileInfo.Size()
|
|
||||||
|
|
||||||
// Determine content type based on file extension
|
|
||||||
contentType = "image/jpeg"
|
|
||||||
if strings.HasSuffix(coverPath, ".png") {
|
|
||||||
contentType = "image/png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &GetDocumentCover200Response{
|
|
||||||
Body: coverFile,
|
|
||||||
ContentLength: contentLength,
|
|
||||||
ContentType: contentType,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /documents/{id}/cover
|
|
||||||
func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return UploadDocumentCover401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Body == nil {
|
|
||||||
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate document exists
|
|
||||||
_, err := s.db.Queries.GetDocument(ctx, request.Id)
|
|
||||||
if err != nil {
|
|
||||||
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read multipart form
|
|
||||||
form, err := request.Body.ReadForm(32 << 20) // 32MB max
|
|
||||||
if err != nil {
|
|
||||||
log.Error("ReadForm error:", err)
|
|
||||||
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file from form
|
|
||||||
fileField := form.File["cover_file"]
|
|
||||||
if len(fileField) == 0 {
|
|
||||||
return UploadDocumentCover400JSONResponse{Code: 400, Message: "No file provided"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
file := fileField[0]
|
|
||||||
|
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(file.Filename), ".jpg") && !strings.HasSuffix(strings.ToLower(file.Filename), ".png") {
|
|
||||||
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Only JPG and PNG files are allowed"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open file
|
|
||||||
f, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Open file error:", err)
|
|
||||||
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
// Read file content
|
|
||||||
data, err := io.ReadAll(f)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Read file error:", err)
|
|
||||||
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate actual content type
|
|
||||||
contentType := http.DetectContentType(data)
|
|
||||||
allowedTypes := map[string]bool{
|
|
||||||
"image/jpeg": true,
|
|
||||||
"image/png": true,
|
|
||||||
}
|
|
||||||
if !allowedTypes[contentType] {
|
|
||||||
return UploadDocumentCover400JSONResponse{
|
|
||||||
Code: 400,
|
|
||||||
Message: fmt.Sprintf("Invalid file type: %s. Only JPG and PNG files are allowed.", contentType),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive storage path
|
|
||||||
coverDir := filepath.Join(s.cfg.DataPath, "covers")
|
|
||||||
fileName := fmt.Sprintf("%s%s", request.Id, strings.ToLower(filepath.Ext(file.Filename)))
|
|
||||||
safePath := filepath.Join(coverDir, fileName)
|
|
||||||
|
|
||||||
// Save file
|
|
||||||
err = os.WriteFile(safePath, data, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Save file error:", err)
|
|
||||||
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Unable to save cover"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert document with new cover
|
|
||||||
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
|
||||||
ID: request.Id,
|
|
||||||
Coverfile: &fileName,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("UpsertDocument DB error:", err)
|
|
||||||
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use GetDocumentsWithStats to get document with stats for the response
|
|
||||||
docs, err := s.db.Queries.GetDocumentsWithStats(
|
|
||||||
ctx,
|
|
||||||
database.GetDocumentsWithStatsParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
ID: &request.Id,
|
|
||||||
Deleted: ptrOf(false),
|
|
||||||
Offset: 0,
|
|
||||||
Limit: 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil || len(docs) == 0 {
|
|
||||||
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
doc := docs[0]
|
|
||||||
|
|
||||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
DocumentID: request.Id,
|
|
||||||
})
|
|
||||||
var progress *Progress
|
|
||||||
if err == nil {
|
|
||||||
progress = &Progress{
|
|
||||||
UserId: &progressRow.UserID,
|
|
||||||
DocumentId: &progressRow.DocumentID,
|
|
||||||
DeviceName: &progressRow.DeviceName,
|
|
||||||
Percentage: &progressRow.Percentage,
|
|
||||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDoc := Document{
|
|
||||||
Id: doc.ID,
|
|
||||||
Title: *doc.Title,
|
|
||||||
Author: *doc.Author,
|
|
||||||
Description: doc.Description,
|
|
||||||
Isbn10: doc.Isbn10,
|
|
||||||
Isbn13: doc.Isbn13,
|
|
||||||
Words: doc.Words,
|
|
||||||
Filepath: doc.Filepath,
|
|
||||||
Percentage: ptrOf(float32(doc.Percentage)),
|
|
||||||
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
|
|
||||||
Wpm: ptrOf(float32(doc.Wpm)),
|
|
||||||
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
|
|
||||||
LastRead: parseInterfaceTime(doc.LastRead),
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
Deleted: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
response := DocumentResponse{
|
|
||||||
Document: apiDoc,
|
|
||||||
Progress: progress,
|
|
||||||
}
|
|
||||||
return UploadDocumentCover200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /documents/{id}/file
|
|
||||||
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
|
|
||||||
// Authentication is handled by middleware, which also adds auth data to context
|
|
||||||
// This endpoint just serves the document file download
|
|
||||||
// Get Document
|
|
||||||
document, err := s.db.Queries.GetDocument(ctx, request.Id)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetDocument DB Error:", err)
|
|
||||||
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if document.Filepath == nil {
|
|
||||||
log.Error("Document Doesn't Have File:", request.Id)
|
|
||||||
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive Basepath
|
|
||||||
basepath := filepath.Join(s.cfg.DataPath, "documents")
|
|
||||||
if document.Basepath != nil && *document.Basepath != "" {
|
|
||||||
basepath = *document.Basepath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive Storage Location
|
|
||||||
filePath := filepath.Join(basepath, *document.Filepath)
|
|
||||||
|
|
||||||
// Validate File Exists
|
|
||||||
fileInfo, err := os.Stat(filePath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
log.Error("File should but doesn't exist:", err)
|
|
||||||
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open file
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to open document file:", err)
|
|
||||||
return GetDocumentFile500JSONResponse{Code: 500, Message: "Failed to open document"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &GetDocumentFile200Response{
|
|
||||||
Body: file,
|
|
||||||
ContentLength: fileInfo.Size(),
|
|
||||||
Filename: filepath.Base(*document.Filepath),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /documents
|
|
||||||
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
|
|
||||||
_, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Body == nil {
|
|
||||||
return CreateDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read multipart form
|
|
||||||
form, err := request.Body.ReadForm(32 << 20) // 32MB max memory
|
|
||||||
if err != nil {
|
|
||||||
log.Error("ReadForm error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file from form
|
|
||||||
fileField := form.File["document_file"]
|
|
||||||
if len(fileField) == 0 {
|
|
||||||
return CreateDocument400JSONResponse{Code: 400, Message: "No file provided"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
file := fileField[0]
|
|
||||||
|
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") {
|
|
||||||
return CreateDocument400JSONResponse{Code: 400, Message: "Only EPUB files are allowed"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open file
|
|
||||||
f, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Open file error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
// Read file content
|
|
||||||
data, err := io.ReadAll(f)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Read file error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate actual content type
|
|
||||||
contentType := http.DetectContentType(data)
|
|
||||||
if contentType != "application/epub+zip" && contentType != "application/zip" {
|
|
||||||
return CreateDocument400JSONResponse{
|
|
||||||
Code: 400,
|
|
||||||
Message: fmt.Sprintf("Invalid file type: %s. Only EPUB files are allowed.", contentType),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temp file to get metadata
|
|
||||||
tempFile, err := os.CreateTemp("", "book")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Temp file create error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to create temp file"}, nil
|
|
||||||
}
|
|
||||||
defer os.Remove(tempFile.Name())
|
|
||||||
defer tempFile.Close()
|
|
||||||
|
|
||||||
// Write data to temp file
|
|
||||||
if _, err := tempFile.Write(data); err != nil {
|
|
||||||
log.Error("Write temp file error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to write temp file"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get metadata using metadata package
|
|
||||||
metadataInfo, err := metadata.GetMetadata(tempFile.Name())
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetMetadata error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to acquire metadata"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already exists
|
|
||||||
_, err = s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
|
||||||
if err == nil {
|
|
||||||
// Document already exists
|
|
||||||
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
|
||||||
apiDoc := Document{
|
|
||||||
Id: existingDoc.ID,
|
|
||||||
Title: *existingDoc.Title,
|
|
||||||
Author: *existingDoc.Author,
|
|
||||||
Description: existingDoc.Description,
|
|
||||||
Isbn10: existingDoc.Isbn10,
|
|
||||||
Isbn13: existingDoc.Isbn13,
|
|
||||||
Words: existingDoc.Words,
|
|
||||||
Filepath: existingDoc.Filepath,
|
|
||||||
CreatedAt: parseTime(existingDoc.CreatedAt),
|
|
||||||
UpdatedAt: parseTime(existingDoc.UpdatedAt),
|
|
||||||
Deleted: existingDoc.Deleted,
|
|
||||||
}
|
|
||||||
response := DocumentResponse{
|
|
||||||
Document: apiDoc,
|
|
||||||
}
|
|
||||||
return CreateDocument200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive & sanitize file name
|
|
||||||
fileName := deriveBaseFileName(metadataInfo)
|
|
||||||
basePath := filepath.Join(s.cfg.DataPath, "documents")
|
|
||||||
safePath := filepath.Join(basePath, fileName)
|
|
||||||
|
|
||||||
// Save file to storage
|
|
||||||
err = os.WriteFile(safePath, data, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Save file error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert document
|
|
||||||
doc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
|
||||||
ID: *metadataInfo.PartialMD5,
|
|
||||||
Title: metadataInfo.Title,
|
|
||||||
Author: metadataInfo.Author,
|
|
||||||
Description: metadataInfo.Description,
|
|
||||||
Md5: metadataInfo.MD5,
|
|
||||||
Words: metadataInfo.WordCount,
|
|
||||||
Filepath: &fileName,
|
|
||||||
Basepath: &basePath,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("UpsertDocument DB error:", err)
|
|
||||||
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to save document"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDoc := Document{
|
|
||||||
Id: doc.ID,
|
|
||||||
Title: *doc.Title,
|
|
||||||
Author: *doc.Author,
|
|
||||||
Description: doc.Description,
|
|
||||||
Isbn10: doc.Isbn10,
|
|
||||||
Isbn13: doc.Isbn13,
|
|
||||||
Words: doc.Words,
|
|
||||||
Filepath: doc.Filepath,
|
|
||||||
CreatedAt: parseTime(doc.CreatedAt),
|
|
||||||
UpdatedAt: parseTime(doc.UpdatedAt),
|
|
||||||
Deleted: doc.Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
response := DocumentResponse{
|
|
||||||
Document: apiDoc,
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreateDocument200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDocumentCover200Response is a custom response type that allows setting content type
|
|
||||||
type GetDocumentCover200Response struct {
|
|
||||||
Body io.Reader
|
|
||||||
ContentLength int64
|
|
||||||
ContentType string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (response GetDocumentCover200Response) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
|
|
||||||
w.Header().Set("Content-Type", response.ContentType)
|
|
||||||
if response.ContentLength != 0 {
|
|
||||||
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
|
|
||||||
}
|
|
||||||
w.WriteHeader(200)
|
|
||||||
|
|
||||||
if closer, ok := response.Body.(io.Closer); ok {
|
|
||||||
defer closer.Close()
|
|
||||||
}
|
|
||||||
_, err := io.Copy(w, response.Body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDocumentFile200Response is a custom response type that allows setting filename for download
|
|
||||||
type GetDocumentFile200Response struct {
|
|
||||||
Body io.Reader
|
|
||||||
ContentLength int64
|
|
||||||
Filename string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (response GetDocumentFile200Response) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
if response.ContentLength != 0 {
|
|
||||||
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", response.Filename))
|
|
||||||
w.WriteHeader(200)
|
|
||||||
|
|
||||||
if closer, ok := response.Body.(io.Closer); ok {
|
|
||||||
defer closer.Close()
|
|
||||||
}
|
|
||||||
_, err := io.Copy(w, response.Body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/md5"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
|
|
||||||
argon2 "github.com/alexedwards/argon2id"
|
|
||||||
"reichard.io/antholume/config"
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
"reichard.io/antholume/pkg/ptr"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DocumentsTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
db *database.DBManager
|
|
||||||
cfg *config.Config
|
|
||||||
srv *Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) setupConfig() *config.Config {
|
|
||||||
return &config.Config{
|
|
||||||
ListenPort: "8080",
|
|
||||||
DBType: "memory",
|
|
||||||
DBName: "test",
|
|
||||||
ConfigPath: "/tmp",
|
|
||||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
|
||||||
CookieEncKey: "0123456789abcdef",
|
|
||||||
CookieSecure: false,
|
|
||||||
CookieHTTPOnly: true,
|
|
||||||
Version: "test",
|
|
||||||
DemoMode: false,
|
|
||||||
RegistrationEnabled: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDocuments(t *testing.T) {
|
|
||||||
suite.Run(t, new(DocumentsTestSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) SetupTest() {
|
|
||||||
suite.cfg = suite.setupConfig()
|
|
||||||
suite.db = database.NewMgr(suite.cfg)
|
|
||||||
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) createTestUser(username, password string) {
|
|
||||||
suite.authTestSuiteHelper(username, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) login(username, password string) *http.Cookie {
|
|
||||||
return suite.authLoginHelper(username, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) authTestSuiteHelper(username, password string) {
|
|
||||||
// MD5 hash for KOSync compatibility (matches existing system)
|
|
||||||
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
|
|
||||||
|
|
||||||
// Then argon2 hash the MD5
|
|
||||||
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
|
|
||||||
ID: username,
|
|
||||||
Pass: &hashedPassword,
|
|
||||||
AuthHash: ptr.Of("test-auth-hash"),
|
|
||||||
Admin: true,
|
|
||||||
})
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) authLoginHelper(username, password string) *http.Cookie {
|
|
||||||
reqBody := LoginRequest{Username: username, Password: password}
|
|
||||||
body, err := json.Marshal(reqBody)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
|
||||||
suite.Require().Len(cookies, 1)
|
|
||||||
|
|
||||||
return cookies[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
|
|
||||||
suite.createTestUser("testuser", "testpass")
|
|
||||||
cookie := suite.login("testuser", "testpass")
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents?page=1&limit=9", nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp DocumentsResponse
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
suite.Equal(int64(1), resp.Page)
|
|
||||||
suite.Equal(int64(9), resp.Limit)
|
|
||||||
suite.Equal("testuser", resp.User.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) TestAPIGetDocument() {
|
|
||||||
suite.createTestUser("testuser", "testpass")
|
|
||||||
|
|
||||||
docID := "test-doc-1"
|
|
||||||
_, err := suite.db.Queries.UpsertDocument(suite.T().Context(), database.UpsertDocumentParams{
|
|
||||||
ID: docID,
|
|
||||||
Title: ptr.Of("Test Document"),
|
|
||||||
Author: ptr.Of("Test Author"),
|
|
||||||
})
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
cookie := suite.login("testuser", "testpass")
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+docID, nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp DocumentResponse
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
suite.Equal(docID, resp.Document.Id)
|
|
||||||
suite.Equal("Test Document", resp.Document.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
|
|
||||||
suite.createTestUser("testuser", "testpass")
|
|
||||||
cookie := suite.login("testuser", "testpass")
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/non-existent", nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusNotFound, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) TestAPIGetDocumentCoverUnauthenticated() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/cover", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *DocumentsTestSuite) TestAPIGetDocumentFileUnauthenticated() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/file", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
//go:generate oapi-codegen -config oapi-codegen.yaml openapi.yaml
|
|
||||||
226
api/v1/home.go
226
api/v1/home.go
@@ -1,226 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
"reichard.io/antholume/graph"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET /home
|
|
||||||
func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetHome401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get database info
|
|
||||||
dbInfo, err := s.db.Queries.GetDatabaseInfo(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetDatabaseInfo DB Error:", err)
|
|
||||||
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get streaks
|
|
||||||
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetUserStreaks DB Error:", err)
|
|
||||||
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get graph data
|
|
||||||
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetDailyReadStats DB Error:", err)
|
|
||||||
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user statistics
|
|
||||||
userStats, err := s.db.Queries.GetUserStatistics(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetUserStatistics DB Error:", err)
|
|
||||||
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build response
|
|
||||||
response := HomeResponse{
|
|
||||||
DatabaseInfo: DatabaseInfo{
|
|
||||||
DocumentsSize: dbInfo.DocumentsSize,
|
|
||||||
ActivitySize: dbInfo.ActivitySize,
|
|
||||||
ProgressSize: dbInfo.ProgressSize,
|
|
||||||
DevicesSize: dbInfo.DevicesSize,
|
|
||||||
},
|
|
||||||
Streaks: StreaksResponse{
|
|
||||||
Streaks: convertStreaks(streaks),
|
|
||||||
},
|
|
||||||
GraphData: GraphDataResponse{
|
|
||||||
GraphData: convertGraphData(graphData),
|
|
||||||
},
|
|
||||||
UserStatistics: arrangeUserStatistics(userStats),
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetHome200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /home/streaks
|
|
||||||
func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetStreaks401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetUserStreaks DB Error:", err)
|
|
||||||
return GetStreaks500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
response := StreaksResponse{
|
|
||||||
Streaks: convertStreaks(streaks),
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetStreaks200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /home/graph
|
|
||||||
func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestObject) (GetGraphDataResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetGraphData401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetDailyReadStats DB Error:", err)
|
|
||||||
return GetGraphData500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
response := GraphDataResponse{
|
|
||||||
GraphData: convertGraphData(graphData),
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetGraphData200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /home/statistics
|
|
||||||
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
|
|
||||||
_, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userStats, err := s.db.Queries.GetUserStatistics(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetUserStatistics DB Error:", err)
|
|
||||||
return GetUserStatistics500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
response := arrangeUserStatistics(userStats)
|
|
||||||
return GetUserStatistics200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertStreaks(streaks []database.UserStreak) []UserStreak {
|
|
||||||
result := make([]UserStreak, len(streaks))
|
|
||||||
for i, streak := range streaks {
|
|
||||||
result[i] = UserStreak{
|
|
||||||
Window: streak.Window,
|
|
||||||
MaxStreak: streak.MaxStreak,
|
|
||||||
MaxStreakStartDate: streak.MaxStreakStartDate,
|
|
||||||
MaxStreakEndDate: streak.MaxStreakEndDate,
|
|
||||||
CurrentStreak: streak.CurrentStreak,
|
|
||||||
CurrentStreakStartDate: streak.CurrentStreakStartDate,
|
|
||||||
CurrentStreakEndDate: streak.CurrentStreakEndDate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoint {
|
|
||||||
result := make([]GraphDataPoint, len(graphData))
|
|
||||||
for i, data := range graphData {
|
|
||||||
result[i] = GraphDataPoint{
|
|
||||||
Date: data.Date,
|
|
||||||
MinutesRead: data.MinutesRead,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
|
|
||||||
// Sort by WPM for each period
|
|
||||||
sortByWPM := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) float64) []LeaderboardEntry {
|
|
||||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
|
||||||
return getter(sorted[i]) > getter(sorted[j])
|
|
||||||
})
|
|
||||||
|
|
||||||
result := make([]LeaderboardEntry, len(sorted))
|
|
||||||
for i, item := range sorted {
|
|
||||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: getter(item)}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by duration (seconds) for each period
|
|
||||||
sortByDuration := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
|
|
||||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
|
||||||
return getter(sorted[i]) > getter(sorted[j])
|
|
||||||
})
|
|
||||||
|
|
||||||
result := make([]LeaderboardEntry, len(sorted))
|
|
||||||
for i, item := range sorted {
|
|
||||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by words for each period
|
|
||||||
sortByWords := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
|
|
||||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
|
||||||
return getter(sorted[i]) > getter(sorted[j])
|
|
||||||
})
|
|
||||||
|
|
||||||
result := make([]LeaderboardEntry, len(sorted))
|
|
||||||
for i, item := range sorted {
|
|
||||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return UserStatisticsResponse{
|
|
||||||
Wpm: LeaderboardData{
|
|
||||||
All: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.TotalWpm }),
|
|
||||||
Year: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.YearlyWpm }),
|
|
||||||
Month: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.MonthlyWpm }),
|
|
||||||
Week: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.WeeklyWpm }),
|
|
||||||
},
|
|
||||||
Duration: LeaderboardData{
|
|
||||||
All: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalSeconds }),
|
|
||||||
Year: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlySeconds }),
|
|
||||||
Month: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlySeconds }),
|
|
||||||
Week: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklySeconds }),
|
|
||||||
},
|
|
||||||
Words: LeaderboardData{
|
|
||||||
All: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalWordsRead }),
|
|
||||||
Year: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlyWordsRead }),
|
|
||||||
Month: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlyWordsRead }),
|
|
||||||
Week: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklyWordsRead }),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSVGGraphData generates SVG bezier path for graph visualization
|
|
||||||
func GetSVGGraphData(inputData []GraphDataPoint, svgWidth int, svgHeight int) graph.SVGGraphData {
|
|
||||||
// Convert to int64 slice expected by graph package
|
|
||||||
intData := make([]int64, len(inputData))
|
|
||||||
|
|
||||||
for i, data := range inputData {
|
|
||||||
intData[i] = int64(data.MinutesRead)
|
|
||||||
}
|
|
||||||
|
|
||||||
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package: v1
|
|
||||||
generate:
|
|
||||||
std-http-server: true
|
|
||||||
strict-server: true
|
|
||||||
models: true
|
|
||||||
output: api.gen.go
|
|
||||||
1787
api/v1/openapi.yaml
1787
api/v1/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math"
|
|
||||||
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET /progress
|
|
||||||
func (s *Server) GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetProgressList401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
page := int64(1)
|
|
||||||
if request.Params.Page != nil {
|
|
||||||
page = *request.Params.Page
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := int64(15)
|
|
||||||
if request.Params.Limit != nil {
|
|
||||||
limit = *request.Params.Limit
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := database.GetProgressParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Offset: (page - 1) * limit,
|
|
||||||
Limit: limit,
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Params.Document != nil && *request.Params.Document != "" {
|
|
||||||
filter.DocFilter = true
|
|
||||||
filter.DocumentID = *request.Params.Document
|
|
||||||
}
|
|
||||||
|
|
||||||
progress, err := s.db.Queries.GetProgress(ctx, filter)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetProgress DB Error:", err)
|
|
||||||
return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
total := int64(len(progress))
|
|
||||||
var nextPage *int64
|
|
||||||
var previousPage *int64
|
|
||||||
|
|
||||||
// Calculate total pages
|
|
||||||
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
|
|
||||||
if page < totalPages {
|
|
||||||
nextPage = ptrOf(page + 1)
|
|
||||||
}
|
|
||||||
if page > 1 {
|
|
||||||
previousPage = ptrOf(page - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
apiProgress := make([]Progress, len(progress))
|
|
||||||
for i, row := range progress {
|
|
||||||
apiProgress[i] = Progress{
|
|
||||||
Title: row.Title,
|
|
||||||
Author: row.Author,
|
|
||||||
DeviceName: &row.DeviceName,
|
|
||||||
Percentage: &row.Percentage,
|
|
||||||
DocumentId: &row.DocumentID,
|
|
||||||
UserId: &row.UserID,
|
|
||||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ProgressListResponse{
|
|
||||||
Progress: &apiProgress,
|
|
||||||
Page: &page,
|
|
||||||
Limit: &limit,
|
|
||||||
NextPage: nextPage,
|
|
||||||
PreviousPage: previousPage,
|
|
||||||
Total: &total,
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetProgressList200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /progress/{id}
|
|
||||||
func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := database.GetProgressParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
DocFilter: true,
|
|
||||||
DocumentID: request.Id,
|
|
||||||
Offset: 0,
|
|
||||||
Limit: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
progress, err := s.db.Queries.GetProgress(ctx, filter)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("GetProgress DB Error:", err)
|
|
||||||
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(progress) == 0 {
|
|
||||||
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
row := progress[0]
|
|
||||||
apiProgress := Progress{
|
|
||||||
Title: row.Title,
|
|
||||||
Author: row.Author,
|
|
||||||
DeviceName: &row.DeviceName,
|
|
||||||
Percentage: &row.Percentage,
|
|
||||||
DocumentId: &row.DocumentID,
|
|
||||||
UserId: &row.UserID,
|
|
||||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ProgressResponse{
|
|
||||||
Progress: &apiProgress,
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetProgress200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"reichard.io/antholume/search"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET /search
|
|
||||||
func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject) (GetSearchResponseObject, error) {
|
|
||||||
|
|
||||||
if request.Params.Query == "" {
|
|
||||||
return GetSearch400JSONResponse{Code: 400, Message: "Invalid query"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
query := request.Params.Query
|
|
||||||
source := string(request.Params.Source)
|
|
||||||
|
|
||||||
// Validate source
|
|
||||||
if source != "LibGen" && source != "Annas Archive" {
|
|
||||||
return GetSearch400JSONResponse{Code: 400, Message: "Invalid source"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
searchResults, err := search.SearchBook(query, search.Source(source))
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Search Error:", err)
|
|
||||||
return GetSearch500JSONResponse{Code: 500, Message: "Search error"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
apiResults := make([]SearchItem, len(searchResults))
|
|
||||||
for i, item := range searchResults {
|
|
||||||
apiResults[i] = SearchItem{
|
|
||||||
Id: ptrOf(item.ID),
|
|
||||||
Title: ptrOf(item.Title),
|
|
||||||
Author: ptrOf(item.Author),
|
|
||||||
Language: ptrOf(item.Language),
|
|
||||||
Series: ptrOf(item.Series),
|
|
||||||
FileType: ptrOf(item.FileType),
|
|
||||||
FileSize: ptrOf(item.FileSize),
|
|
||||||
UploadDate: ptrOf(item.UploadDate),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := SearchResponse{
|
|
||||||
Results: apiResults,
|
|
||||||
Source: source,
|
|
||||||
Query: query,
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetSearch200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /search
|
|
||||||
func (s *Server) PostSearch(ctx context.Context, request PostSearchRequestObject) (PostSearchResponseObject, error) {
|
|
||||||
// This endpoint is used by the SSR template to queue a download
|
|
||||||
// For the API, we just return success - the actual download happens via /documents POST
|
|
||||||
return PostSearch200Response{}, nil
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"reichard.io/antholume/config"
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ StrictServerInterface = (*Server)(nil)
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
mux *http.ServeMux
|
|
||||||
db *database.DBManager
|
|
||||||
cfg *config.Config
|
|
||||||
assets fs.FS
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewServer creates a new native HTTP server
|
|
||||||
func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server {
|
|
||||||
s := &Server{
|
|
||||||
mux: http.NewServeMux(),
|
|
||||||
db: db,
|
|
||||||
cfg: cfg,
|
|
||||||
assets: assets,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create strict handler with authentication middleware
|
|
||||||
strictHandler := NewStrictHandler(s, []StrictMiddlewareFunc{s.authMiddleware})
|
|
||||||
|
|
||||||
s.mux = HandlerFromMuxWithBaseURL(strictHandler, s.mux, "/api/v1").(*http.ServeMux)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.mux.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// authMiddleware adds authentication context to requests
|
|
||||||
func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) StrictHandlerFunc {
|
|
||||||
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) {
|
|
||||||
// Store request and response in context for all handlers
|
|
||||||
ctx = context.WithValue(ctx, "request", r)
|
|
||||||
ctx = context.WithValue(ctx, "response", w)
|
|
||||||
|
|
||||||
// Skip auth for public auth and info endpoints - cover and file require auth via cookies
|
|
||||||
if operationID == "Login" || operationID == "Register" || operationID == "GetInfo" {
|
|
||||||
return handler(ctx, w, r, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth, ok := s.getSession(r)
|
|
||||||
if !ok {
|
|
||||||
// Write 401 response directly
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(401)
|
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Code: 401, Message: "Unauthorized"})
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check admin status for admin-only endpoints
|
|
||||||
adminEndpoints := []string{
|
|
||||||
"GetAdmin",
|
|
||||||
"PostAdminAction",
|
|
||||||
"GetUsers",
|
|
||||||
"UpdateUser",
|
|
||||||
"GetImportDirectory",
|
|
||||||
"PostImport",
|
|
||||||
"GetImportResults",
|
|
||||||
"GetLogs",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, adminEndpoint := range adminEndpoints {
|
|
||||||
if operationID == adminEndpoint && !auth.IsAdmin {
|
|
||||||
// Write 403 response directly
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(403)
|
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{Code: 403, Message: "Admin privileges required"})
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store auth in context for handlers to access
|
|
||||||
ctx = context.WithValue(ctx, "auth", auth)
|
|
||||||
|
|
||||||
return handler(ctx, w, r, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetInfo returns server information
|
|
||||||
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
|
||||||
return GetInfo200JSONResponse{
|
|
||||||
Version: s.cfg.Version,
|
|
||||||
SearchEnabled: s.cfg.SearchEnabled,
|
|
||||||
RegistrationEnabled: s.cfg.RegistrationEnabled,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
|
|
||||||
"reichard.io/antholume/config"
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ServerTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
db *database.DBManager
|
|
||||||
cfg *config.Config
|
|
||||||
srv *Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer(t *testing.T) {
|
|
||||||
suite.Run(t, new(ServerTestSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ServerTestSuite) SetupTest() {
|
|
||||||
suite.cfg = &config.Config{
|
|
||||||
ListenPort: "8080",
|
|
||||||
DBType: "memory",
|
|
||||||
DBName: "test",
|
|
||||||
ConfigPath: "/tmp",
|
|
||||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
|
||||||
CookieEncKey: "0123456789abcdef",
|
|
||||||
CookieSecure: false,
|
|
||||||
CookieHTTPOnly: true,
|
|
||||||
Version: "test",
|
|
||||||
DemoMode: false,
|
|
||||||
RegistrationEnabled: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
suite.db = database.NewMgr(suite.cfg)
|
|
||||||
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ServerTestSuite) TestNewServer() {
|
|
||||||
suite.NotNil(suite.srv)
|
|
||||||
suite.NotNil(suite.srv.mux)
|
|
||||||
suite.NotNil(suite.srv.db)
|
|
||||||
suite.NotNil(suite.srv.cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ServerTestSuite) TestServerServeHTTP() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
suite.srv.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/md5"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"reichard.io/antholume/database"
|
|
||||||
argon2id "github.com/alexedwards/argon2id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET /settings
|
|
||||||
func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObject) (GetSettingsResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return GetSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDevices := make([]Device, len(devices))
|
|
||||||
for i, device := range devices {
|
|
||||||
apiDevices[i] = Device{
|
|
||||||
Id: &device.ID,
|
|
||||||
DeviceName: &device.DeviceName,
|
|
||||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
|
||||||
LastSynced: parseTimePtr(device.LastSynced),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := SettingsResponse{
|
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
Timezone: user.Timezone,
|
|
||||||
Devices: &apiDevices,
|
|
||||||
}
|
|
||||||
return GetSettings200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// authorizeCredentials verifies if credentials are valid
|
|
||||||
func (s *Server) authorizeCredentials(ctx context.Context, username string, password string) bool {
|
|
||||||
user, err := s.db.Queries.GetUser(ctx, username)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try argon2 hash comparison
|
|
||||||
if match, err := argon2id.ComparePasswordAndHash(password, *user.Pass); err == nil && match {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT /settings
|
|
||||||
func (s *Server) UpdateSettings(ctx context.Context, request UpdateSettingsRequestObject) (UpdateSettingsResponseObject, error) {
|
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
|
||||||
if !ok {
|
|
||||||
return UpdateSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Body == nil {
|
|
||||||
return UpdateSettings400JSONResponse{Code: 400, Message: "Request body is required"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
updateParams := database.UpdateUserParams{
|
|
||||||
UserID: auth.UserName,
|
|
||||||
Admin: auth.IsAdmin,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update password if provided
|
|
||||||
if request.Body.NewPassword != nil {
|
|
||||||
if request.Body.Password == nil {
|
|
||||||
return UpdateSettings400JSONResponse{Code: 400, Message: "Current password is required to set new password"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify current password - first try bcrypt (new format), then argon2, then MD5 (legacy format)
|
|
||||||
currentPasswordMatched := false
|
|
||||||
|
|
||||||
// Try argon2 (current format)
|
|
||||||
if !currentPasswordMatched {
|
|
||||||
currentPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.Password)))
|
|
||||||
if match, err := argon2id.ComparePasswordAndHash(currentPassword, *user.Pass); err == nil && match {
|
|
||||||
currentPasswordMatched = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !currentPasswordMatched {
|
|
||||||
return UpdateSettings400JSONResponse{Code: 400, Message: "Invalid current password"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash new password with argon2
|
|
||||||
newPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.NewPassword)))
|
|
||||||
hashedPassword, err := argon2id.CreateHash(newPassword, argon2id.DefaultParams)
|
|
||||||
if err != nil {
|
|
||||||
return UpdateSettings500JSONResponse{Code: 500, Message: "Failed to hash password"}, nil
|
|
||||||
}
|
|
||||||
updateParams.Password = &hashedPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update timezone if provided
|
|
||||||
if request.Body.Timezone != nil {
|
|
||||||
updateParams.Timezone = request.Body.Timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
// If nothing to update, return error
|
|
||||||
if request.Body.NewPassword == nil && request.Body.Timezone == nil {
|
|
||||||
return UpdateSettings400JSONResponse{Code: 400, Message: "At least one field must be provided"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user
|
|
||||||
_, err = s.db.Queries.UpdateUser(ctx, updateParams)
|
|
||||||
if err != nil {
|
|
||||||
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get updated settings to return
|
|
||||||
user, err = s.db.Queries.GetUser(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
|
|
||||||
if err != nil {
|
|
||||||
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
apiDevices := make([]Device, len(devices))
|
|
||||||
for i, device := range devices {
|
|
||||||
apiDevices[i] = Device{
|
|
||||||
Id: &device.ID,
|
|
||||||
DeviceName: &device.DeviceName,
|
|
||||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
|
||||||
LastSynced: parseTimePtr(device.LastSynced),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := SettingsResponse{
|
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
|
||||||
Timezone: user.Timezone,
|
|
||||||
Devices: &apiDevices,
|
|
||||||
}
|
|
||||||
return UpdateSettings200JSONResponse(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// writeJSON writes a JSON response (deprecated - used by tests only)
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, "Failed to encode response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeJSONError writes a JSON error response (deprecated - used by tests only)
|
|
||||||
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
|
||||||
writeJSON(w, status, ErrorResponse{
|
|
||||||
Code: status,
|
|
||||||
Message: message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryParams represents parsed query parameters (deprecated - used by tests only)
|
|
||||||
type QueryParams struct {
|
|
||||||
Page int64
|
|
||||||
Limit int64
|
|
||||||
Search *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseQueryParams parses URL query parameters (deprecated - used by tests only)
|
|
||||||
func parseQueryParams(query url.Values, defaultLimit int64) QueryParams {
|
|
||||||
page, _ := strconv.ParseInt(query.Get("page"), 10, 64)
|
|
||||||
if page == 0 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64)
|
|
||||||
if limit == 0 {
|
|
||||||
limit = defaultLimit
|
|
||||||
}
|
|
||||||
search := query.Get("search")
|
|
||||||
var searchPtr *string
|
|
||||||
if search != "" {
|
|
||||||
searchPtr = ptrOf("%" + search + "%")
|
|
||||||
}
|
|
||||||
return QueryParams{
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
Search: searchPtr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ptrOf returns a pointer to the given value
|
|
||||||
func ptrOf[T any](v T) *T {
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTime parses a string to time.Time
|
|
||||||
func parseTime(s string) time.Time {
|
|
||||||
t, _ := time.Parse(time.RFC3339, s)
|
|
||||||
if t.IsZero() {
|
|
||||||
t, _ = time.Parse("2006-01-02T15:04:05", s)
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTimePtr parses an interface{} (from SQL) to *time.Time
|
|
||||||
func parseTimePtr(v interface{}) *time.Time {
|
|
||||||
if v == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
t := parseTime(s)
|
|
||||||
if t.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UtilsTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUtils(t *testing.T) {
|
|
||||||
suite.Run(t, new(UtilsTestSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *UtilsTestSuite) TestWriteJSON() {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
data := map[string]string{"test": "value"}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, data)
|
|
||||||
|
|
||||||
suite.Equal("application/json", w.Header().Get("Content-Type"))
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var resp map[string]string
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
suite.Equal("value", resp["test"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *UtilsTestSuite) TestWriteJSONError() {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "test error")
|
|
||||||
|
|
||||||
suite.Equal(http.StatusBadRequest, w.Code)
|
|
||||||
|
|
||||||
var resp ErrorResponse
|
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
suite.Equal(http.StatusBadRequest, resp.Code)
|
|
||||||
suite.Equal("test error", resp.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *UtilsTestSuite) TestParseQueryParams() {
|
|
||||||
query := make(map[string][]string)
|
|
||||||
query["page"] = []string{"2"}
|
|
||||||
query["limit"] = []string{"15"}
|
|
||||||
query["search"] = []string{"test"}
|
|
||||||
|
|
||||||
params := parseQueryParams(query, 9)
|
|
||||||
|
|
||||||
suite.Equal(int64(2), params.Page)
|
|
||||||
suite.Equal(int64(15), params.Limit)
|
|
||||||
suite.NotNil(params.Search)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *UtilsTestSuite) TestParseQueryParamsDefaults() {
|
|
||||||
query := make(map[string][]string)
|
|
||||||
|
|
||||||
params := parseQueryParams(query, 9)
|
|
||||||
|
|
||||||
suite.Equal(int64(1), params.Page)
|
|
||||||
suite.Equal(int64(9), params.Limit)
|
|
||||||
suite.Nil(params.Search)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *UtilsTestSuite) TestPtrOf() {
|
|
||||||
value := "test"
|
|
||||||
ptr := ptrOf(value)
|
|
||||||
|
|
||||||
suite.NotNil(ptr)
|
|
||||||
suite.Equal("test", *ptr)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
@tailwind base;
|
/* ----------------------------- */
|
||||||
@tailwind components;
|
/* -------- PWA Styling -------- */
|
||||||
@tailwind utilities;
|
/* ----------------------------- */
|
||||||
|
|
||||||
/* PWA Styling */
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
overscroll-behavior-y: none;
|
overscroll-behavior-y: none;
|
||||||
@@ -11,7 +9,8 @@ body {
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
height: calc(100% + env(safe-area-inset-bottom));
|
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);
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
||||||
|
env(safe-area-inset-left);
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -33,7 +32,9 @@ main {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button visibility toggle */
|
/* ----------------------------- */
|
||||||
|
/* -------- CSS Button -------- */
|
||||||
|
/* ----------------------------- */
|
||||||
.css-button:checked + div {
|
.css-button:checked + div {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -44,7 +45,22 @@ main {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Navigation */
|
/* ----------------------------- */
|
||||||
|
/* ------- User Dropdown ------- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
#user-dropdown-button:checked + #user-dropdown {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-dropdown {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* ----- Mobile Navigation ----- */
|
||||||
|
/* ----------------------------- */
|
||||||
#mobile-nav-button span {
|
#mobile-nav-button span {
|
||||||
transform-origin: 5px 0px;
|
transform-origin: 5px 0px;
|
||||||
transition:
|
transition:
|
||||||
@@ -61,26 +77,26 @@ main {
|
|||||||
transform-origin: 0% 100%;
|
transform-origin: 0% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mobile-nav-button:checked ~ span {
|
#mobile-nav-button input:checked ~ span {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(45deg) translate(2px, -2px);
|
transform: rotate(45deg) translate(2px, -2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#mobile-nav-button:checked ~ span:nth-last-child(3) {
|
#mobile-nav-button input:checked ~ span:nth-last-child(3) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: rotate(0deg) scale(0.2, 0.2);
|
transform: rotate(0deg) scale(0.2, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#mobile-nav-button:checked ~ span:nth-last-child(2) {
|
#mobile-nav-button input:checked ~ span:nth-last-child(2) {
|
||||||
transform: rotate(-45deg) translate(0, 6px);
|
transform: rotate(-45deg) translate(0, 6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#mobile-nav-button:checked ~ #menu {
|
#mobile-nav-button input:checked ~ div {
|
||||||
transform: translate(0, 0) !important;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
#mobile-nav-button ~ #menu {
|
#mobile-nav-button input ~ div {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,50 +114,3 @@ main {
|
|||||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton Wave Animation */
|
|
||||||
@keyframes wave {
|
|
||||||
0% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-wave {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgb(229, 231, 235) 0%,
|
|
||||||
rgb(243, 244, 246) 50%,
|
|
||||||
rgb(229, 231, 235) 100%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: wave 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .animate-wave {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgb(75, 85, 99) 0%,
|
|
||||||
rgb(107, 114, 128) 50%,
|
|
||||||
rgb(75, 85, 99) 100%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast Slide In Animation */
|
|
||||||
@keyframes slideInRight {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slideInRight {
|
|
||||||
animation: slideInRight 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<title>AnthoLume - Local</title>
|
<title>AnthoLume - Local</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/style.css" />
|
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||||
|
|
||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<title>AnthoLume - Reader</title>
|
<title>AnthoLume - Reader</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/style.css" />
|
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||||
|
|
||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
@@ -82,9 +82,13 @@
|
|||||||
id="top-bar"
|
id="top-bar"
|
||||||
class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2"
|
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="max-h-[75vh] w-full flex flex-col items-center justify-around relative dark:text-white"
|
||||||
|
>
|
||||||
<div class="h-32">
|
<div class="h-32">
|
||||||
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
<div
|
||||||
|
class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4"
|
||||||
|
>
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
@@ -153,7 +157,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="toc" class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"></div>
|
<div
|
||||||
|
id="toc"
|
||||||
|
class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1317,7 +1317,7 @@ class EBookReader {
|
|||||||
let spineWC = await Promise.all(
|
let spineWC = await Promise.all(
|
||||||
this.book.spine.spineItems.map(async (item) => {
|
this.book.spine.spineItems.map(async (item) => {
|
||||||
let newDoc = await item.load(this.book.load.bind(this.book));
|
let newDoc = await item.load(this.book.load.bind(this.book));
|
||||||
let spineWords = (newDoc.innerText || "").trim().split(/\s+/).length;
|
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
|
||||||
item.wordCount = spineWords;
|
item.wordCount = spineWords;
|
||||||
return spineWords;
|
return spineWords;
|
||||||
}),
|
}),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -72,7 +72,8 @@ const PRECACHE_ASSETS = [
|
|||||||
// Main App Assets
|
// Main App Assets
|
||||||
"/manifest.json",
|
"/manifest.json",
|
||||||
"/assets/index.js",
|
"/assets/index.js",
|
||||||
"/assets/style.css",
|
"/assets/index.css",
|
||||||
|
"/assets/tailwind.css",
|
||||||
"/assets/common.js",
|
"/assets/common.js",
|
||||||
|
|
||||||
// Library Assets
|
// 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
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
device_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,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
@@ -138,8 +138,8 @@ WHERE id = $device_id LIMIT 1;
|
|||||||
SELECT
|
SELECT
|
||||||
devices.id,
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
|
CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
|
||||||
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
|
CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
|
||||||
FROM devices
|
FROM devices
|
||||||
JOIN users ON users.id = devices.user_id
|
JOIN users ON users.id = devices.user_id
|
||||||
WHERE users.id = $user_id
|
WHERE users.id = $user_id
|
||||||
@@ -246,7 +246,7 @@ SELECT
|
|||||||
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||||
progress.document_id,
|
progress.document_id,
|
||||||
progress.user_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
|
FROM document_progress AS progress
|
||||||
LEFT JOIN users ON progress.user_id = users.id
|
LEFT JOIN users ON progress.user_id = users.id
|
||||||
LEFT JOIN devices ON progress.device_id = devices.id
|
LEFT JOIN devices ON progress.device_id = devices.id
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ WITH filtered_activity AS (
|
|||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
device_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,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
@@ -216,7 +216,7 @@ type GetActivityParams struct {
|
|||||||
type GetActivityRow struct {
|
type GetActivityRow struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
StartTime interface{} `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Author *string `json:"author"`
|
Author *string `json:"author"`
|
||||||
Duration int64 `json:"duration"`
|
Duration int64 `json:"duration"`
|
||||||
@@ -422,8 +422,8 @@ const getDevices = `-- name: GetDevices :many
|
|||||||
SELECT
|
SELECT
|
||||||
devices.id,
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
|
CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
|
||||||
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
|
CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) AS last_synced
|
||||||
FROM devices
|
FROM devices
|
||||||
JOIN users ON users.id = devices.user_id
|
JOIN users ON users.id = devices.user_id
|
||||||
WHERE users.id = ?1
|
WHERE users.id = ?1
|
||||||
@@ -433,8 +433,8 @@ ORDER BY devices.last_synced DESC
|
|||||||
type GetDevicesRow struct {
|
type GetDevicesRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
CreatedAt interface{} `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastSynced interface{} `json:"last_synced"`
|
LastSynced string `json:"last_synced"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
|
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
|
||||||
@@ -824,7 +824,7 @@ SELECT
|
|||||||
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||||
progress.document_id,
|
progress.document_id,
|
||||||
progress.user_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
|
FROM document_progress AS progress
|
||||||
LEFT JOIN users ON progress.user_id = users.id
|
LEFT JOIN users ON progress.user_id = users.id
|
||||||
LEFT JOIN devices ON progress.device_id = devices.id
|
LEFT JOIN devices ON progress.device_id = devices.id
|
||||||
@@ -857,7 +857,7 @@ type GetProgressRow struct {
|
|||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
CreatedAt interface{} `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {
|
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {
|
||||||
|
|||||||
6
flake.lock
generated
6
flake.lock
generated
@@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773524153,
|
"lastModified": 1764522689,
|
||||||
"narHash": "sha256-Jms57zzlFf64ayKzzBWSE2SGvJmK+NGt8Gli71d9kmY=",
|
"narHash": "sha256-SqUuBFjhl/kpDiVaKLQBoD8TLD+/cTUzzgVFoaHrkqY=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e9f278faa1d0c2fc835bd331d4666b59b505a410",
|
"rev": "8bb5646e0bed5dbd3ab08c7a7cc15b75ab4e1d0f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -21,12 +21,11 @@
|
|||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
go
|
go
|
||||||
golangci-lint
|
|
||||||
gopls
|
gopls
|
||||||
|
golangci-lint
|
||||||
bun
|
|
||||||
nodejs
|
nodejs
|
||||||
tailwindcss
|
tailwindcss
|
||||||
|
python311Packages.grip
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export PATH=$PATH:~/go/bin
|
export PATH=$PATH:~/go/bin
|
||||||
|
|||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
node_modules
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Generated API code
|
|
||||||
src/generated/**/*
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"printWidth": 100,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# AnthoLume Frontend
|
|
||||||
|
|
||||||
A React + TypeScript frontend for AnthoLume, replacing the server-side rendering (SSR) templates.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **React 19** - UI framework
|
|
||||||
- **TypeScript** - Type safety
|
|
||||||
- **React Query (TanStack Query)** - Server state management
|
|
||||||
- **Orval** - API client generation from OpenAPI spec
|
|
||||||
- **React Router** - Navigation
|
|
||||||
- **Tailwind CSS** - Styling
|
|
||||||
- **Vite** - Build tool
|
|
||||||
- **Axios** - HTTP client with auth interceptors
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
The frontend includes a complete authentication system:
|
|
||||||
|
|
||||||
### Auth Context
|
|
||||||
- `AuthProvider` - Manages authentication state globally
|
|
||||||
- `useAuth()` - Hook to access auth state and methods
|
|
||||||
- Token stored in `localStorage`
|
|
||||||
- Axios interceptors automatically attach Bearer token to API requests
|
|
||||||
|
|
||||||
### Protected Routes
|
|
||||||
- All main routes are wrapped in `ProtectedRoute`
|
|
||||||
- Unauthenticated users are redirected to `/login`
|
|
||||||
- Layout redirects to login if not authenticated
|
|
||||||
|
|
||||||
### Login Flow
|
|
||||||
1. User enters credentials on `/login`
|
|
||||||
2. POST to `/api/v1/auth/login`
|
|
||||||
3. Token stored in localStorage
|
|
||||||
4. Redirect to home page
|
|
||||||
5. Axios interceptor includes token in subsequent requests
|
|
||||||
|
|
||||||
### Logout Flow
|
|
||||||
1. User clicks "Logout" in dropdown menu
|
|
||||||
2. POST to `/api/v1/auth/logout`
|
|
||||||
3. Token cleared from localStorage
|
|
||||||
4. Redirect to `/login`
|
|
||||||
|
|
||||||
### 401 Handling
|
|
||||||
- Axios response interceptor clears token on 401 errors
|
|
||||||
- Prevents stale auth state
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The frontend mirrors the existing SSR templates structure:
|
|
||||||
|
|
||||||
### Pages
|
|
||||||
- `HomePage` - Landing page with recent documents
|
|
||||||
- `DocumentsPage` - Document listing with search and pagination
|
|
||||||
- `DocumentPage` - Single document view with details
|
|
||||||
- `ProgressPage` - Reading progress table
|
|
||||||
- `ActivityPage` - User activity log
|
|
||||||
- `SearchPage` - Search interface
|
|
||||||
- `SettingsPage` - User settings
|
|
||||||
- `LoginPage` - Authentication
|
|
||||||
|
|
||||||
### Components
|
|
||||||
- `Layout` - Main layout with navigation sidebar and header
|
|
||||||
- Generated API hooks from `api/v1/openapi.yaml`
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run generate:api
|
|
||||||
```
|
|
||||||
|
|
||||||
This generates:
|
|
||||||
- Type definitions for all API schemas
|
|
||||||
- React Query hooks (`useGetDocuments`, `useGetDocument`, etc.)
|
|
||||||
- Mutation hooks (`useLogin`, `useLogout`)
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Generate API types (if OpenAPI spec changes)
|
|
||||||
npm run generate:api
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
The built output is in `dist/` and can be served by the Go backend or deployed separately.
|
|
||||||
|
|
||||||
## Migration from SSR
|
|
||||||
|
|
||||||
The frontend replicates the functionality of the following SSR templates:
|
|
||||||
- `templates/pages/home.tmpl` → `HomePage.tsx`
|
|
||||||
- `templates/pages/documents.tmpl` → `DocumentsPage.tsx`
|
|
||||||
- `templates/pages/document.tmpl` → `DocumentPage.tsx`
|
|
||||||
- `templates/pages/progress.tmpl` → `ProgressPage.tsx`
|
|
||||||
- `templates/pages/activity.tmpl` → `ActivityPage.tsx`
|
|
||||||
- `templates/pages/search.tmpl` → `SearchPage.tsx`
|
|
||||||
- `templates/pages/settings.tmpl` → `SettingsPage.tsx`
|
|
||||||
- `templates/pages/login.tmpl` → `LoginPage.tsx`
|
|
||||||
|
|
||||||
The styling follows the same Tailwind CSS classes as the original templates for consistency.
|
|
||||||
1122
frontend/bun.lock
1122
frontend/bun.lock
File diff suppressed because it is too large
Load Diff
171
frontend/dist/assets/index-C7Wct-hD.js
vendored
171
frontend/dist/assets/index-C7Wct-hD.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-Co--bktJ.css
vendored
1
frontend/dist/assets/index-Co--bktJ.css
vendored
File diff suppressed because one or more lines are too long
32
frontend/dist/index.html
vendored
32
frontend/dist/index.html
vendored
@@ -1,32 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta
|
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
|
||||||
content="black-translucent"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="theme-color"
|
|
||||||
content="#F3F4F6"
|
|
||||||
media="(prefers-color-scheme: light)"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="theme-color"
|
|
||||||
content="#1F2937"
|
|
||||||
media="(prefers-color-scheme: dark)"
|
|
||||||
/>
|
|
||||||
<title>AnthoLume</title>
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<script type="module" crossorigin src="/assets/index-C7Wct-hD.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Co--bktJ.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import typescriptParser from "@typescript-eslint/parser";
|
|
||||||
import typescriptPlugin from "@typescript-eslint/eslint-plugin";
|
|
||||||
import reactPlugin from "eslint-plugin-react";
|
|
||||||
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
|
||||||
import tailwindcss from "eslint-plugin-tailwindcss";
|
|
||||||
import prettier from "eslint-plugin-prettier";
|
|
||||||
import eslintConfigPrettier from "eslint-config-prettier";
|
|
||||||
|
|
||||||
export default [
|
|
||||||
js.configs.recommended,
|
|
||||||
{
|
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
|
||||||
ignores: ["**/generated/**"],
|
|
||||||
languageOptions: {
|
|
||||||
parser: typescriptParser,
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
projectService: true,
|
|
||||||
},
|
|
||||||
globals: {
|
|
||||||
localStorage: "readonly",
|
|
||||||
sessionStorage: "readonly",
|
|
||||||
document: "readonly",
|
|
||||||
window: "readonly",
|
|
||||||
setTimeout: "readonly",
|
|
||||||
clearTimeout: "readonly",
|
|
||||||
setInterval: "readonly",
|
|
||||||
clearInterval: "readonly",
|
|
||||||
HTMLElement: "readonly",
|
|
||||||
HTMLDivElement: "readonly",
|
|
||||||
HTMLButtonElement: "readonly",
|
|
||||||
HTMLAnchorElement: "readonly",
|
|
||||||
MouseEvent: "readonly",
|
|
||||||
Node: "readonly",
|
|
||||||
File: "readonly",
|
|
||||||
Blob: "readonly",
|
|
||||||
FormData: "readonly",
|
|
||||||
alert: "readonly",
|
|
||||||
confirm: "readonly",
|
|
||||||
prompt: "readonly",
|
|
||||||
React: "readonly",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
"@typescript-eslint": typescriptPlugin,
|
|
||||||
react: reactPlugin,
|
|
||||||
"react-hooks": reactHooksPlugin,
|
|
||||||
tailwindcss,
|
|
||||||
prettier,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...eslintConfigPrettier.rules,
|
|
||||||
...tailwindcss.configs.recommended.rules,
|
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
"react/prop-types": "off",
|
|
||||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
|
||||||
"no-undef": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
argsIgnorePattern: "^_",
|
|
||||||
varsIgnorePattern: "^_",
|
|
||||||
caughtErrorsIgnorePattern: "^_",
|
|
||||||
ignoreRestSiblings: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"no-useless-catch": "off",
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: "detect",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta
|
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
|
||||||
content="black-translucent"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="theme-color"
|
|
||||||
content="#F3F4F6"
|
|
||||||
media="(prefers-color-scheme: light)"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="theme-color"
|
|
||||||
content="#1F2937"
|
|
||||||
media="(prefers-color-scheme: dark)"
|
|
||||||
/>
|
|
||||||
<title>AnthoLume</title>
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { defineConfig } from 'orval';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
antholume: {
|
|
||||||
output: {
|
|
||||||
mode: 'split',
|
|
||||||
baseUrl: '/api/v1',
|
|
||||||
target: 'src/generated',
|
|
||||||
schemas: 'src/generated/model',
|
|
||||||
client: 'react-query',
|
|
||||||
mock: false,
|
|
||||||
override: {
|
|
||||||
useQuery: true,
|
|
||||||
mutations: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
target: '../api/v1/openapi.yaml',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "antholume-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"generate:api": "orval",
|
|
||||||
"lint": "eslint src --max-warnings=0",
|
|
||||||
"lint:fix": "eslint src --fix",
|
|
||||||
"format": "prettier --check src",
|
|
||||||
"format:fix": "prettier --write src"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/react-query": "^5.62.16",
|
|
||||||
"ajv": "^8.18.0",
|
|
||||||
"axios": "^1.13.6",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"orval": "8.5.3",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-router-dom": "^7.1.1",
|
|
||||||
"tailwind-merge": "^3.5.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.17.0",
|
|
||||||
"@types/react": "^19.0.8",
|
|
||||||
"@types/react-dom": "^19.0.8",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
|
||||||
"@typescript-eslint/parser": "^8.13.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"eslint": "^9.17.0",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
|
||||||
"eslint-plugin-react": "^7.37.5",
|
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
|
||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"prettier": "^3.3.3",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"typescript": "~5.6.2",
|
|
||||||
"vite": "^6.0.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { AuthProvider } from './auth/AuthContext';
|
|
||||||
import { Routes } from './Routes';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<Routes />
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { Route, Routes as ReactRoutes } from 'react-router-dom';
|
|
||||||
import Layout from './components/Layout';
|
|
||||||
import HomePage from './pages/HomePage';
|
|
||||||
import DocumentsPage from './pages/DocumentsPage';
|
|
||||||
import DocumentPage from './pages/DocumentPage';
|
|
||||||
import ProgressPage from './pages/ProgressPage';
|
|
||||||
import ActivityPage from './pages/ActivityPage';
|
|
||||||
import SearchPage from './pages/SearchPage';
|
|
||||||
import SettingsPage from './pages/SettingsPage';
|
|
||||||
import LoginPage from './pages/LoginPage';
|
|
||||||
import RegisterPage from './pages/RegisterPage';
|
|
||||||
import AdminPage from './pages/AdminPage';
|
|
||||||
import AdminImportPage from './pages/AdminImportPage';
|
|
||||||
import AdminImportResultsPage from './pages/AdminImportResultsPage';
|
|
||||||
import AdminUsersPage from './pages/AdminUsersPage';
|
|
||||||
import AdminLogsPage from './pages/AdminLogsPage';
|
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
|
||||||
|
|
||||||
export function Routes() {
|
|
||||||
return (
|
|
||||||
<ReactRoutes>
|
|
||||||
<Route path="/" element={<Layout />}>
|
|
||||||
<Route
|
|
||||||
index
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<HomePage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="documents"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<DocumentsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="documents/:id"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<DocumentPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="progress"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ProgressPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="activity"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ActivityPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="search"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<SearchPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="settings"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<SettingsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* Admin routes */}
|
|
||||||
<Route
|
|
||||||
path="admin"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/import"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminImportPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/import-results"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminImportResultsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/users"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminUsersPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/logs"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminLogsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
</ReactRoutes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
getGetMeQueryKey,
|
|
||||||
useLogin,
|
|
||||||
useLogout,
|
|
||||||
useGetMe,
|
|
||||||
useRegister,
|
|
||||||
} from '../generated/anthoLumeAPIV1';
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
user: { username: string; is_admin: boolean } | null;
|
|
||||||
isCheckingAuth: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthContextType extends AuthState {
|
|
||||||
login: (_username: string, _password: string) => Promise<void>;
|
|
||||||
register: (_username: string, _password: string) => Promise<void>;
|
|
||||||
logout: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [authState, setAuthState] = useState<AuthState>({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginMutation = useLogin();
|
|
||||||
const registerMutation = useRegister();
|
|
||||||
const logoutMutation = useLogout();
|
|
||||||
|
|
||||||
const { data: meData, error: meError, isLoading: meLoading } = useGetMe();
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAuthState(prev => {
|
|
||||||
if (meLoading) {
|
|
||||||
return { ...prev, isCheckingAuth: true };
|
|
||||||
} else if (meData?.data && meData.status === 200) {
|
|
||||||
const userData = 'username' in meData.data ? meData.data : null;
|
|
||||||
return {
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: userData as { username: string; is_admin: boolean } | null,
|
|
||||||
isCheckingAuth: false,
|
|
||||||
};
|
|
||||||
} else if (meError || (meData && meData.status === 401)) {
|
|
||||||
return {
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...prev, isCheckingAuth: false };
|
|
||||||
});
|
|
||||||
}, [meData, meError, meLoading]);
|
|
||||||
|
|
||||||
const login = useCallback(
|
|
||||||
async (username: string, password: string) => {
|
|
||||||
try {
|
|
||||||
const response = await loginMutation.mutateAsync({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200 || !('username' in response.data)) {
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: false,
|
|
||||||
});
|
|
||||||
throw new Error('Login failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: response.data as { username: string; is_admin: boolean },
|
|
||||||
isCheckingAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
|
||||||
navigate('/');
|
|
||||||
} catch (_error) {
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: false,
|
|
||||||
});
|
|
||||||
throw new Error('Login failed');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[loginMutation, navigate, queryClient]
|
|
||||||
);
|
|
||||||
|
|
||||||
const register = useCallback(
|
|
||||||
async (username: string, password: string) => {
|
|
||||||
try {
|
|
||||||
const response = await registerMutation.mutateAsync({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 201 || !('username' in response.data)) {
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: false,
|
|
||||||
});
|
|
||||||
throw new Error('Registration failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: response.data as { username: string; is_admin: boolean },
|
|
||||||
isCheckingAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
|
||||||
navigate('/');
|
|
||||||
} catch (_error) {
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: false,
|
|
||||||
});
|
|
||||||
throw new Error('Registration failed');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigate, queryClient, registerMutation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
|
||||||
logoutMutation.mutate(undefined, {
|
|
||||||
onSuccess: async () => {
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: false,
|
|
||||||
});
|
|
||||||
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
|
||||||
navigate('/login');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [logoutMutation, navigate, queryClient]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ ...authState, login, register, logout }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuth } from './AuthContext';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|
||||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Show loading while checking authentication status
|
|
||||||
if (isCheckingAuth) {
|
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
// Redirect to login with the current location saved
|
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const TOKEN_KEY = 'antholume_token';
|
|
||||||
|
|
||||||
// Request interceptor to add auth token to requests
|
|
||||||
axios.interceptors.request.use(
|
|
||||||
config => {
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
|
||||||
if (token && config.headers) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Response interceptor to handle auth errors
|
|
||||||
axios.interceptors.response.use(
|
|
||||||
response => {
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
// Clear token on auth failure
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
// Optionally redirect to login
|
|
||||||
// window.location.href = '/login';
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default axios;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react';
|
|
||||||
|
|
||||||
interface BaseButtonProps {
|
|
||||||
variant?: 'default' | 'secondary';
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
|
||||||
|
|
||||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
|
||||||
const baseClass =
|
|
||||||
'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white';
|
|
||||||
|
|
||||||
if (variant === 'secondary') {
|
|
||||||
return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
|
||||||
|
|
||||||
export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>(
|
|
||||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<a ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ButtonLink.displayName = 'ButtonLink';
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface FieldProps {
|
|
||||||
label: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
isEditing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
|
||||||
return (
|
|
||||||
<div className="relative rounded">
|
|
||||||
<div className="relative inline-flex gap-2 text-gray-500">{label}</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FieldLabelProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldLabel({ children }: FieldLabelProps) {
|
|
||||||
return <p>{children}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FieldValueProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldValue({ children, className = '' }: FieldValueProps) {
|
|
||||||
return <p className={`text-lg font-medium ${className}`}>{children}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FieldActionsProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldActions({ children }: FieldActionsProps) {
|
|
||||||
return <div className="inline-flex gap-2">{children}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
|
|
||||||
import { useAuth } from '../auth/AuthContext';
|
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
path: string;
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
|
|
||||||
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
|
|
||||||
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
|
|
||||||
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
|
|
||||||
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const adminSubItems: NavItem[] = [
|
|
||||||
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
|
|
||||||
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
|
|
||||||
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
|
|
||||||
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper function to check if pathname has a prefix
|
|
||||||
function hasPrefix(path: string, prefix: string): boolean {
|
|
||||||
return path.startsWith(prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HamburgerMenu() {
|
|
||||||
const location = useLocation();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const isAdmin = user?.is_admin ?? false;
|
|
||||||
|
|
||||||
// Fetch server info for version
|
|
||||||
const { data: infoData } = useGetInfo({
|
|
||||||
query: {
|
|
||||||
staleTime: Infinity, // Info doesn't change frequently
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const version =
|
|
||||||
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
|
|
||||||
? infoData.data.version
|
|
||||||
: 'v1.0.0';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative z-40 ml-6 flex flex-col">
|
|
||||||
{/* Checkbox input for state management */}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
|
|
||||||
id="mobile-nav-checkbox"
|
|
||||||
checked={isOpen}
|
|
||||||
onChange={e => setIsOpen(e.target.checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hamburger icon lines with CSS animations - hidden on desktop */}
|
|
||||||
<span
|
|
||||||
className="z-40 mt-0.5 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
|
|
||||||
style={{
|
|
||||||
transformOrigin: '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',
|
|
||||||
transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
|
|
||||||
style={{
|
|
||||||
transformOrigin: '0% 100%',
|
|
||||||
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',
|
|
||||||
opacity: isOpen ? 0 : 1,
|
|
||||||
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
|
|
||||||
style={{
|
|
||||||
transformOrigin: '0% 0%',
|
|
||||||
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',
|
|
||||||
transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Navigation menu with slide animation */}
|
|
||||||
<div
|
|
||||||
id="menu"
|
|
||||||
className="fixed -ml-6 h-full w-56 bg-white shadow-lg lg:w-48 dark:bg-gray-700"
|
|
||||||
style={{
|
|
||||||
top: 0,
|
|
||||||
paddingTop: 'env(safe-area-inset-top)',
|
|
||||||
transformOrigin: '0% 0%',
|
|
||||||
// On desktop (lg), always show the menu via CSS class
|
|
||||||
// On mobile, control via state
|
|
||||||
transform: isOpen ? 'none' : 'translate(-100%, 0)',
|
|
||||||
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Desktop override - always visible */}
|
|
||||||
<style>{`
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
#menu {
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div className="flex h-16 justify-end lg:justify-around">
|
|
||||||
<p className="my-auto pr-8 text-right text-xl font-bold lg:pr-0 dark:text-white">
|
|
||||||
AnthoLume
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<nav>
|
|
||||||
{navItems.map(item => (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
|
||||||
location.pathname === item.path
|
|
||||||
? 'border-purple-500 dark:text-white'
|
|
||||||
: 'border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon size={20} />
|
|
||||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Admin section - only visible for admins */}
|
|
||||||
{isAdmin && (
|
|
||||||
<div
|
|
||||||
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
|
||||||
hasPrefix(location.pathname, '/admin')
|
|
||||||
? 'border-purple-500 dark:text-white'
|
|
||||||
: 'border-transparent text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Admin header - always shown */}
|
|
||||||
<Link
|
|
||||||
to="/admin"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className={`flex w-full justify-start ${
|
|
||||||
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
|
|
||||||
? 'dark:text-white'
|
|
||||||
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SettingsIcon size={20} />
|
|
||||||
<span className="mx-4 text-sm font-normal">Admin</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{hasPrefix(location.pathname, '/admin') && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{adminSubItems.map(item => (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className={`flex w-full justify-start ${
|
|
||||||
location.pathname === item.path
|
|
||||||
? 'dark:text-white'
|
|
||||||
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
|
||||||
}`}
|
|
||||||
style={{ paddingLeft: '1.75em' }}
|
|
||||||
>
|
|
||||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
<a
|
|
||||||
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-black dark:text-white"
|
|
||||||
target="_blank"
|
|
||||||
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<GitIcon size={20} />
|
|
||||||
<span className="text-xs">{version}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
|
||||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { useAuth } from '../auth/AuthContext';
|
|
||||||
import { UserIcon, DropdownIcon } from '../icons';
|
|
||||||
import HamburgerMenu from './HamburgerMenu';
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
const location = useLocation();
|
|
||||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
|
||||||
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
|
||||||
const fetchedUser =
|
|
||||||
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
|
|
||||||
const userData = user ?? fetchedUser;
|
|
||||||
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
setIsUserDropdownOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsUserDropdownOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get current page title
|
|
||||||
const navItems = [
|
|
||||||
{ path: '/admin/import-results', title: 'Admin - Import' },
|
|
||||||
{ path: '/admin/import', title: 'Admin - Import' },
|
|
||||||
{ path: '/admin/users', title: 'Admin - Users' },
|
|
||||||
{ path: '/admin/logs', title: 'Admin - Logs' },
|
|
||||||
{ path: '/admin', title: 'Admin - General' },
|
|
||||||
{ path: '/documents', title: 'Documents' },
|
|
||||||
{ path: '/progress', title: 'Progress' },
|
|
||||||
{ path: '/activity', title: 'Activity' },
|
|
||||||
{ path: '/search', title: 'Search' },
|
|
||||||
{ path: '/settings', title: 'Settings' },
|
|
||||||
{ path: '/', title: 'Home' },
|
|
||||||
];
|
|
||||||
const currentPageTitle =
|
|
||||||
navItems.find(item =>
|
|
||||||
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
|
|
||||||
)?.title || 'Home';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
|
||||||
}, [currentPageTitle]);
|
|
||||||
|
|
||||||
// Show loading while checking authentication status
|
|
||||||
if (isCheckingAuth) {
|
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex h-16 w-full items-center justify-between">
|
|
||||||
{/* Mobile Navigation Button with CSS animations */}
|
|
||||||
<HamburgerMenu />
|
|
||||||
|
|
||||||
{/* Header Title */}
|
|
||||||
<h1 className="whitespace-nowrap px-6 text-xl font-bold lg:ml-44 dark:text-white">
|
|
||||||
{currentPageTitle}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* User Dropdown */}
|
|
||||||
<div
|
|
||||||
className="relative flex w-full items-center justify-end space-x-4 p-4"
|
|
||||||
ref={dropdownRef}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
|
||||||
className="relative block text-gray-800 dark:text-gray-200"
|
|
||||||
>
|
|
||||||
<UserIcon size={20} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isUserDropdownOpen && (
|
|
||||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
|
||||||
<div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-700 dark:shadow-gray-800">
|
|
||||||
<div
|
|
||||||
className="py-1"
|
|
||||||
role="menu"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-labelledby="options-menu"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to="/settings"
|
|
||||||
onClick={() => setIsUserDropdownOpen(false)}
|
|
||||||
className="block px-4 py-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
|
||||||
role="menuitem"
|
|
||||||
>
|
|
||||||
<span className="flex flex-col">
|
|
||||||
<span>Settings</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
|
||||||
role="menuitem"
|
|
||||||
>
|
|
||||||
<span className="flex flex-col">
|
|
||||||
<span>Logout</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
|
||||||
className="flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white"
|
|
||||||
>
|
|
||||||
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
|
|
||||||
<span
|
|
||||||
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
|
|
||||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
||||||
>
|
|
||||||
<DropdownIcon size={20} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main
|
|
||||||
className="relative overflow-hidden"
|
|
||||||
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id="container"
|
|
||||||
className="h-dvh overflow-auto px-4 md:px-6 lg:ml-48"
|
|
||||||
style={{ paddingBottom: 'calc(5em + env(safe-area-inset-bottom) * 2)' }}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# UI Components
|
|
||||||
|
|
||||||
This directory contains reusable UI components for the AnthoLume application.
|
|
||||||
|
|
||||||
## Toast Notifications
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
The toast system provides info, warning, and error notifications that respect the current theme and dark/light mode.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useToasts } from './components/ToastContext';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { showInfo, showWarning, showError, showToast } = useToasts();
|
|
||||||
|
|
||||||
const handleAction = async () => {
|
|
||||||
try {
|
|
||||||
// Do something
|
|
||||||
showInfo('Operation completed successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
showError('An error occurred while processing your request.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return <button onClick={handleAction}>Click me</button>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
- `showToast(message: string, type?: 'info' | 'warning' | 'error', duration?: number): string`
|
|
||||||
- Shows a toast notification
|
|
||||||
- Returns the toast ID for manual removal
|
|
||||||
- Default type: 'info'
|
|
||||||
- Default duration: 5000ms (0 = no auto-dismiss)
|
|
||||||
|
|
||||||
- `showInfo(message: string, duration?: number): string`
|
|
||||||
- Shortcut for showing an info toast
|
|
||||||
|
|
||||||
- `showWarning(message: string, duration?: number): string`
|
|
||||||
- Shortcut for showing a warning toast
|
|
||||||
|
|
||||||
- `showError(message: string, duration?: number): string`
|
|
||||||
- Shortcut for showing an error toast
|
|
||||||
|
|
||||||
- `removeToast(id: string): void`
|
|
||||||
- Manually remove a toast by ID
|
|
||||||
|
|
||||||
- `clearToasts(): void`
|
|
||||||
- Clear all active toasts
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Info toast (auto-dismisses after 5 seconds)
|
|
||||||
showInfo('Document saved successfully!');
|
|
||||||
|
|
||||||
// Warning toast (auto-dismisses after 10 seconds)
|
|
||||||
showWarning('Low disk space warning', 10000);
|
|
||||||
|
|
||||||
// Error toast (no auto-dismiss)
|
|
||||||
showError('Failed to load data', 0);
|
|
||||||
|
|
||||||
// Generic toast
|
|
||||||
showToast('Custom message', 'warning', 3000);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Skeleton Loading
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
Skeleton components provide placeholder content while data is loading. They automatically adapt to dark/light mode.
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
#### `Skeleton`
|
|
||||||
|
|
||||||
Basic skeleton element with various variants:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Skeleton } from './components/Skeleton';
|
|
||||||
|
|
||||||
// Default (rounded rectangle)
|
|
||||||
<Skeleton className="w-full h-8" />
|
|
||||||
|
|
||||||
// Text variant
|
|
||||||
<Skeleton variant="text" className="w-3/4" />
|
|
||||||
|
|
||||||
// Circular variant (for avatars)
|
|
||||||
<Skeleton variant="circular" width={40} height={40} />
|
|
||||||
|
|
||||||
// Rectangular variant
|
|
||||||
<Skeleton variant="rectangular" width="100%" height={200} />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `SkeletonText`
|
|
||||||
|
|
||||||
Multiple lines of text skeleton:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<SkeletonText lines={3} />
|
|
||||||
<SkeletonText lines={5} className="max-w-md" />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `SkeletonAvatar`
|
|
||||||
|
|
||||||
Avatar placeholder:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<SkeletonAvatar size="md" />
|
|
||||||
<SkeletonAvatar size={56} />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `SkeletonCard`
|
|
||||||
|
|
||||||
Card placeholder with optional elements:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Default card
|
|
||||||
<SkeletonCard />
|
|
||||||
|
|
||||||
// With avatar
|
|
||||||
<SkeletonCard showAvatar />
|
|
||||||
|
|
||||||
// Custom configuration
|
|
||||||
<SkeletonCard
|
|
||||||
showAvatar
|
|
||||||
showTitle
|
|
||||||
showText
|
|
||||||
textLines={4}
|
|
||||||
className="max-w-sm"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `SkeletonTable`
|
|
||||||
|
|
||||||
Table placeholder:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<SkeletonTable rows={5} columns={4} />
|
|
||||||
<SkeletonTable rows={10} columns={6} showHeader={false} />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `SkeletonButton`
|
|
||||||
|
|
||||||
Button placeholder:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<SkeletonButton width={120} />
|
|
||||||
<SkeletonButton className="w-full" />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `PageLoader`
|
|
||||||
|
|
||||||
Full-page loading indicator:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<PageLoader message="Loading your documents..." />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `InlineLoader`
|
|
||||||
|
|
||||||
Small inline loading spinner:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<InlineLoader size="sm" />
|
|
||||||
<InlineLoader size="md" />
|
|
||||||
<InlineLoader size="lg" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Table Component
|
|
||||||
|
|
||||||
The Table component now supports skeleton loading:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Table, SkeletonTable } from './components/Table';
|
|
||||||
|
|
||||||
function DocumentList() {
|
|
||||||
const { data, isLoading } = useGetDocuments();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <SkeletonTable rows={10} columns={5} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Table columns={columns} data={data?.documents || []} />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Theme Support
|
|
||||||
|
|
||||||
All components automatically adapt to the current theme:
|
|
||||||
|
|
||||||
- **Light mode**: Uses gray tones for skeletons, appropriate colors for toasts
|
|
||||||
- **Dark mode**: Uses darker gray tones for skeletons, adjusted colors for toasts
|
|
||||||
|
|
||||||
The theme is controlled via Tailwind's `dark:` classes, which respond to the system preference or manual theme toggles.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- `clsx` - Utility for constructing className strings
|
|
||||||
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
|
|
||||||
- `lucide-react` - Icon library used by Toast component
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { getSVGGraphData } from './ReadingHistoryGraph';
|
|
||||||
|
|
||||||
// Test data matching Go test exactly
|
|
||||||
const testInput = [
|
|
||||||
{ date: '2024-01-01', minutes_read: 10 },
|
|
||||||
{ date: '2024-01-02', minutes_read: 90 },
|
|
||||||
{ date: '2024-01-03', minutes_read: 50 },
|
|
||||||
{ date: '2024-01-04', minutes_read: 5 },
|
|
||||||
{ date: '2024-01-05', minutes_read: 10 },
|
|
||||||
{ date: '2024-01-06', minutes_read: 5 },
|
|
||||||
{ date: '2024-01-07', minutes_read: 70 },
|
|
||||||
{ date: '2024-01-08', minutes_read: 60 },
|
|
||||||
{ date: '2024-01-09', minutes_read: 50 },
|
|
||||||
{ date: '2024-01-10', minutes_read: 90 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const svgWidth = 500;
|
|
||||||
const svgHeight = 100;
|
|
||||||
|
|
||||||
describe('ReadingHistoryGraph', () => {
|
|
||||||
describe('getSVGGraphData', () => {
|
|
||||||
it('should match exactly', () => {
|
|
||||||
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
|
|
||||||
|
|
||||||
// Expected values from Go test
|
|
||||||
const expectedBezierPath =
|
|
||||||
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
|
|
||||||
const expectedBezierFill = 'L 500,98 L 50,98 Z';
|
|
||||||
const expectedWidth = 500;
|
|
||||||
const expectedHeight = 100;
|
|
||||||
const expectedOffset = 50;
|
|
||||||
|
|
||||||
expect(result.BezierPath).toBe(expectedBezierPath);
|
|
||||||
expect(result.BezierFill).toBe(expectedBezierFill);
|
|
||||||
expect(svgWidth).toBe(expectedWidth);
|
|
||||||
expect(svgHeight).toBe(expectedHeight);
|
|
||||||
expect(result.Offset).toBe(expectedOffset);
|
|
||||||
|
|
||||||
// Verify line points are integers like Go
|
|
||||||
result.LinePoints.forEach((p, _i) => {
|
|
||||||
expect(Number.isInteger(p.x)).toBe(true);
|
|
||||||
expect(Number.isInteger(p.y)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expected line points from Go calculation:
|
|
||||||
// idx 0: itemSize=5, itemY=95, lineX=50
|
|
||||||
// idx 1: itemSize=45, itemY=55, lineX=100
|
|
||||||
// idx 2: itemSize=25, itemY=75, lineX=150
|
|
||||||
// ...and so on
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
import type { GraphDataPoint } from '../generated/model';
|
|
||||||
|
|
||||||
interface ReadingHistoryGraphProps {
|
|
||||||
data: GraphDataPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SVGPoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates bezier control points for smooth curves
|
|
||||||
*/
|
|
||||||
function getSVGBezierOpposedLine(
|
|
||||||
pointA: SVGPoint,
|
|
||||||
pointB: SVGPoint
|
|
||||||
): { Length: number; Angle: number } {
|
|
||||||
const lengthX = pointB.x - pointA.x;
|
|
||||||
const lengthY = pointB.y - pointA.y;
|
|
||||||
|
|
||||||
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
|
|
||||||
return {
|
|
||||||
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
|
|
||||||
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBezierControlPoint(
|
|
||||||
currentPoint: SVGPoint,
|
|
||||||
prevPoint: SVGPoint | null,
|
|
||||||
nextPoint: SVGPoint | null,
|
|
||||||
isReverse: boolean
|
|
||||||
): SVGPoint {
|
|
||||||
// First / Last Point
|
|
||||||
let pPrev = prevPoint;
|
|
||||||
let pNext = nextPoint;
|
|
||||||
if (!pPrev) {
|
|
||||||
pPrev = currentPoint;
|
|
||||||
}
|
|
||||||
if (!pNext) {
|
|
||||||
pNext = currentPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modifiers
|
|
||||||
const smoothingRatio: number = 0.2;
|
|
||||||
const directionModifier: number = isReverse ? Math.PI : 0;
|
|
||||||
|
|
||||||
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
|
|
||||||
const lineAngle: number = opposingLine.Angle + directionModifier;
|
|
||||||
const lineLength: number = opposingLine.Length * smoothingRatio;
|
|
||||||
|
|
||||||
// Calculate Control Point - Go converts everything to int
|
|
||||||
// Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
|
|
||||||
return {
|
|
||||||
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
|
|
||||||
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the bezier path for the graph
|
|
||||||
*/
|
|
||||||
function getSVGBezierPath(points: SVGPoint[]): string {
|
|
||||||
if (points.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
let bezierSVGPath: string = '';
|
|
||||||
|
|
||||||
for (let index = 0; index < points.length; index++) {
|
|
||||||
const point = points[index];
|
|
||||||
if (index === 0) {
|
|
||||||
bezierSVGPath += `M ${point.x},${point.y}`;
|
|
||||||
} else {
|
|
||||||
const pointPlusOne = points[index + 1];
|
|
||||||
const pointMinusOne = points[index - 1];
|
|
||||||
const pointMinusTwo: SVGPoint | null = index - 2 >= 0 ? points[index - 2] : null;
|
|
||||||
|
|
||||||
const startControlPoint: SVGPoint = getBezierControlPoint(
|
|
||||||
pointMinusOne,
|
|
||||||
pointMinusTwo,
|
|
||||||
point,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
const endControlPoint: SVGPoint = getBezierControlPoint(
|
|
||||||
point,
|
|
||||||
pointMinusOne,
|
|
||||||
pointPlusOne || point,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Go converts all coordinates to int
|
|
||||||
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bezierSVGPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SVGGraphData {
|
|
||||||
LinePoints: SVGPoint[];
|
|
||||||
BezierPath: string;
|
|
||||||
BezierFill: string;
|
|
||||||
Offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SVG Graph Data
|
|
||||||
*/
|
|
||||||
export function getSVGGraphData(
|
|
||||||
inputData: GraphDataPoint[],
|
|
||||||
svgWidth: number,
|
|
||||||
svgHeight: number
|
|
||||||
): SVGGraphData {
|
|
||||||
// Derive Height
|
|
||||||
let maxHeight: number = 0;
|
|
||||||
for (const item of inputData) {
|
|
||||||
if (item.minutes_read > maxHeight) {
|
|
||||||
maxHeight = item.minutes_read;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical Graph Real Estate
|
|
||||||
const sizePercentage: number = 0.5;
|
|
||||||
|
|
||||||
// Scale Ratio -> Desired Height
|
|
||||||
const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
|
|
||||||
|
|
||||||
// Point Block Offset
|
|
||||||
const blockOffset: number = Math.floor(svgWidth / inputData.length);
|
|
||||||
|
|
||||||
// Line & Bar Points
|
|
||||||
const linePoints: SVGPoint[] = [];
|
|
||||||
|
|
||||||
// Bezier Fill Coordinates (Max X, Min X, Max Y)
|
|
||||||
let maxBX: number = 0;
|
|
||||||
let maxBY: number = 0;
|
|
||||||
let minBX: number = 0;
|
|
||||||
|
|
||||||
for (let idx = 0; idx < inputData.length; idx++) {
|
|
||||||
// Go uses int conversion
|
|
||||||
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
|
|
||||||
const itemY = svgHeight - itemSize;
|
|
||||||
const lineX = (idx + 1) * blockOffset;
|
|
||||||
|
|
||||||
linePoints.push({
|
|
||||||
x: lineX,
|
|
||||||
y: itemY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lineX > maxBX) {
|
|
||||||
maxBX = lineX;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineX < minBX) {
|
|
||||||
minBX = lineX;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemY > maxBY) {
|
|
||||||
maxBY = itemY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return Data
|
|
||||||
return {
|
|
||||||
LinePoints: linePoints,
|
|
||||||
BezierPath: getSVGBezierPath(linePoints),
|
|
||||||
BezierFill: `L ${Math.floor(maxBX)},${Math.floor(maxBY)} L ${Math.floor(minBX + blockOffset)},${Math.floor(maxBY)} Z`,
|
|
||||||
Offset: blockOffset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a date string to YYYY-MM-DD format (ISO-like)
|
|
||||||
* Note: The date string from the API is already in YYYY-MM-DD format,
|
|
||||||
* but since JavaScript Date parsing can add timezone offsets, we use UTC
|
|
||||||
* methods to ensure we get the correct date.
|
|
||||||
*/
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
// Use UTC methods to avoid timezone offset issues
|
|
||||||
const year = date.getUTCFullYear();
|
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ReadingHistoryGraph component
|
|
||||||
*
|
|
||||||
* Displays a bezier curve graph of daily reading totals with hover tooltips.
|
|
||||||
* Exact copy of Go template implementation.
|
|
||||||
*/
|
|
||||||
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
|
||||||
const svgWidth = 800;
|
|
||||||
const svgHeight = 70;
|
|
||||||
|
|
||||||
if (!data || data.length < 2) {
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
|
||||||
<p className="text-gray-400 dark:text-gray-300">No data available</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
BezierPath,
|
|
||||||
BezierFill,
|
|
||||||
LinePoints: _linePoints,
|
|
||||||
} = getSVGGraphData(data, svgWidth, svgHeight);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
|
||||||
<path fill="#316BBE" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
|
||||||
<path fill="none" stroke="#316BBE" d={BezierPath} />
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 flex size-full"
|
|
||||||
style={{
|
|
||||||
width: 'calc(100% * 31 / 30)',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
left: '50%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.map((point, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
onClick
|
|
||||||
className="w-full opacity-0 hover:opacity-100"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute top-3 flex flex-col items-center rounded p-2 text-xs dark:text-white"
|
|
||||||
style={{
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
left: '50%',
|
|
||||||
backgroundColor: 'rgba(128, 128, 128, 0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{formatDate(point.date)}</span>
|
|
||||||
<span>{point.minutes_read} minutes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
interface SkeletonProps {
|
|
||||||
className?: string;
|
|
||||||
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
animation?: 'pulse' | 'wave' | 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Skeleton({
|
|
||||||
className = '',
|
|
||||||
variant = 'default',
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
animation = 'pulse',
|
|
||||||
}: SkeletonProps) {
|
|
||||||
const baseClasses = 'bg-gray-200 dark:bg-gray-600';
|
|
||||||
|
|
||||||
const variantClasses = {
|
|
||||||
default: 'rounded',
|
|
||||||
text: 'rounded-md h-4',
|
|
||||||
circular: 'rounded-full',
|
|
||||||
rectangular: 'rounded-none',
|
|
||||||
};
|
|
||||||
|
|
||||||
const animationClasses = {
|
|
||||||
pulse: 'animate-pulse',
|
|
||||||
wave: 'animate-wave',
|
|
||||||
none: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
|
|
||||||
height:
|
|
||||||
height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkeletonTextProps {
|
|
||||||
lines?: number;
|
|
||||||
className?: string;
|
|
||||||
lineClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-2', className)}>
|
|
||||||
{Array.from({ length: lines }).map((_, i) => (
|
|
||||||
<Skeleton
|
|
||||||
key={i}
|
|
||||||
variant="text"
|
|
||||||
className={cn(lineClassName, i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkeletonAvatarProps {
|
|
||||||
size?: number | 'sm' | 'md' | 'lg';
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
|
|
||||||
const sizeMap = {
|
|
||||||
sm: 32,
|
|
||||||
md: 40,
|
|
||||||
lg: 56,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
|
|
||||||
|
|
||||||
return <Skeleton variant="circular" width={pixelSize} height={pixelSize} className={className} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkeletonCardProps {
|
|
||||||
className?: string;
|
|
||||||
showAvatar?: boolean;
|
|
||||||
showTitle?: boolean;
|
|
||||||
showText?: boolean;
|
|
||||||
textLines?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonCard({
|
|
||||||
className = '',
|
|
||||||
showAvatar = false,
|
|
||||||
showTitle = true,
|
|
||||||
showText = true,
|
|
||||||
textLines = 3,
|
|
||||||
}: SkeletonCardProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showAvatar && (
|
|
||||||
<div className="mb-4 flex items-start gap-4">
|
|
||||||
<SkeletonAvatar />
|
|
||||||
<div className="flex-1">
|
|
||||||
<Skeleton variant="text" className="mb-2 w-3/4" />
|
|
||||||
<Skeleton variant="text" className="w-1/2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showTitle && <Skeleton variant="text" className="mb-4 h-6 w-1/2" />}
|
|
||||||
{showText && <SkeletonText lines={textLines} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkeletonTableProps {
|
|
||||||
rows?: number;
|
|
||||||
columns?: number;
|
|
||||||
className?: string;
|
|
||||||
showHeader?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonTable({
|
|
||||||
rows = 5,
|
|
||||||
columns = 4,
|
|
||||||
className = '',
|
|
||||||
showHeader = true,
|
|
||||||
}: SkeletonTableProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
|
|
||||||
<table className="min-w-full">
|
|
||||||
{showHeader && (
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b dark:border-gray-600">
|
|
||||||
{Array.from({ length: columns }).map((_, i) => (
|
|
||||||
<th key={i} className="p-3">
|
|
||||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
)}
|
|
||||||
<tbody>
|
|
||||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
|
||||||
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
|
|
||||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
|
||||||
<td key={colIndex} className="p-3">
|
|
||||||
<Skeleton
|
|
||||||
variant="text"
|
|
||||||
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkeletonButtonProps {
|
|
||||||
className?: string;
|
|
||||||
width?: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
|
|
||||||
return (
|
|
||||||
<Skeleton
|
|
||||||
variant="rectangular"
|
|
||||||
height={36}
|
|
||||||
width={width || '100%'}
|
|
||||||
className={cn('rounded', className)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageLoaderProps {
|
|
||||||
message?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col items-center justify-center min-h-[400px] gap-4', className)}>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="size-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500 dark:border-gray-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{message}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InlineLoaderProps {
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
|
|
||||||
const sizeMap = {
|
|
||||||
sm: 'w-4 h-4 border-2',
|
|
||||||
md: 'w-6 h-6 border-3',
|
|
||||||
lg: 'w-8 h-8 border-4',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex items-center justify-center', className)}>
|
|
||||||
<div
|
|
||||||
className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-blue-500 dark:border-gray-600`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export SkeletonTable for backward compatibility
|
|
||||||
export { SkeletonTable as SkeletonTableExport };
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Skeleton } from './Skeleton';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
export interface Column<T extends Record<string, unknown>> {
|
|
||||||
key: keyof T;
|
|
||||||
header: string;
|
|
||||||
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TableProps<T extends Record<string, unknown>> {
|
|
||||||
columns: Column<T>[];
|
|
||||||
data: T[];
|
|
||||||
loading?: boolean;
|
|
||||||
emptyMessage?: string;
|
|
||||||
rowKey?: keyof T | ((row: T) => string);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skeleton table component for loading state
|
|
||||||
function SkeletonTable({
|
|
||||||
rows = 5,
|
|
||||||
columns = 4,
|
|
||||||
className = '',
|
|
||||||
}: {
|
|
||||||
rows?: number;
|
|
||||||
columns?: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
|
|
||||||
<table className="min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b dark:border-gray-600">
|
|
||||||
{Array.from({ length: columns }).map((_, i) => (
|
|
||||||
<th key={i} className="p-3">
|
|
||||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
|
||||||
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
|
|
||||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
|
||||||
<td key={colIndex} className="p-3">
|
|
||||||
<Skeleton
|
|
||||||
variant="text"
|
|
||||||
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Table<T extends Record<string, unknown>>({
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
loading = false,
|
|
||||||
emptyMessage = 'No Results',
|
|
||||||
rowKey,
|
|
||||||
}: TableProps<T>) {
|
|
||||||
const getRowKey = (_row: T, index: number): string => {
|
|
||||||
if (typeof rowKey === 'function') {
|
|
||||||
return rowKey(_row);
|
|
||||||
}
|
|
||||||
if (rowKey) {
|
|
||||||
return String(_row[rowKey] ?? index);
|
|
||||||
}
|
|
||||||
return `row-${index}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <SkeletonTable rows={5} columns={columns.length} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
|
||||||
<table className="min-w-full bg-white dark:bg-gray-700">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b dark:border-gray-600">
|
|
||||||
{columns.map(column => (
|
|
||||||
<th
|
|
||||||
key={String(column.key)}
|
|
||||||
className={`p-3 text-left text-gray-500 dark:text-white ${column.className || ''}`}
|
|
||||||
>
|
|
||||||
{column.header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="p-3 text-center text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
{emptyMessage}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
data.map((row, index) => (
|
|
||||||
<tr key={getRowKey(row, index)} className="border-b dark:border-gray-600">
|
|
||||||
{columns.map(column => (
|
|
||||||
<td
|
|
||||||
key={`${getRowKey(row, index)}-${String(column.key)}`}
|
|
||||||
className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`}
|
|
||||||
>
|
|
||||||
{column.render ? column.render(row[column.key], row, index) : row[column.key]}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons';
|
|
||||||
|
|
||||||
export type ToastType = 'info' | 'warning' | 'error';
|
|
||||||
|
|
||||||
export interface ToastProps {
|
|
||||||
id: string;
|
|
||||||
type: ToastType;
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
|
||||||
onClose?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getToastStyles = (_type: ToastType) => {
|
|
||||||
const baseStyles =
|
|
||||||
'flex items-center gap-3 p-4 rounded-lg shadow-lg border-l-4 transition-all duration-300';
|
|
||||||
|
|
||||||
const typeStyles = {
|
|
||||||
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400',
|
|
||||||
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-500 dark:border-yellow-400',
|
|
||||||
error: 'bg-red-50 dark:bg-red-900/30 border-red-500 dark:border-red-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconStyles = {
|
|
||||||
info: 'text-blue-600 dark:text-blue-400',
|
|
||||||
warning: 'text-yellow-600 dark:text-yellow-400',
|
|
||||||
error: 'text-red-600 dark:text-red-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const textStyles = {
|
|
||||||
info: 'text-blue-800 dark:text-blue-200',
|
|
||||||
warning: 'text-yellow-800 dark:text-yellow-200',
|
|
||||||
error: 'text-red-800 dark:text-red-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
return { baseStyles, typeStyles, iconStyles, textStyles };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
|
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
|
||||||
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
|
||||||
|
|
||||||
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsAnimatingOut(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsVisible(false);
|
|
||||||
onClose?.(id);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (duration > 0) {
|
|
||||||
const timer = setTimeout(handleClose, duration);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [duration]);
|
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
info: <InfoIcon size={20} className={iconStyles[type]} />,
|
|
||||||
warning: <WarningIcon size={20} className={iconStyles[type]} />,
|
|
||||||
error: <ErrorIcon size={20} className={iconStyles[type]} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${baseStyles} ${typeStyles[type]} ${
|
|
||||||
isAnimatingOut ? 'translate-x-full opacity-0' : 'animate-slideInRight opacity-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{icons[type]}
|
|
||||||
<p className={`flex-1 text-sm font-medium ${textStyles[type]}`}>{message}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<CloseIcon size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
|
||||||
import { Toast, ToastType, ToastProps } from './Toast';
|
|
||||||
|
|
||||||
interface ToastContextType {
|
|
||||||
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
|
||||||
showInfo: (message: string, duration?: number) => string;
|
|
||||||
showWarning: (message: string, duration?: number) => string;
|
|
||||||
showError: (message: string, duration?: number) => string;
|
|
||||||
removeToast: (id: string) => void;
|
|
||||||
clearToasts: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]);
|
|
||||||
|
|
||||||
const removeToast = useCallback((id: string) => {
|
|
||||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showToast = useCallback(
|
|
||||||
(message: string, _type: ToastType = 'info', _duration?: number): string => {
|
|
||||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
setToasts(prev => [
|
|
||||||
...prev,
|
|
||||||
{ id, type: _type, message, duration: _duration, onClose: removeToast },
|
|
||||||
]);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
[removeToast]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showInfo = useCallback(
|
|
||||||
(message: string, _duration?: number) => {
|
|
||||||
return showToast(message, 'info', _duration);
|
|
||||||
},
|
|
||||||
[showToast]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showWarning = useCallback(
|
|
||||||
(message: string, _duration?: number) => {
|
|
||||||
return showToast(message, 'warning', _duration);
|
|
||||||
},
|
|
||||||
[showToast]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showError = useCallback(
|
|
||||||
(message: string, _duration?: number) => {
|
|
||||||
return showToast(message, 'error', _duration);
|
|
||||||
},
|
|
||||||
[showToast]
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearToasts = useCallback(() => {
|
|
||||||
setToasts([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider
|
|
||||||
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ToastContainer toasts={toasts} />
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastContainerProps {
|
|
||||||
toasts: (ToastProps & { id: string })[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToastContainer({ toasts }: ToastContainerProps) {
|
|
||||||
if (toasts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
|
|
||||||
{toasts.map(toast => (
|
|
||||||
<div key={toast.id} className="pointer-events-auto">
|
|
||||||
<Toast {...toast} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToasts() {
|
|
||||||
const context = useContext(ToastContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useToasts must be used within a ToastProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// Reading History Graph
|
|
||||||
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
|
|
||||||
|
|
||||||
// Toast components
|
|
||||||
export { Toast } from './Toast';
|
|
||||||
export { ToastProvider, useToasts } from './ToastContext';
|
|
||||||
export type { ToastType, ToastProps } from './Toast';
|
|
||||||
|
|
||||||
// Skeleton components
|
|
||||||
export {
|
|
||||||
Skeleton,
|
|
||||||
SkeletonText,
|
|
||||||
SkeletonAvatar,
|
|
||||||
SkeletonCard,
|
|
||||||
SkeletonTable,
|
|
||||||
SkeletonButton,
|
|
||||||
PageLoader,
|
|
||||||
InlineLoader,
|
|
||||||
} from './Skeleton';
|
|
||||||
|
|
||||||
// Field components
|
|
||||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Activity {
|
|
||||||
document_id: string;
|
|
||||||
device_id: string;
|
|
||||||
start_time: string;
|
|
||||||
title?: string;
|
|
||||||
author?: string;
|
|
||||||
duration: number;
|
|
||||||
start_percentage: number;
|
|
||||||
end_percentage: number;
|
|
||||||
read_percentage: number;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { Activity } from './activity';
|
|
||||||
|
|
||||||
export interface ActivityResponse {
|
|
||||||
activities: Activity[];
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type BackupType = typeof BackupType[keyof typeof BackupType];
|
|
||||||
|
|
||||||
|
|
||||||
export const BackupType = {
|
|
||||||
COVERS: 'COVERS',
|
|
||||||
DOCUMENTS: 'DOCUMENTS',
|
|
||||||
} as const;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ConfigResponse {
|
|
||||||
version: string;
|
|
||||||
search_enabled: boolean;
|
|
||||||
registration_enabled: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type CreateDocumentBody = {
|
|
||||||
document_file: Blob;
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DatabaseInfo {
|
|
||||||
documents_size: number;
|
|
||||||
activity_size: number;
|
|
||||||
progress_size: number;
|
|
||||||
devices_size: number;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Device {
|
|
||||||
id?: string;
|
|
||||||
device_name?: string;
|
|
||||||
created_at?: string;
|
|
||||||
last_synced?: string;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DirectoryItem {
|
|
||||||
name?: string;
|
|
||||||
path?: string;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { DirectoryItem } from './directoryItem';
|
|
||||||
|
|
||||||
export interface DirectoryListResponse {
|
|
||||||
current_path?: string;
|
|
||||||
items?: DirectoryItem[];
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Document {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
description?: string;
|
|
||||||
isbn10?: string;
|
|
||||||
isbn13?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
deleted: boolean;
|
|
||||||
words?: number;
|
|
||||||
filepath?: string;
|
|
||||||
percentage?: number;
|
|
||||||
total_time_seconds?: number;
|
|
||||||
wpm?: number;
|
|
||||||
seconds_per_percent?: number;
|
|
||||||
last_read?: string;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { Document } from './document';
|
|
||||||
import type { Progress } from './progress';
|
|
||||||
|
|
||||||
export interface DocumentResponse {
|
|
||||||
document: Document;
|
|
||||||
progress?: Progress;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { Document } from './document';
|
|
||||||
import type { UserData } from './userData';
|
|
||||||
import type { WordCount } from './wordCount';
|
|
||||||
|
|
||||||
export interface DocumentsResponse {
|
|
||||||
documents: Document[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
next_page?: number;
|
|
||||||
previous_page?: number;
|
|
||||||
search?: string;
|
|
||||||
user: UserData;
|
|
||||||
word_counts: WordCount[];
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type EditDocumentBody = {
|
|
||||||
title?: string;
|
|
||||||
author?: string;
|
|
||||||
description?: string;
|
|
||||||
isbn10?: string;
|
|
||||||
isbn13?: string;
|
|
||||||
cover_gbid?: string;
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ErrorResponse {
|
|
||||||
code: number;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type GetActivityParams = {
|
|
||||||
doc_filter?: boolean;
|
|
||||||
document_id?: string;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { DatabaseInfo } from './databaseInfo';
|
|
||||||
|
|
||||||
export type GetAdmin200 = {
|
|
||||||
database_info?: DatabaseInfo;
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type GetDocumentsParams = {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
search?: string;
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type GetImportDirectoryParams = {
|
|
||||||
directory?: string;
|
|
||||||
select?: string;
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type GetLogsParams = {
|
|
||||||
filter?: string;
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type GetProgressListParams = {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
document?: string;
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { GetSearchSource } from './getSearchSource';
|
|
||||||
|
|
||||||
export type GetSearchParams = {
|
|
||||||
query: string;
|
|
||||||
source: GetSearchSource;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type GetSearchSource = typeof GetSearchSource[keyof typeof GetSearchSource];
|
|
||||||
|
|
||||||
|
|
||||||
export const GetSearchSource = {
|
|
||||||
LibGen: 'LibGen',
|
|
||||||
Annas_Archive: 'Annas Archive',
|
|
||||||
} as const;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface GraphDataPoint {
|
|
||||||
date: string;
|
|
||||||
minutes_read: number;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { GraphDataPoint } from './graphDataPoint';
|
|
||||||
|
|
||||||
export interface GraphDataResponse {
|
|
||||||
graph_data: GraphDataPoint[];
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { DatabaseInfo } from './databaseInfo';
|
|
||||||
import type { GraphDataResponse } from './graphDataResponse';
|
|
||||||
import type { StreaksResponse } from './streaksResponse';
|
|
||||||
import type { UserStatisticsResponse } from './userStatisticsResponse';
|
|
||||||
|
|
||||||
export interface HomeResponse {
|
|
||||||
database_info: DatabaseInfo;
|
|
||||||
streaks: StreaksResponse;
|
|
||||||
graph_data: GraphDataResponse;
|
|
||||||
user_statistics: UserStatisticsResponse;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
import type { ImportResultStatus } from './importResultStatus';
|
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
path?: string;
|
|
||||||
status?: ImportResultStatus;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* AnthoLume API v1
|
|
||||||
* REST API for AnthoLume document management system
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ImportResultStatus = typeof ImportResultStatus[keyof typeof ImportResultStatus];
|
|
||||||
|
|
||||||
|
|
||||||
export const ImportResultStatus = {
|
|
||||||
FAILED: 'FAILED',
|
|
||||||
SUCCESS: 'SUCCESS',
|
|
||||||
EXISTS: 'EXISTS',
|
|
||||||
} as const;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user