[fix] map concurrency issue, [add] better logging, [add] activity template, [fix] safari redirect issue, [add] timezone framework
This commit is contained in:
parent
240b3a2b67
commit
f2163c8fd9
@ -80,13 +80,12 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
|
|
||||||
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
|
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
|
||||||
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
||||||
|
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
|
||||||
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile)
|
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile)
|
||||||
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
||||||
|
|
||||||
// TODO
|
// TODO
|
||||||
api.Router.GET("/activity", api.authWebAppMiddleware, baseResourceRoute("activity"))
|
|
||||||
api.Router.GET("/graphs", api.authWebAppMiddleware, baseResourceRoute("graphs"))
|
api.Router.GET("/graphs", api.authWebAppMiddleware, baseResourceRoute("graphs"))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
|
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
|
||||||
|
@ -27,17 +27,25 @@ func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Cont
|
|||||||
|
|
||||||
func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) {
|
func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) {
|
||||||
// Merge Optional Template Data
|
// Merge Optional Template Data
|
||||||
var templateVars = gin.H{}
|
var templateVarsBase = gin.H{}
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
templateVars = args[0]
|
templateVarsBase = args[0]
|
||||||
}
|
}
|
||||||
templateVars["RouteName"] = routeName
|
templateVarsBase["RouteName"] = routeName
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
rUser, _ := c.Get("AuthorizedUser")
|
||||||
qParams := bindQueryParams(c)
|
|
||||||
|
// Copy Base & Update
|
||||||
|
templateVars := gin.H{}
|
||||||
|
for k, v := range templateVarsBase {
|
||||||
|
templateVars[k] = v
|
||||||
|
}
|
||||||
templateVars["User"] = rUser
|
templateVars["User"] = rUser
|
||||||
|
|
||||||
|
// Potential URL Parameters
|
||||||
|
qParams := bindQueryParams(c)
|
||||||
|
|
||||||
if routeName == "documents" {
|
if routeName == "documents" {
|
||||||
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
|
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
@ -45,28 +53,44 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Info(err)
|
log.Error("[createAppResourcesRoute] GetDocumentsWithStats DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
templateVars["Data"] = documents
|
templateVars["Data"] = documents
|
||||||
|
} else if routeName == "activity" {
|
||||||
|
activity, err := api.DB.Queries.GetActivity(api.DB.Ctx, database.GetActivityParams{
|
||||||
|
UserID: rUser.(string),
|
||||||
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
|
Limit: *qParams.Limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[createAppResourcesRoute] GetActivity DB Error:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = activity
|
||||||
} else if routeName == "home" {
|
} else if routeName == "home" {
|
||||||
weekly_streak, _ := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
|
weekly_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
Window: "WEEK",
|
Window: "WEEK",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err)
|
||||||
|
}
|
||||||
|
|
||||||
daily_streak, _ := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
|
daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
Window: "DAY",
|
Window: "DAY",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err)
|
||||||
|
}
|
||||||
|
|
||||||
database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string))
|
database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string))
|
||||||
read_graph_data, err := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string))
|
read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string))
|
||||||
if err != nil {
|
|
||||||
log.Info("HMMMM:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
templateVars["Data"] = gin.H{
|
templateVars["Data"] = gin.H{
|
||||||
"DailyStreak": daily_streak,
|
"DailyStreak": daily_streak,
|
||||||
@ -85,6 +109,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
func (api *API) getDocumentCover(c *gin.Context) {
|
func (api *API) getDocumentCover(c *gin.Context) {
|
||||||
var rDoc requestDocumentID
|
var rDoc requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDoc); err != nil {
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
|
log.Error("[getDocumentCover] Invalid URI Bind")
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -92,6 +117,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
// Validate Document Exists in DB
|
// Validate Document Exists in DB
|
||||||
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[getDocumentCover] GetDocument DB Error:", err)
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -99,7 +125,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
// Handle Identified Document
|
// Handle Identified Document
|
||||||
if document.Olid != nil {
|
if document.Olid != nil {
|
||||||
if *document.Olid == "UNKNOWN" {
|
if *document.Olid == "UNKNOWN" {
|
||||||
c.Redirect(http.StatusFound, "/assets/no-cover.jpg")
|
c.File("./assets/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +136,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
// Validate File Exists
|
// Validate File Exists
|
||||||
_, err = os.Stat(safePath)
|
_, err = os.Stat(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Redirect(http.StatusFound, "/assets/no-cover.jpg")
|
c.File("./assets/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,12 +167,12 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
ID: document.ID,
|
ID: document.ID,
|
||||||
Olid: &coverID,
|
Olid: &coverID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("Document Upsert Error")
|
log.Warn("[getDocumentCover] UpsertDocument DB Error:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return Unknown Cover
|
// Return Unknown Cover
|
||||||
if coverID == "UNKNOWN" {
|
if coverID == "UNKNOWN" {
|
||||||
c.Redirect(http.StatusFound, "/assets/no-cover.jpg")
|
c.File("./assets/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ func (api *API) authAPIMiddleware(c *gin.Context) {
|
|||||||
// Utilize Session Token
|
// Utilize Session Token
|
||||||
if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil {
|
if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil {
|
||||||
c.Set("AuthorizedUser", authorizedUser)
|
c.Set("AuthorizedUser", authorizedUser)
|
||||||
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -69,6 +70,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
// Utilize Session Token
|
// Utilize Session Token
|
||||||
if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil {
|
if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil {
|
||||||
c.Set("AuthorizedUser", authorizedUser)
|
c.Set("AuthorizedUser", authorizedUser)
|
||||||
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
112
api/ko-routes.go
112
api/ko-routes.go
@ -83,21 +83,25 @@ func (api *API) authorizeUser(c *gin.Context) {
|
|||||||
func (api *API) createUser(c *gin.Context) {
|
func (api *API) createUser(c *gin.Context) {
|
||||||
if !api.Config.RegistrationEnabled {
|
if !api.Config.RegistrationEnabled {
|
||||||
c.AbortWithStatus(http.StatusConflict)
|
c.AbortWithStatus(http.StatusConflict)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var rUser requestUser
|
var rUser requestUser
|
||||||
if err := c.ShouldBindJSON(&rUser); err != nil {
|
if err := c.ShouldBindJSON(&rUser); err != nil {
|
||||||
|
log.Error("[createUser] Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if rUser.Username == "" || rUser.Password == "" {
|
if rUser.Username == "" || rUser.Password == "" {
|
||||||
|
log.Error("[createUser] Invalid User - Empty Username or Password")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
|
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[createUser] Argon2 Hash Failure:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -106,20 +110,18 @@ func (api *API) createUser(c *gin.Context) {
|
|||||||
ID: rUser.Username,
|
ID: rUser.Username,
|
||||||
Pass: hashedPassword,
|
Pass: hashedPassword,
|
||||||
})
|
})
|
||||||
|
|
||||||
// SQL Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[createUser] CreateUser DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// User Exists (ON CONFLICT DO NOTHING)
|
// User Exists
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "User Already Exists"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "User Already Exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Struct -> JSON
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"username": rUser.Username,
|
"username": rUser.Username,
|
||||||
})
|
})
|
||||||
@ -130,26 +132,25 @@ func (api *API) setProgress(c *gin.Context) {
|
|||||||
|
|
||||||
var rPosition requestPosition
|
var rPosition requestPosition
|
||||||
if err := c.ShouldBindJSON(&rPosition); err != nil {
|
if err := c.ShouldBindJSON(&rPosition); err != nil {
|
||||||
|
log.Error("[setProgress] Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Progress Data"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Progress Data"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Device
|
// Upsert Device
|
||||||
device, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
if _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
||||||
ID: rPosition.DeviceID,
|
ID: rPosition.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
DeviceName: rPosition.Device,
|
DeviceName: rPosition.Device,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
log.Error("[setProgress] UpsertDevice DB Error:", err)
|
||||||
log.Error("Device Upsert Error:", device, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Document
|
// Upsert Document
|
||||||
document, err := api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
if _, err := api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
||||||
ID: rPosition.DocumentID,
|
ID: rPosition.DocumentID,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
log.Error("[setProgress] UpsertDocument DB Error:", err)
|
||||||
log.Error("Document Upsert Error:", document, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or Replace Progress
|
// Create or Replace Progress
|
||||||
@ -161,11 +162,11 @@ func (api *API) setProgress(c *gin.Context) {
|
|||||||
Progress: rPosition.Progress,
|
Progress: rPosition.Progress,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[setProgress] UpdateProgress DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Struct -> JSON
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"document": progress.DocumentID,
|
"document": progress.DocumentID,
|
||||||
"timestamp": progress.CreatedAt,
|
"timestamp": progress.CreatedAt,
|
||||||
@ -177,6 +178,7 @@ func (api *API) getProgress(c *gin.Context) {
|
|||||||
|
|
||||||
var rDocID requestDocumentID
|
var rDocID requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
log.Error("[getProgress] Invalid URI Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -187,12 +189,11 @@ func (api *API) getProgress(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Invalid Progress:", progress, err)
|
log.Error("[getProgress] GetProgress DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Struct -> JSON
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"document": progress.DocumentID,
|
"document": progress.DocumentID,
|
||||||
"percentage": progress.Percentage,
|
"percentage": progress.Percentage,
|
||||||
@ -207,6 +208,7 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
|
|
||||||
var rActivity requestActivity
|
var rActivity requestActivity
|
||||||
if err := c.ShouldBindJSON(&rActivity); err != nil {
|
if err := c.ShouldBindJSON(&rActivity); err != nil {
|
||||||
|
log.Error("[addActivity] Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -214,6 +216,7 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
// Do Transaction
|
// Do Transaction
|
||||||
tx, err := api.DB.DB.Begin()
|
tx, err := api.DB.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[addActivities] Transaction Begin DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -231,30 +234,29 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
|
|
||||||
// Upsert Documents
|
// Upsert Documents
|
||||||
for _, doc := range allDocuments {
|
for _, doc := range allDocuments {
|
||||||
_, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
if _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
||||||
ID: doc,
|
ID: doc,
|
||||||
})
|
}); err != nil {
|
||||||
|
log.Error("[addActivities] UpsertDocument DB Error:", err)
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Device
|
// Upsert Device
|
||||||
_, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
if _, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
||||||
ID: rActivity.DeviceID,
|
ID: rActivity.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
DeviceName: rActivity.Device,
|
DeviceName: rActivity.Device,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
log.Error("[addActivities] UpsertDevice DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add All Activity
|
// Add All Activity
|
||||||
for _, item := range rActivity.Activity {
|
for _, item := range rActivity.Activity {
|
||||||
_, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{
|
if _, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
DocumentID: item.DocumentID,
|
DocumentID: item.DocumentID,
|
||||||
DeviceID: rActivity.DeviceID,
|
DeviceID: rActivity.DeviceID,
|
||||||
@ -262,19 +264,17 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
Duration: int64(item.Duration),
|
Duration: int64(item.Duration),
|
||||||
CurrentPage: int64(item.CurrentPage),
|
CurrentPage: int64(item.CurrentPage),
|
||||||
TotalPages: int64(item.TotalPages),
|
TotalPages: int64(item.TotalPages),
|
||||||
})
|
}); err != nil {
|
||||||
|
log.Error("[addActivities] AddActivity DB Error:", err)
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit Transaction
|
// Commit Transaction
|
||||||
tx.Commit()
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Error("[addActivities] Transaction Commit DB Error:", err)
|
||||||
if err != nil {
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,6 +288,7 @@ func (api *API) checkActivitySync(c *gin.Context) {
|
|||||||
|
|
||||||
var rCheckActivity requestCheckActivitySync
|
var rCheckActivity requestCheckActivitySync
|
||||||
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
|
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
|
||||||
|
log.Error("[checkActivitySync] Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -300,7 +301,7 @@ func (api *API) checkActivitySync(c *gin.Context) {
|
|||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
lastActivity = time.UnixMilli(0)
|
lastActivity = time.UnixMilli(0)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
log.Error("GetLastActivity Error:", err)
|
log.Error("[checkActivitySync] GetLastActivity DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -321,7 +322,7 @@ func (api *API) addDocuments(c *gin.Context) {
|
|||||||
// Do Transaction
|
// Do Transaction
|
||||||
tx, err := api.DB.DB.Begin()
|
tx, err := api.DB.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[addDocuments] Unknown Transaction Error")
|
log.Error("[addDocuments] Transaction Begin DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -342,17 +343,16 @@ func (api *API) addDocuments(c *gin.Context) {
|
|||||||
Description: doc.Description,
|
Description: doc.Description,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[addDocuments] UpsertDocument Error:", err)
|
log.Error("[addDocuments] UpsertDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
|
if _, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
|
||||||
ID: doc.ID,
|
ID: doc.ID,
|
||||||
Synced: true,
|
Synced: true,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
log.Error("[addDocuments] UpdateDocumentSync DB Error:", err)
|
||||||
log.Error("[addDocuments] UpsertDocumentSync Error:", err)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -360,7 +360,11 @@ func (api *API) addDocuments(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit Transaction
|
// Commit Transaction
|
||||||
tx.Commit()
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Error("[addDocuments] Transaction Commit DB Error:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"changed": len(rNewDocs.Documents),
|
"changed": len(rNewDocs.Documents),
|
||||||
@ -372,6 +376,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
|
|
||||||
var rCheckDocs requestCheckDocumentSync
|
var rCheckDocs requestCheckDocumentSync
|
||||||
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
|
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
|
||||||
|
log.Error("[checkDocumentsSync] Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -383,6 +388,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
DeviceName: rCheckDocs.Device,
|
DeviceName: rCheckDocs.Device,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[checkDocumentsSync] UpsertDevice DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -394,7 +400,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
// Get Missing Documents
|
// Get Missing Documents
|
||||||
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
|
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetMissingDocuments Error:", err)
|
log.Error("[checkDocumentsSync] GetMissingDocuments DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -402,7 +408,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
// Get Deleted Documents
|
// Get Deleted Documents
|
||||||
deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have)
|
deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetDeletedDocuements Error:", err)
|
log.Error("[checkDocumentsSync] GetDeletedDocuments DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -411,14 +417,14 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
// Get Wanted Documents
|
// Get Wanted Documents
|
||||||
jsonHaves, err := json.Marshal(rCheckDocs.Have)
|
jsonHaves, err := json.Marshal(rCheckDocs.Have)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("JSON Marshal Error:", err)
|
log.Error("[checkDocumentsSync] JSON Marshal Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wantedDocs, err := api.DB.Queries.GetWantedDocuments(api.DB.Ctx, string(jsonHaves))
|
wantedDocs, err := api.DB.Queries.GetWantedDocuments(api.DB.Ctx, string(jsonHaves))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetWantedDocuments Error:", err)
|
log.Error("[checkDocumentsSync] GetWantedDocuments DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -462,12 +468,14 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
func (api *API) uploadDocumentFile(c *gin.Context) {
|
func (api *API) uploadDocumentFile(c *gin.Context) {
|
||||||
var rDoc requestDocumentID
|
var rDoc requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDoc); err != nil {
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
|
log.Error("[uploadDocumentFile] Invalid URI Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fileData, err := c.FormFile("file")
|
fileData, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[uploadDocumentFile] File Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -478,6 +486,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
|
|||||||
fileExtension := fileMime.Extension()
|
fileExtension := fileMime.Extension()
|
||||||
|
|
||||||
if !slices.Contains(allowedExtensions, fileExtension) {
|
if !slices.Contains(allowedExtensions, fileExtension) {
|
||||||
|
log.Error("[uploadDocumentFile] Invalid FileType:", fileExtension)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -485,6 +494,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
|
|||||||
// Validate Document Exists in DB
|
// Validate Document Exists in DB
|
||||||
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[uploadDocumentFile] GetDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -517,6 +527,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
|
|||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
err = c.SaveUploadedFile(fileData, safePath)
|
err = c.SaveUploadedFile(fileData, safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[uploadDocumentFile] Save Failure:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -525,27 +536,28 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
|
|||||||
// Get MD5 Hash
|
// Get MD5 Hash
|
||||||
fileHash, err := getFileMD5(safePath)
|
fileHash, err := getFileMD5(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[uploadDocumentFile] Hash Failure:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Document
|
// Upsert Document
|
||||||
_, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
||||||
ID: document.ID,
|
ID: document.ID,
|
||||||
Md5: fileHash,
|
Md5: fileHash,
|
||||||
Filepath: &fileName,
|
Filepath: &fileName,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
log.Error("[uploadDocumentFile] UpsertDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Document Sync Attribute
|
// Update Document Sync Attribute
|
||||||
_, err = api.DB.Queries.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
|
if _, err = api.DB.Queries.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
|
||||||
ID: document.ID,
|
ID: document.ID,
|
||||||
Synced: true,
|
Synced: true,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
log.Error("[uploadDocumentFile] UpdateDocumentSync DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -558,6 +570,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
|
|||||||
func (api *API) downloadDocumentFile(c *gin.Context) {
|
func (api *API) downloadDocumentFile(c *gin.Context) {
|
||||||
var rDoc requestDocumentID
|
var rDoc requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDoc); err != nil {
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
|
log.Error("[downloadDocumentFile] Invalid URI Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -565,11 +578,13 @@ func (api *API) downloadDocumentFile(c *gin.Context) {
|
|||||||
// Get Document
|
// Get Document
|
||||||
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("[uploadDocumentFile] GetDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if document.Filepath == nil {
|
if document.Filepath == nil {
|
||||||
|
log.Error("[uploadDocumentFile] Document Doesn't Have File:", rDoc.DocumentID)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -580,6 +595,7 @@ func (api *API) downloadDocumentFile(c *gin.Context) {
|
|||||||
// Validate File Exists
|
// Validate File Exists
|
||||||
_, err = os.Stat(filePath)
|
_, err = os.Stat(filePath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
log.Error("[uploadDocumentFile] File Doesn't Exist:", rDoc.DocumentID)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -135,7 +135,7 @@ func (api *API) getActivity(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if activity == nil {
|
if activity == nil {
|
||||||
activity = []database.Activity{}
|
activity = []database.GetActivityRow{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, activity)
|
c.JSON(http.StatusOK, activity)
|
||||||
|
@ -75,5 +75,6 @@ type User struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Pass string `json:"-"`
|
Pass string `json:"-"`
|
||||||
Admin bool `json:"-"`
|
Admin bool `json:"-"`
|
||||||
|
TimeOffset string `json:"time_offset"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
@ -126,7 +126,7 @@ WHERE
|
|||||||
SELECT
|
SELECT
|
||||||
CAST(value AS TEXT) AS id,
|
CAST(value AS TEXT) AS id,
|
||||||
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
||||||
CAST((documents.synced != true) AS BOOLEAN) AS want_metadata
|
CAST((IFNULL(documents.synced, false) != true) AS BOOLEAN) AS want_metadata
|
||||||
FROM json_each(?1)
|
FROM json_each(?1)
|
||||||
LEFT JOIN documents
|
LEFT JOIN documents
|
||||||
ON value = documents.id
|
ON value = documents.id
|
||||||
@ -174,10 +174,7 @@ SELECT
|
|||||||
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
|
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
|
||||||
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
|
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
|
||||||
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes,
|
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes,
|
||||||
|
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
|
||||||
CAST(
|
|
||||||
STRFTIME('%Y-%m-%dT%H:%M:%SZ', IFNULL(last_read, "1970-01-01")
|
|
||||||
) AS TEXT) AS last_read,
|
|
||||||
|
|
||||||
CAST(CASE
|
CAST(CASE
|
||||||
WHEN percentage > 97.0 THEN 100.0
|
WHEN percentage > 97.0 THEN 100.0
|
||||||
@ -186,8 +183,9 @@ SELECT
|
|||||||
END AS REAL) AS percentage
|
END AS REAL) AS percentage
|
||||||
|
|
||||||
FROM documents
|
FROM documents
|
||||||
LEFT JOIN true_progress ON document_id = id
|
LEFT JOIN true_progress ON true_progress.document_id = documents.id
|
||||||
ORDER BY last_read DESC, created_at DESC
|
LEFT JOIN users ON users.id = $user_id
|
||||||
|
ORDER BY true_progress.last_read DESC, documents.created_at DESC
|
||||||
LIMIT $limit
|
LIMIT $limit
|
||||||
OFFSET $offset;
|
OFFSET $offset;
|
||||||
|
|
||||||
@ -206,13 +204,24 @@ LIMIT $limit
|
|||||||
OFFSET $offset;
|
OFFSET $offset;
|
||||||
|
|
||||||
-- name: GetActivity :many
|
-- name: GetActivity :many
|
||||||
SELECT * FROM activity
|
SELECT
|
||||||
|
document_id,
|
||||||
|
CAST(DATETIME(activity.start_time, time_offset) AS TEXT) AS start_time,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
duration,
|
||||||
|
current_page,
|
||||||
|
total_pages
|
||||||
|
FROM activity
|
||||||
|
LEFT JOIN documents ON documents.id = activity.document_id
|
||||||
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
WHERE
|
WHERE
|
||||||
user_id = $user_id
|
activity.user_id = $user_id
|
||||||
AND (
|
AND (
|
||||||
($doc_filter = TRUE AND document_id = $document_id)
|
CAST($doc_filter AS BOOLEAN) = TRUE
|
||||||
OR $doc_filter = FALSE
|
AND document_id = $document_id
|
||||||
)
|
)
|
||||||
|
OR $doc_filter = FALSE
|
||||||
ORDER BY start_time DESC
|
ORDER BY start_time DESC
|
||||||
LIMIT $limit
|
LIMIT $limit
|
||||||
OFFSET $offset;
|
OFFSET $offset;
|
||||||
@ -249,8 +258,9 @@ FROM capped_stats;
|
|||||||
|
|
||||||
-- name: GetDocumentDaysRead :one
|
-- name: GetDocumentDaysRead :one
|
||||||
WITH document_days AS (
|
WITH document_days AS (
|
||||||
SELECT DATE(start_time, 'localtime') AS dates
|
SELECT DATE(start_time, time_offset) AS dates
|
||||||
FROM rescaled_activity
|
FROM rescaled_activity
|
||||||
|
JOIN users ON users.id = rescaled_activity.user_id
|
||||||
WHERE document_id = $document_id
|
WHERE document_id = $document_id
|
||||||
AND user_id = $user_id
|
AND user_id = $user_id
|
||||||
GROUP BY dates
|
GROUP BY dates
|
||||||
@ -261,12 +271,11 @@ FROM document_days;
|
|||||||
-- name: GetUserWindowStreaks :one
|
-- name: GetUserWindowStreaks :one
|
||||||
WITH document_windows AS (
|
WITH document_windows AS (
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
-- TODO: Timezones! E.g. DATE(start_time, '-5 hours')
|
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day', time_offset)
|
||||||
-- TODO: Timezones! E.g. DATE(start_time, '-5 hours', '-7 days')
|
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
|
||||||
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day')
|
|
||||||
WHEN ?2 = "DAY" THEN DATE(start_time)
|
|
||||||
END AS read_window
|
END AS read_window
|
||||||
FROM activity
|
FROM activity
|
||||||
|
JOIN users ON users.id = activity.user_id
|
||||||
WHERE user_id = $user_id
|
WHERE user_id = $user_id
|
||||||
AND CAST($window AS TEXT) = CAST($window AS TEXT)
|
AND CAST($window AS TEXT) = CAST($window AS TEXT)
|
||||||
GROUP BY read_window
|
GROUP BY read_window
|
||||||
@ -331,8 +340,9 @@ SELECT
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetDailyReadStats :many
|
-- name: GetDailyReadStats :many
|
||||||
WITH RECURSIVE last_30_days (date) AS (
|
WITH RECURSIVE last_30_days AS (
|
||||||
SELECT DATE('now') AS date
|
SELECT DATE('now', time_offset) AS date
|
||||||
|
FROM users WHERE users.id = $user_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT DATE(date, '-1 days')
|
SELECT DATE(date, '-1 days')
|
||||||
FROM last_30_days
|
FROM last_30_days
|
||||||
@ -341,8 +351,9 @@ WITH RECURSIVE last_30_days (date) AS (
|
|||||||
activity_records AS (
|
activity_records AS (
|
||||||
SELECT
|
SELECT
|
||||||
sum(duration) AS seconds_read,
|
sum(duration) AS seconds_read,
|
||||||
DATE(start_time, 'localtime') AS day
|
DATE(start_time, time_offset) AS day
|
||||||
FROM activity
|
FROM activity
|
||||||
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
WHERE user_id = $user_id
|
WHERE user_id = $user_id
|
||||||
GROUP BY day
|
GROUP BY day
|
||||||
ORDER BY day DESC
|
ORDER BY day DESC
|
||||||
@ -358,11 +369,3 @@ FROM last_30_days
|
|||||||
LEFT JOIN activity_records ON activity_records.day == last_30_days.date
|
LEFT JOIN activity_records ON activity_records.day == last_30_days.date
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 30;
|
LIMIT 30;
|
||||||
|
|
||||||
-- SELECT
|
|
||||||
-- sum(duration) / 60 AS minutes_read,
|
|
||||||
-- DATE(start_time, 'localtime') AS day
|
|
||||||
-- FROM activity
|
|
||||||
-- GROUP BY day
|
|
||||||
-- ORDER BY day DESC
|
|
||||||
-- LIMIT 10;
|
|
||||||
|
@ -96,13 +96,24 @@ func (q *Queries) DeleteDocument(ctx context.Context, id string) (int64, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActivity = `-- name: GetActivity :many
|
const getActivity = `-- name: GetActivity :many
|
||||||
SELECT id, user_id, document_id, device_id, start_time, duration, current_page, total_pages, created_at FROM activity
|
SELECT
|
||||||
|
document_id,
|
||||||
|
CAST(DATETIME(activity.start_time, time_offset) AS TEXT) AS start_time,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
duration,
|
||||||
|
current_page,
|
||||||
|
total_pages
|
||||||
|
FROM activity
|
||||||
|
LEFT JOIN documents ON documents.id = activity.document_id
|
||||||
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
WHERE
|
WHERE
|
||||||
user_id = ?1
|
activity.user_id = ?1
|
||||||
AND (
|
AND (
|
||||||
(?2 = TRUE AND document_id = ?3)
|
CAST(?2 AS BOOLEAN) = TRUE
|
||||||
OR ?2 = FALSE
|
AND document_id = ?3
|
||||||
)
|
)
|
||||||
|
OR ?2 = FALSE
|
||||||
ORDER BY start_time DESC
|
ORDER BY start_time DESC
|
||||||
LIMIT ?5
|
LIMIT ?5
|
||||||
OFFSET ?4
|
OFFSET ?4
|
||||||
@ -110,13 +121,23 @@ OFFSET ?4
|
|||||||
|
|
||||||
type GetActivityParams struct {
|
type GetActivityParams struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
DocFilter interface{} `json:"doc_filter"`
|
DocFilter bool `json:"doc_filter"`
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
Offset int64 `json:"offset"`
|
Offset int64 `json:"offset"`
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Activity, error) {
|
type GetActivityRow struct {
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Author *string `json:"author"`
|
||||||
|
Duration int64 `json:"duration"`
|
||||||
|
CurrentPage int64 `json:"current_page"`
|
||||||
|
TotalPages int64 `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getActivity,
|
rows, err := q.db.QueryContext(ctx, getActivity,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
arg.DocFilter,
|
arg.DocFilter,
|
||||||
@ -128,19 +149,17 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Act
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []Activity
|
var items []GetActivityRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Activity
|
var i GetActivityRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
|
||||||
&i.UserID,
|
|
||||||
&i.DocumentID,
|
&i.DocumentID,
|
||||||
&i.DeviceID,
|
|
||||||
&i.StartTime,
|
&i.StartTime,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
&i.Duration,
|
&i.Duration,
|
||||||
&i.CurrentPage,
|
&i.CurrentPage,
|
||||||
&i.TotalPages,
|
&i.TotalPages,
|
||||||
&i.CreatedAt,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -156,8 +175,9 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Act
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDailyReadStats = `-- name: GetDailyReadStats :many
|
const getDailyReadStats = `-- name: GetDailyReadStats :many
|
||||||
WITH RECURSIVE last_30_days (date) AS (
|
WITH RECURSIVE last_30_days AS (
|
||||||
SELECT DATE('now') AS date
|
SELECT DATE('now', time_offset) AS date
|
||||||
|
FROM users WHERE users.id = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT DATE(date, '-1 days')
|
SELECT DATE(date, '-1 days')
|
||||||
FROM last_30_days
|
FROM last_30_days
|
||||||
@ -166,8 +186,9 @@ WITH RECURSIVE last_30_days (date) AS (
|
|||||||
activity_records AS (
|
activity_records AS (
|
||||||
SELECT
|
SELECT
|
||||||
sum(duration) AS seconds_read,
|
sum(duration) AS seconds_read,
|
||||||
DATE(start_time, 'localtime') AS day
|
DATE(start_time, time_offset) AS day
|
||||||
FROM activity
|
FROM activity
|
||||||
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
WHERE user_id = ?1
|
WHERE user_id = ?1
|
||||||
GROUP BY day
|
GROUP BY day
|
||||||
ORDER BY day DESC
|
ORDER BY day DESC
|
||||||
@ -372,8 +393,9 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
|
|||||||
|
|
||||||
const getDocumentDaysRead = `-- name: GetDocumentDaysRead :one
|
const getDocumentDaysRead = `-- name: GetDocumentDaysRead :one
|
||||||
WITH document_days AS (
|
WITH document_days AS (
|
||||||
SELECT DATE(start_time, 'localtime') AS dates
|
SELECT DATE(start_time, time_offset) AS dates
|
||||||
FROM rescaled_activity
|
FROM rescaled_activity
|
||||||
|
JOIN users ON users.id = rescaled_activity.user_id
|
||||||
WHERE document_id = ?1
|
WHERE document_id = ?1
|
||||||
AND user_id = ?2
|
AND user_id = ?2
|
||||||
GROUP BY dates
|
GROUP BY dates
|
||||||
@ -521,7 +543,7 @@ WITH true_progress AS (
|
|||||||
total_pages,
|
total_pages,
|
||||||
ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage
|
ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE user_id = ?3
|
WHERE user_id = ?1
|
||||||
GROUP BY document_id
|
GROUP BY document_id
|
||||||
HAVING MAX(start_time)
|
HAVING MAX(start_time)
|
||||||
)
|
)
|
||||||
@ -531,10 +553,7 @@ SELECT
|
|||||||
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
|
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
|
||||||
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
|
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
|
||||||
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes,
|
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes,
|
||||||
|
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
|
||||||
CAST(
|
|
||||||
STRFTIME('%Y-%m-%dT%H:%M:%SZ', IFNULL(last_read, "1970-01-01")
|
|
||||||
) AS TEXT) AS last_read,
|
|
||||||
|
|
||||||
CAST(CASE
|
CAST(CASE
|
||||||
WHEN percentage > 97.0 THEN 100.0
|
WHEN percentage > 97.0 THEN 100.0
|
||||||
@ -543,16 +562,17 @@ SELECT
|
|||||||
END AS REAL) AS percentage
|
END AS REAL) AS percentage
|
||||||
|
|
||||||
FROM documents
|
FROM documents
|
||||||
LEFT JOIN true_progress ON document_id = id
|
LEFT JOIN true_progress ON true_progress.document_id = documents.id
|
||||||
ORDER BY last_read DESC, created_at DESC
|
LEFT JOIN users ON users.id = ?1
|
||||||
LIMIT ?2
|
ORDER BY true_progress.last_read DESC, documents.created_at DESC
|
||||||
OFFSET ?1
|
LIMIT ?3
|
||||||
|
OFFSET ?2
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDocumentsWithStatsParams struct {
|
type GetDocumentsWithStatsParams struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
Offset int64 `json:"offset"`
|
Offset int64 `json:"offset"`
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
UserID string `json:"user_id"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetDocumentsWithStatsRow struct {
|
type GetDocumentsWithStatsRow struct {
|
||||||
@ -578,7 +598,7 @@ type GetDocumentsWithStatsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) {
|
func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getDocumentsWithStats, arg.Offset, arg.Limit, arg.UserID)
|
rows, err := q.db.QueryContext(ctx, getDocumentsWithStats, arg.UserID, arg.Offset, arg.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -742,7 +762,7 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) (GetPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUser = `-- name: GetUser :one
|
const getUser = `-- name: GetUser :one
|
||||||
SELECT id, pass, admin, created_at FROM users
|
SELECT id, pass, admin, time_offset, created_at FROM users
|
||||||
WHERE id = ?1 LIMIT 1
|
WHERE id = ?1 LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -753,6 +773,7 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
|
|||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Pass,
|
&i.Pass,
|
||||||
&i.Admin,
|
&i.Admin,
|
||||||
|
&i.TimeOffset,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@ -761,12 +782,11 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
|
|||||||
const getUserWindowStreaks = `-- name: GetUserWindowStreaks :one
|
const getUserWindowStreaks = `-- name: GetUserWindowStreaks :one
|
||||||
WITH document_windows AS (
|
WITH document_windows AS (
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
-- TODO: Timezones! E.g. DATE(start_time, '-5 hours')
|
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day', time_offset)
|
||||||
-- TODO: Timezones! E.g. DATE(start_time, '-5 hours', '-7 days')
|
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
|
||||||
WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day')
|
|
||||||
WHEN ?2 = "DAY" THEN DATE(start_time)
|
|
||||||
END AS read_window
|
END AS read_window
|
||||||
FROM activity
|
FROM activity
|
||||||
|
JOIN users ON users.id = activity.user_id
|
||||||
WHERE user_id = ?1
|
WHERE user_id = ?1
|
||||||
AND CAST(?2 AS TEXT) = CAST(?2 AS TEXT)
|
AND CAST(?2 AS TEXT) = CAST(?2 AS TEXT)
|
||||||
GROUP BY read_window
|
GROUP BY read_window
|
||||||
@ -852,7 +872,7 @@ func (q *Queries) GetUserWindowStreaks(ctx context.Context, arg GetUserWindowStr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUsers = `-- name: GetUsers :many
|
const getUsers = `-- name: GetUsers :many
|
||||||
SELECT id, pass, admin, created_at FROM users
|
SELECT id, pass, admin, time_offset, created_at FROM users
|
||||||
WHERE
|
WHERE
|
||||||
users.id = ?1
|
users.id = ?1
|
||||||
OR ?1 IN (
|
OR ?1 IN (
|
||||||
@ -885,6 +905,7 @@ func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, err
|
|||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Pass,
|
&i.Pass,
|
||||||
&i.Admin,
|
&i.Admin,
|
||||||
|
&i.TimeOffset,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -904,7 +925,7 @@ const getWantedDocuments = `-- name: GetWantedDocuments :many
|
|||||||
SELECT
|
SELECT
|
||||||
CAST(value AS TEXT) AS id,
|
CAST(value AS TEXT) AS id,
|
||||||
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
||||||
CAST((documents.synced != true) AS BOOLEAN) AS want_metadata
|
CAST((IFNULL(documents.synced, false) != true) AS BOOLEAN) AS want_metadata
|
||||||
FROM json_each(?1)
|
FROM json_each(?1)
|
||||||
LEFT JOIN documents
|
LEFT JOIN documents
|
||||||
ON value = documents.id
|
ON value = documents.id
|
||||||
|
@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
|
|
||||||
pass TEXT NOT NULL,
|
pass TEXT NOT NULL,
|
||||||
admin BOOLEAN NOT NULL DEFAULT 0 CHECK (admin IN (0, 1)),
|
admin BOOLEAN NOT NULL DEFAULT 0 CHECK (admin IN (0, 1)),
|
||||||
|
time_offset TEXT NOT NULL DEFAULT '0 hours',
|
||||||
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,56 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Activity{{end}} {{define
|
{{template "base.html" .}} {{define "title"}}Activity{{end}} {{define
|
||||||
"content"}}
|
"content"}}
|
||||||
<h1>Activity</h1>
|
|
||||||
|
<div class="px-4 -mx-4 overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-xs">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Document
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Time
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Duration
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Page
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{range $activity := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $activity.Author }} - {{ $activity.Title }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $activity.StartTime }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $activity.Duration }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $activity.CurrentPage }} / {{ $activity.TotalPages }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -11,9 +11,28 @@
|
|||||||
class="relative h-screen overflow-hidden bg-gray-100 dark:bg-gray-800"
|
class="relative h-screen overflow-hidden bg-gray-100 dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="relative hidden h-screen shadow-lg lg:block w-64">
|
<input type="checkbox" id="mobile-nav-button" class="hidden"/>
|
||||||
|
<div class="fixed -left-64 duration-500 transition-all w-56 z-50 h-screen shadow-lg lg:left-0 lg:block lg:relative">
|
||||||
<div class="h-full bg-white dark:bg-gray-700">
|
<div class="h-full bg-white dark:bg-gray-700">
|
||||||
<div class="flex items-center justify-start pt-4 ml-8">
|
<div class="flex items-center justify-center gap-4 h-16">
|
||||||
|
<label
|
||||||
|
id="mobile-nav-close-button"
|
||||||
|
for="mobile-nav-button"
|
||||||
|
class="flex block items-center p-2 text-gray-500 bg-white rounded-full shadow text-md cursor-pointer lg:hidden"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="text-gray-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 1792 1792"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1664 1344v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
<p class="text-xl font-bold dark:text-white">Book Manager</p>
|
<p class="text-xl font-bold dark:text-white">Book Manager</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mt-6">
|
<nav class="mt-6">
|
||||||
@ -100,8 +119,9 @@
|
|||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<header class="z-40 flex items-center justify-between w-full h-16">
|
<header class="z-40 flex items-center justify-between w-full h-16">
|
||||||
<div class="block ml-6 lg:hidden">
|
<div class="block ml-6 lg:hidden">
|
||||||
<button
|
<label
|
||||||
class="flex items-center p-2 text-gray-500 bg-white rounded-full shadow text-md"
|
for="mobile-nav-button"
|
||||||
|
class="flex items-center p-2 text-gray-500 bg-white rounded-full shadow text-md cursor-pointer"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@ -115,7 +135,7 @@
|
|||||||
d="M1664 1344v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45z"
|
d="M1664 1344v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45zm0-512v128q0 26-19 45t-45 19h-1408q-26 0-45-19t-19-45v-128q0-26 19-45t45-19h1408q26 0 45 19t19 45z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-bold dark:text-white px-6">{{block "title" .}}{{end}}</h1>
|
<h1 class="text-xl font-bold dark:text-white px-6">{{block "title" .}}{{end}}</h1>
|
||||||
<div
|
<div
|
||||||
@ -135,25 +155,10 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<input type="checkbox" id="user-dropdown-button" class="hidden"/>
|
||||||
class="custom-profile-button flex items-center text-gray-500 dark:text-white text-md py-4"
|
|
||||||
>
|
|
||||||
{{ .User }}
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
class="ml-2 text-gray-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
class="custom-profile-dropdown transition duration-200 absolute right-4 top-16 pt-4"
|
id="user-dropdown"
|
||||||
|
class="transition duration-200 absolute right-4 top-16 pt-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-56 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5"
|
class="w-56 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5"
|
||||||
@ -176,6 +181,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label for="user-dropdown-button">
|
||||||
|
<div
|
||||||
|
class="flex items-center text-gray-500 dark:text-white text-md py-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ .User }}
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="ml-2 text-gray-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 1792 1792"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="h-screen px-4 pb-24 overflow-auto md:px-6">
|
<div class="h-screen px-4 pb-24 overflow-auto md:px-6">
|
||||||
@ -187,16 +211,26 @@
|
|||||||
|
|
||||||
<!-- Custom Animation CSS -->
|
<!-- Custom Animation CSS -->
|
||||||
<style>
|
<style>
|
||||||
.custom-profile-dropdown {
|
|
||||||
visibility: hidden;
|
/* ----------------------------- */
|
||||||
opacity: 0;
|
/* ------ Navigation Slide ----- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
#mobile-nav-button:checked + div {
|
||||||
|
left: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-profile-button:hover + .custom-profile-dropdown,
|
/* ----------------------------- */
|
||||||
.custom-profile-dropdown:hover {
|
/* ------- User Dropdown ------- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
#user-dropdown-button:checked + #user-dropdown {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#user-dropdown {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Documents{{end}} {{define
|
{{template "base.html" .}} {{define "title"}}Documents{{end}} {{define
|
||||||
"content"}}
|
"content"}}
|
||||||
<div class="grid grid-cols-1 gap-4 my-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{{range $doc := .Data }}
|
{{range $doc := .Data }}
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
<div class="min-w-fit h-48 relative">
|
<div class="min-w-fit h-48 relative">
|
||||||
<a href="./documents/{{$doc.ID}}/file">
|
<a href="./documents/{{$doc.ID}}/file">
|
||||||
<img class="rounded object-cover h-full w-full" src="./documents/{{$doc.ID}}/cover"></img>
|
<img class="rounded object-cover h-full" src="./documents/{{$doc.ID}}/cover"></img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-around dark:text-white w-full text-xs">
|
<div class="flex flex-col justify-around dark:text-white w-full text-xs">
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Home{{end}} {{define "content"}}
|
{{template "base.html" .}} {{define "title"}}Home{{end}} {{define "content"}}
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="relative w-full px-4 py-4 bg-white shadow-lg dark:bg-gray-700">
|
<div
|
||||||
|
class="relative w-full px-4 py-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
class="absolute top-3 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
>
|
>
|
||||||
Daily Read Totals
|
Daily Read Totals
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{{ $data := (GetSVGGraphData .Data.GraphData 800 70 )}}
|
{{ $data := (GetSVGGraphData .Data.GraphData 800 70 )}}
|
||||||
<svg viewBox="0 0 {{ $data.Width }} {{ $data.Height }}">
|
<svg
|
||||||
|
viewBox="26 0 755 {{ $data.Height }}"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
width="100%"
|
||||||
|
height="4em"
|
||||||
|
>
|
||||||
<!-- Bezier Line Graph -->
|
<!-- Bezier Line Graph -->
|
||||||
<path
|
<path
|
||||||
fill="#316BBE"
|
fill="#316BBE"
|
||||||
@ -147,7 +154,9 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 my-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 my-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700">
|
<div
|
||||||
|
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
>
|
>
|
||||||
@ -190,7 +199,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700">
|
<div
|
||||||
|
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
>
|
>
|
||||||
|
Loading…
Reference in New Issue
Block a user