package controllers import ( "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" database "goGin/app/database/config" "goGin/app/database/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // Hero payload type HeroPayload struct { Color string `json:"color" binding:"required"` Title string `json:"title"` Text1 string `json:"text1"` Text2 string `json:"text2"` Text4 string `json:"text4"` Text5 string `json:"text5"` Image string `json:"image"` IsActive *bool `json:"is_active"` } // AdminListHeroes godoc // @Summary Admin: List heroes // @Description Admin listing of heroes. Use ?soft=only to list deleted, ?soft=with to include deleted. // @Tags heroes // @Security BearerAuth // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Param soft query string false "Soft delete filter: only|with" // @Success 200 {object} controllers.HeroListResponse // @Failure 500 {object} map[string]string // @Router /api/v1/admin/heroes [get] func AdminListHeroes(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 200 { perPage = 200 } offset := (page - 1) * perPage soft := c.Query("soft") var query *gorm.DB if soft == "only" { query = database.DB.Unscoped().Model(&models.Hero{}).Where("deleted_at IS NOT NULL") } else if soft == "with" { query = database.DB.Unscoped().Model(&models.Hero{}) } else { query = database.DB.Model(&models.Hero{}) } var total int64 if err := query.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []models.Hero if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage}) } // AdminGetHero godoc // @Summary Admin: Get a hero by id // @Description Return a single hero by id // @Tags heroes // @Security BearerAuth // @Produce json // @Param id path int true "Hero ID" // @Success 200 {object} controllers.HeroResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/admin/heroes/{id} [get] func AdminGetHero(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var h models.Hero if err := database.DB.Unscoped().First(&h, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": h}) } // CreateHero godoc // @Summary Admin: Create a hero // @Description Create a new hero item (multipart/form-data) // @Tags heroes // @Security BearerAuth // @Accept multipart/form-data // @Produce json // @Param color formData string true "Color" // @Param title formData string false "Title" // @Param text1 formData string false "Text1" // @Param text2 formData string false "Text2" // @Param text4 formData string false "Text4" // @Param text5 formData string false "Text5" // @Param is_active formData boolean false "Is Active" // @Param width formData int false "Image width (frontend-provided)" // @Param height formData int false "Image height (frontend-provided)" // @Param quality formData int false "Image quality (frontend-provided)" // @Param format formData string false "Image format (jpeg|png|webp) (frontend-provided)" // @Param image formData file false "Image file" // @Success 201 {object} controllers.HeroResponse // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/heroes [post] func CreateHero(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } // Parse form fields color := c.PostForm("color") if color == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "color is required"}) return } title := c.PostForm("title") text1 := c.PostForm("text1") text2 := c.PostForm("text2") text4 := c.PostForm("text4") text5 := c.PostForm("text5") isActive := true if v := c.PostForm("is_active"); v != "" { if b, err := strconv.ParseBool(v); err == nil { isActive = b } } // optional frontend-provided image metadata var width, height, quality int if w := c.PostForm("width"); w != "" { if wi, err := strconv.Atoi(w); err == nil { width = wi } } if h := c.PostForm("height"); h != "" { if hi, err := strconv.Atoi(h); err == nil { height = hi } } if q := c.PostForm("quality"); q != "" { if qi, err := strconv.Atoi(q); err == nil { quality = qi } } format := c.PostForm("format") hero := models.Hero{ Color: color, Title: title, Text1: text1, Text2: text2, Text4: text4, Text5: text5, IsActive: isActive, Width: width, Height: height, Quality: quality, Format: format, } // handle file upload (no server-side image processing) file, err := c.FormFile("image") if err == nil { // ensure uploads/heroes exists uploadDir := filepath.Join("uploads", "heroes") if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) return } ext := filepath.Ext(file.Filename) newName := fmt.Sprintf("hero-%d%s", time.Now().UnixNano(), ext) destination := filepath.Join(uploadDir, newName) if err := c.SaveUploadedFile(file, destination); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } hero.Image = "/uploads/heroes/" + newName // do not attempt to decode/process image here; frontend provides metadata // if format not provided, fallback to extension without dot if heroFormat := format; heroFormat == "" { if ext != "" { hero.Format = ext[1:] } } } if err := database.DB.Create(&hero).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"data": hero}) } // UpdateHero godoc // @Summary Admin: Update a hero // @Description Update an existing hero (multipart/form-data) // @Tags heroes // @Security BearerAuth // @Accept multipart/form-data // @Produce json // @Param id path int true "Hero ID" // @Param color formData string false "Color" // @Param title formData string false "Title" // @Param text1 formData string false "Text1" // @Param text2 formData string false "Text2" // @Param text4 formData string false "Text4" // @Param text5 formData string false "Text5" // @Param is_active formData boolean false "Is Active" // @Param width formData int false "Image width (frontend-provided)" // @Param height formData int false "Image height (frontend-provided)" // @Param quality formData int false "Image quality (frontend-provided)" // @Param format formData string false "Image format (jpeg|png|webp) (frontend-provided)" // @Param image formData file false "Image file" // @Success 200 {object} controllers.HeroResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/heroes/{id} [put] func UpdateHero(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var h models.Hero if err := database.DB.First(&h, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // read form fields (if present) if color := c.PostForm("color"); color != "" { h.Color = color } if title := c.PostForm("title"); title != "" { h.Title = title } if t := c.PostForm("text1"); t != "" { h.Text1 = t } if t := c.PostForm("text2"); t != "" { h.Text2 = t } if t := c.PostForm("text4"); t != "" { h.Text4 = t } if t := c.PostForm("text5"); t != "" { h.Text5 = t } if v := c.PostForm("is_active"); v != "" { if b, err := strconv.ParseBool(v); err == nil { h.IsActive = b } } // optional frontend-provided image metadata if w := c.PostForm("width"); w != "" { if wi, err := strconv.Atoi(w); err == nil { h.Width = wi } } if hgt := c.PostForm("height"); hgt != "" { if hi, err := strconv.Atoi(hgt); err == nil { h.Height = hi } } if q := c.PostForm("quality"); q != "" { if qi, err := strconv.Atoi(q); err == nil { h.Quality = qi } } if fmtStr := c.PostForm("format"); fmtStr != "" { h.Format = fmtStr } // handle optional file upload (no server-side processing) file, err := c.FormFile("image") if err == nil { // Save new file first uploadDir := filepath.Join("uploads", "heroes") if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) return } ext := filepath.Ext(file.Filename) newName := fmt.Sprintf("hero-%d%s", time.Now().UnixNano(), ext) destination := filepath.Join(uploadDir, newName) if err := c.SaveUploadedFile(file, destination); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // If there was a previous image, attempt to remove it safely prev := h.Image if prev != "" { // normalize and ensure it's inside uploads/ prevPath := strings.TrimPrefix(prev, "/") clean := filepath.Clean(prevPath) // only remove files under uploads/ to avoid accidental deletions if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) { _ = os.Remove(clean) // ignore error } } h.Image = "/uploads/heroes/" + newName // if format not provided by frontend, fallback to extension if h.Format == "" && ext != "" { h.Format = ext[1:] } } if err := database.DB.Save(&h).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": h}) } // DeleteHero godoc // @Summary Admin: Delete a hero // @Description Soft-delete a hero by ID // @Tags heroes // @Security BearerAuth // @Produce json // @Param id path int true "Hero ID" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/heroes/{id} [delete] func DeleteHero(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var h models.Hero if err := database.DB.First(&h, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := database.DB.Delete(&h).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // attempt to remove image file if present if h.Image != "" { imgPath := strings.TrimPrefix(h.Image, "/") clean := filepath.Clean(imgPath) if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) { _ = os.Remove(clean) } } c.JSON(http.StatusOK, gin.H{"message": "hero deleted successfully", "id": h.ID}) } // RestoreHero godoc // @Summary Admin: Restore a soft-deleted hero // @Description Restore a soft-deleted hero by ID // @Tags heroes // @Security BearerAuth // @Produce json // @Param id path int true "Hero ID" // @Success 200 {object} controllers.HeroResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/admin/heroes/{id}/restore [post] func RestoreHero(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var h models.Hero // Find soft-deleted record with Unscoped if err := database.DB.Unscoped().Where("id = ?", id).First(&h).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Clear deleted_at (restore) if err := database.DB.Unscoped().Model(&models.Hero{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Reload the record in normal scope to ensure DeletedAt is nil in struct if err := database.DB.First(&h, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": h}) } // ListHeroes godoc // @Summary Public: List heroes // @Description Return active heroes with pagination // @Tags heroes // @Produce json // @Param page query int false "Page number" // @Param per_page query int false "Items per page" // @Success 200 {object} controllers.HeroListResponse // @Failure 500 {object} map[string]string // @Router /api/v1/heroes [get] func ListHeroes(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } pageStr := c.DefaultQuery("page", "1") perPageStr := c.DefaultQuery("per_page", "20") page, _ := strconv.Atoi(pageStr) perPage, _ := strconv.Atoi(perPageStr) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } if perPage > 100 { perPage = 100 } offset := (page - 1) * perPage query := database.DB.Model(&models.Hero{}).Where("is_active = ?", true) var total int64 if err := query.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var items []models.Hero if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage}) } // GetHero godoc // @Summary Public: Get a hero by id // @Description Return a single hero by id // @Tags heroes // @Produce json // @Param id path int true "Hero ID" // @Success 200 {object} controllers.HeroResponse // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /api/v1/heroes/{id} [get] func GetHero(c *gin.Context) { if database.DB == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) return } idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil || id < 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var h models.Hero if err := database.DB.First(&h, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": h}) }