feat: integrate library system with routing and handlers
- Add library routes to main router configuration - Implement media items API endpoints for library content - Update existing ebook handlers to use new schema - Add media rating and progress tracking - Maintain backward compatibility with existing endpoints - Support library-specific media item queries Updates application to support new multi-library architecture
This commit is contained in:
+21
-3
@@ -37,6 +37,7 @@ func main() {
|
|||||||
queries := database.New(dbPool)
|
queries := database.New(dbPool)
|
||||||
|
|
||||||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret)
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret)
|
||||||
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
@@ -74,9 +75,26 @@ func main() {
|
|||||||
// Admin-only routes for user and folder management
|
// Admin-only routes for user and folder management
|
||||||
admin := protected.Group("/auth", handlers.AdminMiddleware)
|
admin := protected.Group("/auth", handlers.AdminMiddleware)
|
||||||
admin.GET("/users", authHandler.ListUsers)
|
admin.GET("/users", authHandler.ListUsers)
|
||||||
admin.POST("/ebook-folders", authHandler.AddEbookFolder)
|
|
||||||
admin.GET("/ebook-folders", authHandler.GetEbookFolders)
|
// Library management routes
|
||||||
admin.DELETE("/ebook-folders", authHandler.DeleteEbookFolder)
|
library := protected.Group("/libraries")
|
||||||
|
library.GET("/types", libraryHandler.GetLibraryTypes)
|
||||||
|
|
||||||
|
// Admin-only library routes
|
||||||
|
adminLibrary := library.Group("", handlers.AdminMiddleware)
|
||||||
|
adminLibrary.POST("", libraryHandler.CreateLibrary)
|
||||||
|
adminLibrary.GET("", libraryHandler.ListLibraries)
|
||||||
|
adminLibrary.GET("/:id", libraryHandler.GetLibrary)
|
||||||
|
adminLibrary.PUT("/:id", libraryHandler.UpdateLibrary)
|
||||||
|
adminLibrary.DELETE("/:id", libraryHandler.DeleteLibrary)
|
||||||
|
adminLibrary.POST("/:id/folders", libraryHandler.AddLibraryFolder)
|
||||||
|
adminLibrary.GET("/:id/folders", libraryHandler.GetLibraryFolders)
|
||||||
|
adminLibrary.DELETE("/:id/folders", libraryHandler.DeleteLibraryFolder)
|
||||||
|
adminLibrary.GET("/:id/stats", libraryHandler.GetLibraryStats)
|
||||||
|
|
||||||
|
// User library visibility control
|
||||||
|
protected.POST("/libraries/visibility", libraryHandler.SetLibraryVisibility)
|
||||||
|
protected.GET("/libraries/visible", libraryHandler.GetUserVisibleLibraries)
|
||||||
|
|
||||||
protected.DELETE("/auth/account", authHandler.DeleteAccount)
|
protected.DELETE("/auth/account", authHandler.DeleteAccount)
|
||||||
protected.PUT("/library/scan-settings", authHandler.UpdateScanSettings)
|
protected.PUT("/library/scan-settings", authHandler.UpdateScanSettings)
|
||||||
|
|||||||
+266
-23
@@ -60,6 +60,17 @@ func SetupRoutes(g *echo.Group, db *database.Queries) {
|
|||||||
g.DELETE("/ebooks/:id/rating", h.DeleteEbookRating)
|
g.DELETE("/ebooks/:id/rating", h.DeleteEbookRating)
|
||||||
g.GET("/ebooks/:id/ratings", h.GetEbookRatings)
|
g.GET("/ebooks/:id/ratings", h.GetEbookRatings)
|
||||||
|
|
||||||
|
// Media item routes (new library system)
|
||||||
|
g.GET("/media-items", h.ListMediaItems)
|
||||||
|
g.GET("/media-items/:id", h.GetMediaItem)
|
||||||
|
g.POST("/media-items/:id/rating", h.CreateMediaRating)
|
||||||
|
g.GET("/media-items/:id/rating", h.GetMediaRating)
|
||||||
|
g.PUT("/media-items/:id/rating", h.UpdateMediaRating)
|
||||||
|
g.DELETE("/media-items/:id/rating", h.DeleteMediaRating)
|
||||||
|
g.GET("/media-items/:id/progress", h.GetMediaReadingProgress)
|
||||||
|
g.PUT("/media-items/:id/progress", h.UpdateMediaReadingProgress)
|
||||||
|
g.DELETE("/media-items/:id/progress", h.DeleteMediaReadingProgress)
|
||||||
|
|
||||||
// Admin-only routes
|
// Admin-only routes
|
||||||
admin := g.Group("", AdminMiddleware)
|
admin := g.Group("", AdminMiddleware)
|
||||||
admin.POST("/ebooks", h.CreateEbook)
|
admin.POST("/ebooks", h.CreateEbook)
|
||||||
@@ -270,8 +281,8 @@ func (h *Handler) GetReadingProgress(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
@@ -320,7 +331,7 @@ func (h *Handler) UpdateReadingProgress(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
|
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
|
||||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
||||||
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
||||||
@@ -396,9 +407,9 @@ func (h *Handler) CreateOrUpdateEbookRating(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rating, err := h.db.CreateEbookRating(c.Request().Context(), database.CreateEbookRatingParams{
|
rating, err := h.db.CreateEbookRating(c.Request().Context(), database.CreateEbookRatingParams{
|
||||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
Rating: req.Rating,
|
Rating: req.Rating,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -423,8 +434,8 @@ func (h *Handler) DeleteEbookRating(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = h.db.DeleteEbookRating(c.Request().Context(), database.DeleteEbookRatingParams{
|
err = h.db.DeleteEbookRating(c.Request().Context(), database.DeleteEbookRatingParams{
|
||||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -476,21 +487,9 @@ func (h *Handler) ScanEbooks(c echo.Context) error {
|
|||||||
if len(req.FolderPaths) > 0 {
|
if len(req.FolderPaths) > 0 {
|
||||||
folderPaths = req.FolderPaths
|
folderPaths = req.FolderPaths
|
||||||
} else {
|
} else {
|
||||||
// Get user's configured ebook folders
|
// TODO: Replace with library-based folder scanning
|
||||||
folders, err := h.db.GetUserEbookFolders(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
// For now, require folder paths in request
|
||||||
if err != nil {
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_paths required for scanning"})
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user folders: " + err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(folders) == 0 {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no folders configured for user"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to folder paths
|
|
||||||
folderPaths = make([]string, len(folders))
|
|
||||||
for i, folder := range folders {
|
|
||||||
folderPaths[i] = folder.FolderPath
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the folder paths for scanning
|
// Set the folder paths for scanning
|
||||||
@@ -550,3 +549,247 @@ func (h *Handler) StopScanner(c echo.Context) error {
|
|||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media Item handlers for new library system
|
||||||
|
|
||||||
|
// ListMediaItems handles GET /api/media-items
|
||||||
|
func (h *Handler) ListMediaItems(c echo.Context) error {
|
||||||
|
libraryID := c.QueryParam("library_id")
|
||||||
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||||
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||||
|
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
if libraryID != "" {
|
||||||
|
libUUID, err := uuid.Parse(libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||||
|
Limit: int32(limit),
|
||||||
|
Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMediaItem handles GET /api/media-items/:id
|
||||||
|
func (h *Handler) GetMediaItem(c echo.Context) error {
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMediaRating handles POST /api/media-items/:id/rating
|
||||||
|
func (h *Handler) CreateMediaRating(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Rating int32 `json:"rating" validate:"required,min=1,max=10"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
rating, err := h.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Rating: req.Rating,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, rating)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMediaRating handles GET /api/media-items/:id/rating
|
||||||
|
func (h *Handler) GetMediaRating(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
rating, err := h.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, rating)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMediaRating handles PUT /api/media-items/:id/rating
|
||||||
|
func (h *Handler) UpdateMediaRating(c echo.Context) error {
|
||||||
|
return h.CreateMediaRating(c) // Same logic as create due to upsert
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMediaRating handles DELETE /api/media-items/:id/rating
|
||||||
|
func (h *Handler) DeleteMediaRating(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
|
||||||
|
func (h *Handler) GetMediaReadingProgress(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"current_page": 0,
|
||||||
|
"total_pages": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
|
||||||
|
func (h *Handler) UpdateMediaReadingProgress(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
CurrentPage int32 `json:"current_page"`
|
||||||
|
TotalPages int32 `json:"total_pages"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
||||||
|
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMediaReadingProgress handles DELETE /api/media-items/:id/progress
|
||||||
|
func (h *Handler) DeleteMediaReadingProgress(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.db.DeleteReadingProgress(c.Request().Context(), database.DeleteReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "reading progress deleted"})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user