[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("/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/cover", api.authWebAppMiddleware, api.getDocumentCover) | ||||
| 
 | ||||
| 	// TODO | ||||
| 	api.Router.GET("/activity", api.authWebAppMiddleware, baseResourceRoute("activity")) | ||||
| 	api.Router.GET("/graphs", api.authWebAppMiddleware, baseResourceRoute("graphs")) | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 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) { | ||||
| 	// Merge Optional Template Data | ||||
| 	var templateVars = gin.H{} | ||||
| 	var templateVarsBase = gin.H{} | ||||
| 	if len(args) > 0 { | ||||
| 		templateVars = args[0] | ||||
| 		templateVarsBase = args[0] | ||||
| 	} | ||||
| 	templateVars["RouteName"] = routeName | ||||
| 	templateVarsBase["RouteName"] = routeName | ||||
| 
 | ||||
| 	return func(c *gin.Context) { | ||||
| 		rUser, _ := c.Get("AuthorizedUser") | ||||
| 		qParams := bindQueryParams(c) | ||||
| 
 | ||||
| 		// Copy Base & Update | ||||
| 		templateVars := gin.H{} | ||||
| 		for k, v := range templateVarsBase { | ||||
| 			templateVars[k] = v | ||||
| 		} | ||||
| 		templateVars["User"] = rUser | ||||
| 
 | ||||
| 		// Potential URL Parameters | ||||
| 		qParams := bindQueryParams(c) | ||||
| 
 | ||||
| 		if routeName == "documents" { | ||||
| 			documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{ | ||||
| 				UserID: rUser.(string), | ||||
| @ -45,28 +53,44 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any | ||||
| 				Limit:  *qParams.Limit, | ||||
| 			}) | ||||
| 			if err != nil { | ||||
| 				log.Info(err) | ||||
| 				log.Error("[createAppResourcesRoute] GetDocumentsWithStats DB Error:", err) | ||||
| 				c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 				return | ||||
| 			} | ||||
| 
 | ||||
| 			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" { | ||||
| 			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), | ||||
| 				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), | ||||
| 				Window: "DAY", | ||||
| 			}) | ||||
| 			if err != nil { | ||||
| 				log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err) | ||||
| 			} | ||||
| 
 | ||||
| 			database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string)) | ||||
| 			read_graph_data, err := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string)) | ||||
| 			if err != nil { | ||||
| 				log.Info("HMMMM:", err) | ||||
| 			} | ||||
| 			read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string)) | ||||
| 
 | ||||
| 			templateVars["Data"] = gin.H{ | ||||
| 				"DailyStreak":  daily_streak, | ||||
| @ -85,6 +109,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any | ||||
| func (api *API) getDocumentCover(c *gin.Context) { | ||||
| 	var rDoc requestDocumentID | ||||
| 	if err := c.ShouldBindUri(&rDoc); err != nil { | ||||
| 		log.Error("[getDocumentCover] Invalid URI Bind") | ||||
| 		c.AbortWithStatus(http.StatusBadRequest) | ||||
| 		return | ||||
| 	} | ||||
| @ -92,6 +117,7 @@ func (api *API) getDocumentCover(c *gin.Context) { | ||||
| 	// Validate Document Exists in DB | ||||
| 	document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID) | ||||
| 	if err != nil { | ||||
| 		log.Error("[getDocumentCover] GetDocument DB Error:", err) | ||||
| 		c.AbortWithStatus(http.StatusBadRequest) | ||||
| 		return | ||||
| 	} | ||||
| @ -99,7 +125,7 @@ func (api *API) getDocumentCover(c *gin.Context) { | ||||
| 	// Handle Identified Document | ||||
| 	if document.Olid != nil { | ||||
| 		if *document.Olid == "UNKNOWN" { | ||||
| 			c.Redirect(http.StatusFound, "/assets/no-cover.jpg") | ||||
| 			c.File("./assets/no-cover.jpg") | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| @ -110,7 +136,7 @@ func (api *API) getDocumentCover(c *gin.Context) { | ||||
| 		// Validate File Exists | ||||
| 		_, err = os.Stat(safePath) | ||||
| 		if err != nil { | ||||
| 			c.Redirect(http.StatusFound, "/assets/no-cover.jpg") | ||||
| 			c.File("./assets/no-cover.jpg") | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| @ -141,12 +167,12 @@ func (api *API) getDocumentCover(c *gin.Context) { | ||||
| 		ID:   document.ID, | ||||
| 		Olid: &coverID, | ||||
| 	}); err != nil { | ||||
| 		log.Error("Document Upsert Error") | ||||
| 		log.Warn("[getDocumentCover] UpsertDocument DB Error:", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Return Unknown Cover | ||||
| 	if coverID == "UNKNOWN" { | ||||
| 		c.Redirect(http.StatusFound, "/assets/no-cover.jpg") | ||||
| 		c.File("./assets/no-cover.jpg") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
|  | ||||
| @ -36,6 +36,7 @@ func (api *API) authAPIMiddleware(c *gin.Context) { | ||||
| 	// Utilize Session Token | ||||
| 	if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil { | ||||
| 		c.Set("AuthorizedUser", authorizedUser) | ||||
| 		c.Header("Cache-Control", "private") | ||||
| 		c.Next() | ||||
| 		return | ||||
| 	} | ||||
| @ -69,6 +70,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) { | ||||
| 	// Utilize Session Token | ||||
| 	if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil { | ||||
| 		c.Set("AuthorizedUser", authorizedUser) | ||||
| 		c.Header("Cache-Control", "private") | ||||
| 		c.Next() | ||||
| 		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) { | ||||
| 	if !api.Config.RegistrationEnabled { | ||||
| 		c.AbortWithStatus(http.StatusConflict) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	var rUser requestUser | ||||
| 	if err := c.ShouldBindJSON(&rUser); err != nil { | ||||
| 		log.Error("[createUser] Invalid JSON Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if rUser.Username == "" || rUser.Password == "" { | ||||
| 		log.Error("[createUser] Invalid User - Empty Username or Password") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams) | ||||
| 	if err != nil { | ||||
| 		log.Error("[createUser] Argon2 Hash Failure:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -106,20 +110,18 @@ func (api *API) createUser(c *gin.Context) { | ||||
| 		ID:   rUser.Username, | ||||
| 		Pass: hashedPassword, | ||||
| 	}) | ||||
| 
 | ||||
| 	// SQL Error | ||||
| 	if err != nil { | ||||
| 		log.Error("[createUser] CreateUser DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// User Exists (ON CONFLICT DO NOTHING) | ||||
| 	// User Exists | ||||
| 	if rows == 0 { | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "User Already Exists"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// TODO: Struct -> JSON | ||||
| 	c.JSON(http.StatusCreated, gin.H{ | ||||
| 		"username": rUser.Username, | ||||
| 	}) | ||||
| @ -130,26 +132,25 @@ func (api *API) setProgress(c *gin.Context) { | ||||
| 
 | ||||
| 	var rPosition requestPosition | ||||
| 	if err := c.ShouldBindJSON(&rPosition); err != nil { | ||||
| 		log.Error("[setProgress] Invalid JSON Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Progress Data"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// 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, | ||||
| 		UserID:     rUser.(string), | ||||
| 		DeviceName: rPosition.Device, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Error("Device Upsert Error:", device, err) | ||||
| 	}); err != nil { | ||||
| 		log.Error("[setProgress] UpsertDevice DB Error:", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// 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, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Error("Document Upsert Error:", document, err) | ||||
| 	}); err != nil { | ||||
| 		log.Error("[setProgress] UpsertDocument DB Error:", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Create or Replace Progress | ||||
| @ -161,11 +162,11 @@ func (api *API) setProgress(c *gin.Context) { | ||||
| 		Progress:   rPosition.Progress, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Error("[setProgress] UpdateProgress DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// TODO: Struct -> JSON | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"document":  progress.DocumentID, | ||||
| 		"timestamp": progress.CreatedAt, | ||||
| @ -177,6 +178,7 @@ func (api *API) getProgress(c *gin.Context) { | ||||
| 
 | ||||
| 	var rDocID requestDocumentID | ||||
| 	if err := c.ShouldBindUri(&rDocID); err != nil { | ||||
| 		log.Error("[getProgress] Invalid URI Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -187,12 +189,11 @@ func (api *API) getProgress(c *gin.Context) { | ||||
| 	}) | ||||
| 
 | ||||
| 	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"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// TODO: Struct -> JSON | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"document":   progress.DocumentID, | ||||
| 		"percentage": progress.Percentage, | ||||
| @ -207,6 +208,7 @@ func (api *API) addActivities(c *gin.Context) { | ||||
| 
 | ||||
| 	var rActivity requestActivity | ||||
| 	if err := c.ShouldBindJSON(&rActivity); err != nil { | ||||
| 		log.Error("[addActivity] Invalid JSON Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -214,6 +216,7 @@ func (api *API) addActivities(c *gin.Context) { | ||||
| 	// Do Transaction | ||||
| 	tx, err := api.DB.DB.Begin() | ||||
| 	if err != nil { | ||||
| 		log.Error("[addActivities] Transaction Begin DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -231,30 +234,29 @@ func (api *API) addActivities(c *gin.Context) { | ||||
| 
 | ||||
| 	// Upsert Documents | ||||
| 	for _, doc := range allDocuments { | ||||
| 		_, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ | ||||
| 		if _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ | ||||
| 			ID: doc, | ||||
| 		}) | ||||
| 
 | ||||
| 		if err != nil { | ||||
| 		}); err != nil { | ||||
| 			log.Error("[addActivities] UpsertDocument DB Error:", err) | ||||
| 			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"}) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Upsert Device | ||||
| 	_, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{ | ||||
| 	if _, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{ | ||||
| 		ID:         rActivity.DeviceID, | ||||
| 		UserID:     rUser.(string), | ||||
| 		DeviceName: rActivity.Device, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 	}); err != nil { | ||||
| 		log.Error("[addActivities] UpsertDevice DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Add All 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), | ||||
| 			DocumentID:  item.DocumentID, | ||||
| 			DeviceID:    rActivity.DeviceID, | ||||
| @ -262,19 +264,17 @@ func (api *API) addActivities(c *gin.Context) { | ||||
| 			Duration:    int64(item.Duration), | ||||
| 			CurrentPage: int64(item.CurrentPage), | ||||
| 			TotalPages:  int64(item.TotalPages), | ||||
| 		}) | ||||
| 
 | ||||
| 		if err != nil { | ||||
| 		}); err != nil { | ||||
| 			log.Error("[addActivities] AddActivity DB Error:", err) | ||||
| 			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"}) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Commit Transaction | ||||
| 	tx.Commit() | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"}) | ||||
| 	if err := tx.Commit(); err != nil { | ||||
| 		log.Error("[addActivities] Transaction Commit DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| @ -288,6 +288,7 @@ func (api *API) checkActivitySync(c *gin.Context) { | ||||
| 
 | ||||
| 	var rCheckActivity requestCheckActivitySync | ||||
| 	if err := c.ShouldBindJSON(&rCheckActivity); err != nil { | ||||
| 		log.Error("[checkActivitySync] Invalid JSON Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -300,7 +301,7 @@ func (api *API) checkActivitySync(c *gin.Context) { | ||||
| 	if err == sql.ErrNoRows { | ||||
| 		lastActivity = time.UnixMilli(0) | ||||
| 	} 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"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -321,7 +322,7 @@ func (api *API) addDocuments(c *gin.Context) { | ||||
| 	// Do Transaction | ||||
| 	tx, err := api.DB.DB.Begin() | ||||
| 	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"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -342,17 +343,16 @@ func (api *API) addDocuments(c *gin.Context) { | ||||
| 			Description: doc.Description, | ||||
| 		}) | ||||
| 		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"}) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		_, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{ | ||||
| 		if _, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{ | ||||
| 			ID:     doc.ID, | ||||
| 			Synced: true, | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			log.Error("[addDocuments] UpsertDocumentSync Error:", err) | ||||
| 		}); err != nil { | ||||
| 			log.Error("[addDocuments] UpdateDocumentSync DB Error:", err) | ||||
| 			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"}) | ||||
| 			return | ||||
| 		} | ||||
| @ -360,7 +360,11 @@ func (api *API) addDocuments(c *gin.Context) { | ||||
| 	} | ||||
| 
 | ||||
| 	// 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{ | ||||
| 		"changed": len(rNewDocs.Documents), | ||||
| @ -372,6 +376,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) { | ||||
| 
 | ||||
| 	var rCheckDocs requestCheckDocumentSync | ||||
| 	if err := c.ShouldBindJSON(&rCheckDocs); err != nil { | ||||
| 		log.Error("[checkDocumentsSync] Invalid JSON Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -383,6 +388,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) { | ||||
| 		DeviceName: rCheckDocs.Device, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Error("[checkDocumentsSync] UpsertDevice DB Error", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -394,7 +400,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) { | ||||
| 		// Get Missing Documents | ||||
| 		missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have) | ||||
| 		if err != nil { | ||||
| 			log.Error("GetMissingDocuments Error:", err) | ||||
| 			log.Error("[checkDocumentsSync] GetMissingDocuments DB Error", err) | ||||
| 			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 			return | ||||
| 		} | ||||
| @ -402,7 +408,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) { | ||||
| 		// Get Deleted Documents | ||||
| 		deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have) | ||||
| 		if err != nil { | ||||
| 			log.Error("GetDeletedDocuements Error:", err) | ||||
| 			log.Error("[checkDocumentsSync] GetDeletedDocuments DB Error", err) | ||||
| 			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 			return | ||||
| 		} | ||||
| @ -411,14 +417,14 @@ func (api *API) checkDocumentsSync(c *gin.Context) { | ||||
| 	// Get Wanted Documents | ||||
| 	jsonHaves, err := json.Marshal(rCheckDocs.Have) | ||||
| 	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"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	wantedDocs, err := api.DB.Queries.GetWantedDocuments(api.DB.Ctx, string(jsonHaves)) | ||||
| 	if err != nil { | ||||
| 		log.Error("GetWantedDocuments Error:", err) | ||||
| 		log.Error("[checkDocumentsSync] GetWantedDocuments DB Error", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -462,12 +468,14 @@ func (api *API) checkDocumentsSync(c *gin.Context) { | ||||
| func (api *API) uploadDocumentFile(c *gin.Context) { | ||||
| 	var rDoc requestDocumentID | ||||
| 	if err := c.ShouldBindUri(&rDoc); err != nil { | ||||
| 		log.Error("[uploadDocumentFile] Invalid URI Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	fileData, err := c.FormFile("file") | ||||
| 	if err != nil { | ||||
| 		log.Error("[uploadDocumentFile] File Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -478,6 +486,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) { | ||||
| 	fileExtension := fileMime.Extension() | ||||
| 
 | ||||
| 	if !slices.Contains(allowedExtensions, fileExtension) { | ||||
| 		log.Error("[uploadDocumentFile] Invalid FileType:", fileExtension) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -485,6 +494,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) { | ||||
| 	// Validate Document Exists in DB | ||||
| 	document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID) | ||||
| 	if err != nil { | ||||
| 		log.Error("[uploadDocumentFile] GetDocument DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -517,6 +527,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) { | ||||
| 	if os.IsNotExist(err) { | ||||
| 		err = c.SaveUploadedFile(fileData, safePath) | ||||
| 		if err != nil { | ||||
| 			log.Error("[uploadDocumentFile] Save Failure:", err) | ||||
| 			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"}) | ||||
| 			return | ||||
| 		} | ||||
| @ -525,27 +536,28 @@ func (api *API) uploadDocumentFile(c *gin.Context) { | ||||
| 	// Get MD5 Hash | ||||
| 	fileHash, err := getFileMD5(safePath) | ||||
| 	if err != nil { | ||||
| 		log.Error("[uploadDocumentFile] Hash Failure:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// 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, | ||||
| 		Md5:      fileHash, | ||||
| 		Filepath: &fileName, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 	}); err != nil { | ||||
| 		log.Error("[uploadDocumentFile] UpsertDocument DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// 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, | ||||
| 		Synced: true, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 	}); err != nil { | ||||
| 		log.Error("[uploadDocumentFile] UpdateDocumentSync DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -558,6 +570,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) { | ||||
| func (api *API) downloadDocumentFile(c *gin.Context) { | ||||
| 	var rDoc requestDocumentID | ||||
| 	if err := c.ShouldBindUri(&rDoc); err != nil { | ||||
| 		log.Error("[downloadDocumentFile] Invalid URI Bind") | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -565,11 +578,13 @@ func (api *API) downloadDocumentFile(c *gin.Context) { | ||||
| 	// Get Document | ||||
| 	document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID) | ||||
| 	if err != nil { | ||||
| 		log.Error("[uploadDocumentFile] GetDocument DB Error:", err) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	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"}) | ||||
| 		return | ||||
| 	} | ||||
| @ -580,6 +595,7 @@ func (api *API) downloadDocumentFile(c *gin.Context) { | ||||
| 	// Validate File Exists | ||||
| 	_, err = os.Stat(filePath) | ||||
| 	if os.IsNotExist(err) { | ||||
| 		log.Error("[uploadDocumentFile] File Doesn't Exist:", rDoc.DocumentID) | ||||
| 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| @ -135,7 +135,7 @@ func (api *API) getActivity(c *gin.Context) { | ||||
| 	} | ||||
| 
 | ||||
| 	if activity == nil { | ||||
| 		activity = []database.Activity{} | ||||
| 		activity = []database.GetActivityRow{} | ||||
| 	} | ||||
| 
 | ||||
| 	c.JSON(http.StatusOK, activity) | ||||
|  | ||||
| @ -72,8 +72,9 @@ type RescaledActivity struct { | ||||
| } | ||||
| 
 | ||||
| type User struct { | ||||
| 	ID        string    `json:"id"` | ||||
| 	Pass      string    `json:"-"` | ||||
| 	Admin     bool      `json:"-"` | ||||
| 	CreatedAt time.Time `json:"created_at"` | ||||
| 	ID         string    `json:"id"` | ||||
| 	Pass       string    `json:"-"` | ||||
| 	Admin      bool      `json:"-"` | ||||
| 	TimeOffset string    `json:"time_offset"` | ||||
| 	CreatedAt  time.Time `json:"created_at"` | ||||
| } | ||||
|  | ||||
| @ -126,7 +126,7 @@ WHERE | ||||
| SELECT | ||||
|     CAST(value AS TEXT) AS id, | ||||
|     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) | ||||
| LEFT JOIN documents | ||||
| ON value = documents.id | ||||
| @ -134,8 +134,8 @@ WHERE ( | ||||
|     documents.id IS NOT NULL | ||||
|     AND documents.deleted = false | ||||
|     AND ( | ||||
| 	documents.synced = false | ||||
| 	OR documents.filepath IS NULL | ||||
|         documents.synced = false | ||||
|         OR documents.filepath IS NULL | ||||
|     ) | ||||
| ) | ||||
| OR (documents.id IS NULL) | ||||
| @ -174,10 +174,7 @@ SELECT | ||||
|     CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page, | ||||
|     CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages, | ||||
|     CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes, | ||||
| 
 | ||||
|     CAST( | ||||
|         STRFTIME('%Y-%m-%dT%H:%M:%SZ', IFNULL(last_read, "1970-01-01") | ||||
|     ) AS TEXT) AS last_read, | ||||
|     CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read, | ||||
| 
 | ||||
|     CAST(CASE | ||||
|         WHEN percentage > 97.0 THEN 100.0 | ||||
| @ -186,8 +183,9 @@ SELECT | ||||
|     END AS REAL) AS percentage | ||||
| 
 | ||||
| FROM documents | ||||
| LEFT JOIN true_progress ON document_id = id | ||||
| ORDER BY last_read DESC, created_at DESC | ||||
| LEFT JOIN true_progress ON true_progress.document_id = documents.id | ||||
| LEFT JOIN users ON users.id = $user_id | ||||
| ORDER BY true_progress.last_read DESC, documents.created_at DESC | ||||
| LIMIT $limit | ||||
| OFFSET $offset; | ||||
| 
 | ||||
| @ -206,13 +204,24 @@ LIMIT $limit | ||||
| OFFSET $offset; | ||||
| 
 | ||||
| -- 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 | ||||
|     user_id = $user_id | ||||
|     activity.user_id = $user_id | ||||
|     AND ( | ||||
|         ($doc_filter = TRUE AND document_id = $document_id) | ||||
|         OR $doc_filter = FALSE | ||||
|         CAST($doc_filter AS BOOLEAN) = TRUE | ||||
|         AND document_id = $document_id | ||||
|     ) | ||||
|     OR $doc_filter = FALSE | ||||
| ORDER BY start_time DESC | ||||
| LIMIT $limit | ||||
| OFFSET $offset; | ||||
| @ -249,8 +258,9 @@ FROM capped_stats; | ||||
| 
 | ||||
| -- name: GetDocumentDaysRead :one | ||||
| WITH document_days AS ( | ||||
|     SELECT DATE(start_time, 'localtime') AS dates | ||||
|     SELECT DATE(start_time, time_offset) AS dates | ||||
|     FROM rescaled_activity | ||||
|     JOIN users ON users.id = rescaled_activity.user_id | ||||
|     WHERE document_id = $document_id | ||||
|     AND user_id = $user_id | ||||
|     GROUP BY dates | ||||
| @ -261,12 +271,11 @@ FROM document_days; | ||||
| -- name: GetUserWindowStreaks :one | ||||
| WITH document_windows AS ( | ||||
|     SELECT CASE | ||||
|       -- TODO: Timezones! E.g. DATE(start_time, '-5 hours') | ||||
|       -- TODO: Timezones! E.g. DATE(start_time, '-5 hours', '-7 days') | ||||
|       WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day') | ||||
|       WHEN ?2 = "DAY" THEN DATE(start_time) | ||||
|       WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day', time_offset) | ||||
|       WHEN ?2 = "DAY" THEN DATE(start_time, time_offset) | ||||
|     END AS read_window | ||||
|     FROM activity | ||||
|     JOIN users ON users.id = activity.user_id | ||||
|     WHERE user_id = $user_id | ||||
|     AND CAST($window AS TEXT) = CAST($window AS TEXT) | ||||
|     GROUP BY read_window | ||||
| @ -287,8 +296,8 @@ streaks AS ( | ||||
|         MAX(read_window) AS end_date | ||||
|     FROM partitions | ||||
|     GROUP BY CASE | ||||
| 	WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day') | ||||
| 	WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day') | ||||
|         WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day') | ||||
|         WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day') | ||||
|     END | ||||
|     ORDER BY end_date DESC | ||||
| ), | ||||
| @ -331,8 +340,9 @@ SELECT | ||||
| LIMIT 1; | ||||
| 
 | ||||
| -- name: GetDailyReadStats :many | ||||
| WITH RECURSIVE last_30_days (date) AS ( | ||||
|     SELECT DATE('now') AS date | ||||
| WITH RECURSIVE last_30_days AS ( | ||||
|     SELECT DATE('now', time_offset) AS date | ||||
|     FROM users WHERE users.id = $user_id | ||||
|     UNION ALL | ||||
|     SELECT DATE(date, '-1 days') | ||||
|     FROM last_30_days | ||||
| @ -341,8 +351,9 @@ WITH RECURSIVE last_30_days (date) AS ( | ||||
| activity_records AS ( | ||||
|     SELECT | ||||
|         sum(duration) AS seconds_read, | ||||
|         DATE(start_time, 'localtime') AS day | ||||
|         DATE(start_time, time_offset) AS day | ||||
|     FROM activity | ||||
|     LEFT JOIN users ON users.id = activity.user_id | ||||
|     WHERE user_id = $user_id | ||||
|     GROUP BY day | ||||
|     ORDER BY day DESC | ||||
| @ -358,11 +369,3 @@ FROM last_30_days | ||||
| LEFT JOIN activity_records ON activity_records.day == last_30_days.date | ||||
| ORDER BY date DESC | ||||
| 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,27 +96,48 @@ func (q *Queries) DeleteDocument(ctx context.Context, id string) (int64, error) | ||||
| } | ||||
| 
 | ||||
| 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 | ||||
|     user_id = ?1 | ||||
|     activity.user_id = ?1 | ||||
|     AND ( | ||||
|         (?2 = TRUE AND document_id = ?3) | ||||
|         OR ?2 = FALSE | ||||
|         CAST(?2 AS BOOLEAN) = TRUE | ||||
|         AND document_id = ?3 | ||||
|     ) | ||||
|     OR ?2 = FALSE | ||||
| ORDER BY start_time DESC | ||||
| LIMIT ?5 | ||||
| OFFSET ?4 | ||||
| ` | ||||
| 
 | ||||
| type GetActivityParams struct { | ||||
| 	UserID     string      `json:"user_id"` | ||||
| 	DocFilter  interface{} `json:"doc_filter"` | ||||
| 	DocumentID string      `json:"document_id"` | ||||
| 	Offset     int64       `json:"offset"` | ||||
| 	Limit      int64       `json:"limit"` | ||||
| 	UserID     string `json:"user_id"` | ||||
| 	DocFilter  bool   `json:"doc_filter"` | ||||
| 	DocumentID string `json:"document_id"` | ||||
| 	Offset     int64  `json:"offset"` | ||||
| 	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, | ||||
| 		arg.UserID, | ||||
| 		arg.DocFilter, | ||||
| @ -128,19 +149,17 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Act | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	var items []Activity | ||||
| 	var items []GetActivityRow | ||||
| 	for rows.Next() { | ||||
| 		var i Activity | ||||
| 		var i GetActivityRow | ||||
| 		if err := rows.Scan( | ||||
| 			&i.ID, | ||||
| 			&i.UserID, | ||||
| 			&i.DocumentID, | ||||
| 			&i.DeviceID, | ||||
| 			&i.StartTime, | ||||
| 			&i.Title, | ||||
| 			&i.Author, | ||||
| 			&i.Duration, | ||||
| 			&i.CurrentPage, | ||||
| 			&i.TotalPages, | ||||
| 			&i.CreatedAt, | ||||
| 		); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @ -156,8 +175,9 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Act | ||||
| } | ||||
| 
 | ||||
| const getDailyReadStats = `-- name: GetDailyReadStats :many | ||||
| WITH RECURSIVE last_30_days (date) AS ( | ||||
|     SELECT DATE('now') AS date | ||||
| WITH RECURSIVE last_30_days AS ( | ||||
|     SELECT DATE('now', time_offset) AS date | ||||
|     FROM users WHERE users.id = ?1 | ||||
|     UNION ALL | ||||
|     SELECT DATE(date, '-1 days') | ||||
|     FROM last_30_days | ||||
| @ -166,8 +186,9 @@ WITH RECURSIVE last_30_days (date) AS ( | ||||
| activity_records AS ( | ||||
|     SELECT | ||||
|         sum(duration) AS seconds_read, | ||||
|         DATE(start_time, 'localtime') AS day | ||||
|         DATE(start_time, time_offset) AS day | ||||
|     FROM activity | ||||
|     LEFT JOIN users ON users.id = activity.user_id | ||||
|     WHERE user_id = ?1 | ||||
|     GROUP BY day | ||||
|     ORDER BY day DESC | ||||
| @ -372,8 +393,9 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document, | ||||
| 
 | ||||
| const getDocumentDaysRead = `-- name: GetDocumentDaysRead :one | ||||
| WITH document_days AS ( | ||||
|     SELECT DATE(start_time, 'localtime') AS dates | ||||
|     SELECT DATE(start_time, time_offset) AS dates | ||||
|     FROM rescaled_activity | ||||
|     JOIN users ON users.id = rescaled_activity.user_id | ||||
|     WHERE document_id = ?1 | ||||
|     AND user_id = ?2 | ||||
|     GROUP BY dates | ||||
| @ -521,7 +543,7 @@ WITH true_progress AS ( | ||||
|         total_pages, | ||||
|         ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage | ||||
|     FROM activity | ||||
|     WHERE user_id = ?3 | ||||
|     WHERE user_id = ?1 | ||||
|     GROUP BY document_id | ||||
|     HAVING MAX(start_time) | ||||
| ) | ||||
| @ -531,10 +553,7 @@ SELECT | ||||
|     CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page, | ||||
|     CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages, | ||||
|     CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes, | ||||
| 
 | ||||
|     CAST( | ||||
|         STRFTIME('%Y-%m-%dT%H:%M:%SZ', IFNULL(last_read, "1970-01-01") | ||||
|     ) AS TEXT) AS last_read, | ||||
|     CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read, | ||||
| 
 | ||||
|     CAST(CASE | ||||
|         WHEN percentage > 97.0 THEN 100.0 | ||||
| @ -543,16 +562,17 @@ SELECT | ||||
|     END AS REAL) AS percentage | ||||
| 
 | ||||
| FROM documents | ||||
| LEFT JOIN true_progress ON document_id = id | ||||
| ORDER BY last_read DESC, created_at DESC | ||||
| LIMIT ?2 | ||||
| OFFSET ?1 | ||||
| LEFT JOIN true_progress ON true_progress.document_id = documents.id | ||||
| LEFT JOIN users ON users.id = ?1 | ||||
| ORDER BY true_progress.last_read DESC, documents.created_at DESC | ||||
| LIMIT ?3 | ||||
| OFFSET ?2 | ||||
| ` | ||||
| 
 | ||||
| type GetDocumentsWithStatsParams struct { | ||||
| 	UserID string `json:"user_id"` | ||||
| 	Offset int64  `json:"offset"` | ||||
| 	Limit  int64  `json:"limit"` | ||||
| 	UserID string `json:"user_id"` | ||||
| } | ||||
| 
 | ||||
| type GetDocumentsWithStatsRow struct { | ||||
| @ -578,7 +598,7 @@ type GetDocumentsWithStatsRow struct { | ||||
| } | ||||
| 
 | ||||
| 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 { | ||||
| 		return nil, err | ||||
| 	} | ||||
| @ -742,7 +762,7 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) (GetPr | ||||
| } | ||||
| 
 | ||||
| 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 | ||||
| ` | ||||
| 
 | ||||
| @ -753,6 +773,7 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) { | ||||
| 		&i.ID, | ||||
| 		&i.Pass, | ||||
| 		&i.Admin, | ||||
| 		&i.TimeOffset, | ||||
| 		&i.CreatedAt, | ||||
| 	) | ||||
| 	return i, err | ||||
| @ -761,12 +782,11 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) { | ||||
| const getUserWindowStreaks = `-- name: GetUserWindowStreaks :one | ||||
| WITH document_windows AS ( | ||||
|     SELECT CASE | ||||
|       -- TODO: Timezones! E.g. DATE(start_time, '-5 hours') | ||||
|       -- TODO: Timezones! E.g. DATE(start_time, '-5 hours', '-7 days') | ||||
|       WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day') | ||||
|       WHEN ?2 = "DAY" THEN DATE(start_time) | ||||
|       WHEN ?2 = "WEEK" THEN STRFTIME('%Y-%m-%d', start_time, 'weekday 0', '-7 day', time_offset) | ||||
|       WHEN ?2 = "DAY" THEN DATE(start_time, time_offset) | ||||
|     END AS read_window | ||||
|     FROM activity | ||||
|     JOIN users ON users.id = activity.user_id | ||||
|     WHERE user_id = ?1 | ||||
|     AND CAST(?2 AS TEXT) = CAST(?2 AS TEXT) | ||||
|     GROUP BY read_window | ||||
| @ -787,8 +807,8 @@ streaks AS ( | ||||
|         MAX(read_window) AS end_date | ||||
|     FROM partitions | ||||
|     GROUP BY CASE | ||||
| 	WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day') | ||||
| 	WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day') | ||||
|         WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day') | ||||
|         WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day') | ||||
|     END | ||||
|     ORDER BY end_date DESC | ||||
| ), | ||||
| @ -852,7 +872,7 @@ func (q *Queries) GetUserWindowStreaks(ctx context.Context, arg GetUserWindowStr | ||||
| } | ||||
| 
 | ||||
| const getUsers = `-- name: GetUsers :many | ||||
| SELECT id, pass, admin, created_at FROM users | ||||
| SELECT id, pass, admin, time_offset, created_at FROM users | ||||
| WHERE | ||||
|     users.id = ?1 | ||||
|     OR ?1 IN ( | ||||
| @ -885,6 +905,7 @@ func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, err | ||||
| 			&i.ID, | ||||
| 			&i.Pass, | ||||
| 			&i.Admin, | ||||
| 			&i.TimeOffset, | ||||
| 			&i.CreatedAt, | ||||
| 		); err != nil { | ||||
| 			return nil, err | ||||
| @ -904,7 +925,7 @@ const getWantedDocuments = `-- name: GetWantedDocuments :many | ||||
| SELECT | ||||
|     CAST(value AS TEXT) AS id, | ||||
|     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) | ||||
| LEFT JOIN documents | ||||
| ON value = documents.id | ||||
| @ -912,8 +933,8 @@ WHERE ( | ||||
|     documents.id IS NOT NULL | ||||
|     AND documents.deleted = false | ||||
|     AND ( | ||||
| 	documents.synced = false | ||||
| 	OR documents.filepath IS NULL | ||||
|         documents.synced = false | ||||
|         OR documents.filepath IS NULL | ||||
|     ) | ||||
| ) | ||||
| OR (documents.id IS NULL) | ||||
|  | ||||
| @ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS users ( | ||||
| 
 | ||||
|     pass TEXT NOT NULL, | ||||
|     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 | ||||
| ); | ||||
|  | ||||
| @ -1,4 +1,56 @@ | ||||
| {{template "base.html" .}} {{define "title"}}Activity{{end}} {{define | ||||
| "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}} | ||||
|  | ||||
| @ -11,9 +11,28 @@ | ||||
|       class="relative h-screen overflow-hidden bg-gray-100 dark:bg-gray-800" | ||||
|     > | ||||
|       <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="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> | ||||
|             </div> | ||||
|             <nav class="mt-6"> | ||||
| @ -100,8 +119,9 @@ | ||||
|         <div class="flex flex-col w-full"> | ||||
|           <header class="z-40 flex items-center justify-between w-full h-16"> | ||||
|             <div class="block ml-6 lg:hidden"> | ||||
|               <button | ||||
|                 class="flex items-center p-2 text-gray-500 bg-white rounded-full shadow text-md" | ||||
|               <label | ||||
| 		for="mobile-nav-button" | ||||
|                 class="flex items-center p-2 text-gray-500 bg-white rounded-full shadow text-md cursor-pointer" | ||||
|               > | ||||
|                 <svg | ||||
|                   width="20" | ||||
| @ -115,67 +135,71 @@ | ||||
|                     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> | ||||
|               </button> | ||||
|               </label> | ||||
|             </div> | ||||
| 	    <h1 class="text-xl font-bold dark:text-white px-6">{{block "title" .}}{{end}}</h1> | ||||
| 	    <div | ||||
| 	      class="relative flex items-center justify-end w-full p-4 space-x-4" | ||||
| 	    > | ||||
| 	      <a href="#" class="relative block"> | ||||
| 		    <svg | ||||
| 		      width="20" | ||||
| 		      fill="currentColor" | ||||
| 		      height="20" | ||||
| 		      class="text-gray-800 dark:text-gray-200" | ||||
| 		      viewBox="0 0 1792 1792" | ||||
| 		      xmlns="http://www.w3.org/2000/svg" | ||||
| 		    > | ||||
| 		      <path | ||||
| 			d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z" | ||||
| 		      /> | ||||
| 		    </svg> | ||||
| 	      </a> | ||||
| 	      <button | ||||
| 		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" | ||||
| 		  height="20" | ||||
| 		  class="text-gray-800 dark:text-gray-200" | ||||
| 		  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> | ||||
| 		    d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z" | ||||
| 		  /> | ||||
| 		</svg> | ||||
| 	      </button> | ||||
|                 <div | ||||
|                   class="custom-profile-dropdown transition duration-200 absolute right-4 top-16 pt-4" | ||||
|                 > | ||||
| 	      </a> | ||||
| 	      <input type="checkbox" id="user-dropdown-button" class="hidden"/> | ||||
| 	      <div | ||||
| 		id="user-dropdown" | ||||
| 		class="transition duration-200 absolute right-4 top-16 pt-4" | ||||
| 	      > | ||||
| 		<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" | ||||
| 		> | ||||
| 		  <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="py-1" | ||||
| 		    role="menu" | ||||
| 		    aria-orientation="vertical" | ||||
| 		    aria-labelledby="options-menu" | ||||
| 		  > | ||||
| 		    <div | ||||
| 		      class="py-1" | ||||
| 		      role="menu" | ||||
| 		      aria-orientation="vertical" | ||||
| 		      aria-labelledby="options-menu" | ||||
| 		    <a | ||||
| 		      href="/logout" | ||||
| 		      class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600" | ||||
| 		      role="menuitem" | ||||
| 		    > | ||||
| 		      <a | ||||
| 			href="/logout" | ||||
| 			class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600" | ||||
| 			role="menuitem" | ||||
| 		      > | ||||
| 			<span class="flex flex-col"> | ||||
| 			  <span>Logout</span> | ||||
| 			</span> | ||||
| 		      </a> | ||||
| 		    </div> | ||||
| 		      <span class="flex flex-col"> | ||||
| 			<span>Logout</span> | ||||
| 		      </span> | ||||
| 		    </a> | ||||
| 		  </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> | ||||
|           </header> | ||||
|           <div class="h-screen px-4 pb-24 overflow-auto md:px-6"> | ||||
| @ -187,16 +211,26 @@ | ||||
| 
 | ||||
|     <!-- Custom Animation CSS --> | ||||
|     <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; | ||||
|         opacity: 1; | ||||
|       } | ||||
| 
 | ||||
|       #user-dropdown { | ||||
| 	visibility: hidden; | ||||
| 	opacity: 0; | ||||
|       } | ||||
|     </style> | ||||
|   </body> | ||||
| </html> | ||||
|  | ||||
| @ -1,12 +1,12 @@ | ||||
| {{template "base.html" .}} {{define "title"}}Documents{{end}} {{define | ||||
| "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 }} | ||||
|   <div class="w-full"> | ||||
|     <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"> | ||||
| 	<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> | ||||
|       </div> | ||||
|       <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"}} | ||||
| 
 | ||||
| <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 | ||||
|       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 | ||||
|     </p> | ||||
| 
 | ||||
|     {{ $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 --> | ||||
|       <path | ||||
|         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="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 | ||||
|         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 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 | ||||
|         class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500" | ||||
|       > | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user