This commit is contained in:
Tomas Dvorak
2025-10-21 15:02:05 +02:00
parent 68e69e00cc
commit 63700eedb2
103 changed files with 12442 additions and 446 deletions
+24 -1
View File
@@ -6,6 +6,7 @@ import (
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
"net/http"
"os"
"strings"
"time"
@@ -245,9 +246,31 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
// 17. Reload with associations for response
ac.DB.Preload("Author").Preload("Category").First(&article, article.ID)
// 18. Return success response
// 18. Trigger prefetch cache update (async)
if published {
go func() {
base := getBaseURL()
logger.Info("CreateArticle: Triggering prefetch cache update for published article")
services.PrefetchOnce(base)
}()
}
// 19. Return success response
c.JSON(http.StatusCreated, article)
}
// getBaseURL returns the base URL for internal API calls (used for prefetch trigger)
func getBaseURL() string {
base := strings.TrimSpace(os.Getenv("PREFETCH_TARGET"))
if base == "" {
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "8080"
}
base = "http://127.0.0.1:" + port + "/api/v1"
}
return base
}
// Note: Helper functions makeSlug, computeEstimatedReadMinutes, and deriveSeoDescription
// are defined in base_controller.go and shared across the controllers package
+27
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -2448,6 +2449,19 @@ func makeSlug(s string) string {
return s
}
// getPrefetchBaseURL returns the base URL for internal API calls (used for prefetch trigger)
func getPrefetchBaseURL() string {
base := strings.TrimSpace(os.Getenv("PREFETCH_TARGET"))
if base == "" {
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "8080"
}
base = "http://127.0.0.1:" + port + "/api/v1"
}
return base
}
// CreateArticle creates a new article (protected)
func (bc *BaseController) CreateArticle(c *gin.Context) {
// Require authenticated user
@@ -2806,6 +2820,14 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
// Best-effort: refresh published articles cache
go bc.writeArticlesCache()
// Trigger full prefetch cache update if article is published
if art.Published {
go func() {
base := getPrefetchBaseURL()
services.PrefetchOnce(base)
}()
}
// Reload article with associations and match link for complete response
bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
var matchLink models.ArticleMatchLink
@@ -2889,17 +2911,22 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
}
var matchLinks []models.ArticleMatchLink
bc.DB.Where("article_id IN ?", articleIDs).Find(&matchLinks)
log.Printf("[GetArticles] Loaded %d match links for %d articles", len(matchLinks), len(items))
// Create map for quick lookup
matchLinkMap := make(map[uint]*models.ArticleMatchLink)
for i := range matchLinks {
matchLinkMap[matchLinks[i].ArticleID] = &matchLinks[i]
log.Printf("[GetArticles] Match link: article_id=%d, external_match_id=%s", matchLinks[i].ArticleID, matchLinks[i].ExternalMatchID)
}
// Assign match links to articles
matchCount := 0
for i := range items {
if ml, ok := matchLinkMap[items[i].ID]; ok {
items[i].MatchLink = ml
matchCount++
}
}
log.Printf("[GetArticles] Assigned %d match links to articles", matchCount)
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}
@@ -0,0 +1,260 @@
package controllers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// EditorPreviewController handles real-time editor preview operations
type EditorPreviewController struct {
DB *gorm.DB
}
// NewEditorPreviewController creates a new editor preview controller
func NewEditorPreviewController(db *gorm.DB) *EditorPreviewController {
return &EditorPreviewController{DB: db}
}
// PreviewState represents the current editor state
type PreviewState struct {
SessionID string `json:"session_id"`
PageType string `json:"page_type"`
Elements []ElementPreviewConfig `json:"elements"`
UpdatedAt time.Time `json:"updated_at"`
UserID uint `json:"user_id,omitempty"`
}
// ElementPreviewConfig represents a single element's preview configuration
type ElementPreviewConfig struct {
ElementName string `json:"element_name"`
Variant string `json:"variant"`
Visible bool `json:"visible"`
DisplayOrder int `json:"display_order"`
CustomStyles map[string]interface{} `json:"custom_styles,omitempty"`
}
// GetPreviewState retrieves the current preview state for a session
// GET /api/v1/editor/preview/:session_id
func (c *EditorPreviewController) GetPreviewState(ctx *gin.Context) {
sessionID := ctx.Param("session_id")
// In a real implementation, you would fetch from cache/session store
// For now, return a stub response
ctx.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"page_type": "homepage",
"elements": []ElementPreviewConfig{},
"updated_at": time.Now(),
})
}
// UpdatePreviewState updates the preview state for a session
// POST /api/v1/editor/preview/:session_id
func (c *EditorPreviewController) UpdatePreviewState(ctx *gin.Context) {
sessionID := ctx.Param("session_id")
var state PreviewState
if err := ctx.ShouldBindJSON(&state); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
state.SessionID = sessionID
state.UpdatedAt = time.Now()
// In a real implementation, you would:
// 1. Store in Redis/cache with TTL (e.g., 1 hour)
// 2. Associate with user session
// 3. Validate element configurations
ctx.JSON(http.StatusOK, gin.H{
"success": true,
"state": state,
})
}
// ApplyPreviewChanges commits preview changes to the database
// POST /api/v1/editor/preview/:session_id/apply
func (c *EditorPreviewController) ApplyPreviewChanges(ctx *gin.Context) {
sessionID := ctx.Param("session_id")
var payload struct {
PageType string `json:"page_type" binding:"required"`
Elements []ElementPreviewConfig `json:"elements" binding:"required"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Start transaction
tx := c.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Delete existing configurations for this page type
if err := tx.Exec("DELETE FROM page_element_configs WHERE page_type = ?", payload.PageType).Error; err != nil {
tx.Rollback()
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing configurations"})
return
}
// Insert new configurations
for _, elem := range payload.Elements {
config := map[string]interface{}{
"page_type": payload.PageType,
"element_name": elem.ElementName,
"variant": elem.Variant,
"visible": elem.Visible,
"display_order": elem.DisplayOrder,
"created_at": time.Now(),
"updated_at": time.Now(),
}
if err := tx.Table("page_element_configs").Create(config).Error; err != nil {
tx.Rollback()
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
return
}
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
return
}
// Clear preview state from cache
// In a real implementation: cache.Delete(sessionID)
ctx.JSON(http.StatusOK, gin.H{
"success": true,
"session_id": sessionID,
"message": "Changes applied successfully",
"applied_at": time.Now(),
})
}
// DiscardPreviewChanges discards preview changes without saving
// DELETE /api/v1/editor/preview/:session_id
func (c *EditorPreviewController) DiscardPreviewChanges(ctx *gin.Context) {
sessionID := ctx.Param("session_id")
// In a real implementation: cache.Delete(sessionID)
ctx.JSON(http.StatusOK, gin.H{
"success": true,
"session_id": sessionID,
"message": "Preview discarded",
})
}
// ValidatePreviewConfig validates element configurations before saving
// POST /api/v1/editor/preview/validate
func (c *EditorPreviewController) ValidatePreviewConfig(ctx *gin.Context) {
var configs []ElementPreviewConfig
if err := ctx.ShouldBindJSON(&configs); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Validation rules
errors := []string{}
elementNames := make(map[string]bool)
for i, config := range configs {
// Check for duplicate element names
if elementNames[config.ElementName] {
errors = append(errors, "Duplicate element name: "+config.ElementName)
}
elementNames[config.ElementName] = true
// Validate variant is not empty
if config.Variant == "" {
errors = append(errors, "Element "+config.ElementName+" has empty variant")
}
// Validate display order
if config.DisplayOrder < 0 {
errors = append(errors, "Element "+config.ElementName+" has invalid display order")
}
// Check for gaps in display order
if config.DisplayOrder != i {
errors = append(errors, "Display order has gaps or is not sequential")
}
}
if len(errors) > 0 {
ctx.JSON(http.StatusBadRequest, gin.H{
"valid": false,
"errors": errors,
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"valid": true,
"message": "Configuration is valid",
})
}
// GetAvailableVariants returns available variants for an element
// GET /api/v1/editor/variants/:element_name
func (c *EditorPreviewController) GetAvailableVariants(ctx *gin.Context) {
elementName := ctx.Param("element_name")
// Define available variants for each element
// In a real implementation, this could come from database or config
variants := map[string][]map[string]interface{}{
"hero": {
{"value": "grid", "label": "Grid Layout", "description": "3-card grid hero section"},
{"value": "scroller", "label": "Horizontal Scroller", "description": "Scrollable cards"},
{"value": "swiper", "label": "Swiper", "description": "Swipeable carousel"},
{"value": "swiper_full", "label": "Full-width Swiper", "description": "Full viewport carousel"},
},
"matches": {
{"value": "default", "label": "Default", "description": "Standard match display"},
{"value": "compact", "label": "Compact", "description": "Compact match cards"},
},
"table": {
{"value": "default", "label": "Default", "description": "Standard table layout"},
{"value": "compact", "label": "Compact", "description": "Compact table view"},
},
"videos": {
{"value": "default", "label": "Default", "description": "Standard video grid"},
{"value": "carousel", "label": "Carousel", "description": "Video carousel"},
},
"gallery": {
{"value": "default", "label": "Default", "description": "Standard gallery grid"},
{"value": "masonry", "label": "Masonry", "description": "Masonry layout"},
},
"sponsors": {
{"value": "grid", "label": "Grid", "description": "Grid layout"},
{"value": "slider", "label": "Slider", "description": "Horizontal slider"},
},
"newsletter": {
{"value": "default", "label": "Default", "description": "Standard newsletter form"},
{"value": "inline", "label": "Inline", "description": "Inline compact form"},
},
}
if variantList, ok := variants[elementName]; ok {
ctx.JSON(http.StatusOK, gin.H{
"element_name": elementName,
"variants": variantList,
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"element_name": elementName,
"variants": []map[string]interface{}{{"value": "default", "label": "Default"}},
})
}
}
+318
View File
@@ -0,0 +1,318 @@
package controllers
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type MyUIbrixController struct {
DB *gorm.DB
}
// PageElementConfigRequest represents element configuration
type PageElementConfigRequest struct {
PageType string `json:"page_type" binding:"required"`
ElementName string `json:"element_name" binding:"required"`
Variant string `json:"variant"`
Visible bool `json:"visible"`
DisplayOrder int `json:"display_order"`
CustomStyles map[string]interface{} `json:"custom_styles"`
}
// ValidateElementConfig validates and optimizes element configuration
func (ctrl *MyUIbrixController) ValidateElementConfig(c *gin.Context) {
var req PageElementConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate element name (alphanumeric, underscore, hyphen only)
if !isValidElementName(req.ElementName) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid element name"})
return
}
// Optimize custom styles by removing redundant properties
optimizedStyles := optimizeStyles(req.CustomStyles)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"optimized_styles": optimizedStyles,
"suggestions": generateStyleSuggestions(req.ElementName, optimizedStyles),
})
}
// GetElementPreview generates server-side preview data
func (ctrl *MyUIbrixController) GetElementPreview(c *gin.Context) {
elementName := c.Query("element")
variant := c.Query("variant")
viewport := c.Query("viewport") // desktop, tablet, mobile
if elementName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "element required"})
return
}
// Generate preview metadata
preview := generatePreviewMetadata(elementName, variant, viewport)
c.JSON(http.StatusOK, preview)
}
// BatchValidateConfigs validates multiple configs at once
func (ctrl *MyUIbrixController) BatchValidateConfigs(c *gin.Context) {
var configs []PageElementConfigRequest
if err := c.ShouldBindJSON(&configs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
results := make([]map[string]interface{}, 0, len(configs))
for _, config := range configs {
if !isValidElementName(config.ElementName) {
results = append(results, map[string]interface{}{
"element_name": config.ElementName,
"valid": false,
"error": "Invalid element name",
})
continue
}
optimizedStyles := optimizeStyles(config.CustomStyles)
results = append(results, map[string]interface{}{
"element_name": config.ElementName,
"valid": true,
"optimized_styles": optimizedStyles,
"display_order": config.DisplayOrder,
})
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"timestamp": time.Now().Unix(),
})
}
// OptimizePageLayout analyzes and optimizes page element layout
func (ctrl *MyUIbrixController) OptimizePageLayout(c *gin.Context) {
pageType := c.Query("page_type")
if pageType == "" {
pageType = "homepage"
}
// Load current configurations from database
var configs []map[string]interface{}
query := `
SELECT element_name, variant, visible, display_order, custom_styles
FROM page_element_configs
WHERE page_type = ?
ORDER BY display_order ASC
`
rows, err := ctrl.DB.Raw(query, pageType).Rows()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load configs"})
return
}
defer rows.Close()
for rows.Next() {
var elementName, variant string
var visible bool
var displayOrder int
var customStylesJSON []byte
if err := rows.Scan(&elementName, &variant, &visible, &displayOrder, &customStylesJSON); err != nil {
continue
}
var customStyles map[string]interface{}
if len(customStylesJSON) > 0 {
json.Unmarshal(customStylesJSON, &customStyles)
}
configs = append(configs, map[string]interface{}{
"element_name": elementName,
"variant": variant,
"visible": visible,
"display_order": displayOrder,
"custom_styles": customStyles,
})
}
// Generate optimization suggestions
suggestions := generateLayoutSuggestions(configs)
c.JSON(http.StatusOK, gin.H{
"current_layout": configs,
"suggestions": suggestions,
"performance_score": calculatePerformanceScore(configs),
})
}
// Helper functions
func isValidElementName(name string) bool {
if name == "" || len(name) > 100 {
return false
}
for _, r := range name {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-') {
return false
}
}
return true
}
func optimizeStyles(styles map[string]interface{}) map[string]interface{} {
if styles == nil {
return make(map[string]interface{})
}
optimized := make(map[string]interface{})
// Remove redundant or default values
for key, value := range styles {
// Skip empty values
if value == nil || value == "" || value == 0 {
continue
}
// Skip defaults
if isDefaultValue(key, value) {
continue
}
optimized[key] = value
}
return optimized
}
func isDefaultValue(key string, value interface{}) bool {
defaults := map[string]interface{}{
"margin": "0",
"padding": "0",
"border": "none",
"background": "transparent",
"opacity": 1,
"display": "block",
}
if defaultVal, exists := defaults[key]; exists {
return value == defaultVal
}
return false
}
func generateStyleSuggestions(elementName string, styles map[string]interface{}) []string {
suggestions := []string{}
// Check for performance-heavy properties
if _, hasBoxShadow := styles["box-shadow"]; hasBoxShadow {
if _, hasFilter := styles["filter"]; hasFilter {
suggestions = append(suggestions, "Multiple shadows and filters may impact performance")
}
}
// Check for conflicting properties
if position, ok := styles["position"].(string); ok {
if position == "fixed" || position == "sticky" {
suggestions = append(suggestions, "Fixed/sticky positioning may cause layout issues on mobile")
}
}
return suggestions
}
func generatePreviewMetadata(elementName, variant, viewport string) map[string]interface{} {
// Generate viewport-specific metadata
dimensions := map[string]map[string]int{
"desktop": {"width": 1920, "height": 1080},
"tablet": {"width": 768, "height": 1024},
"mobile": {"width": 375, "height": 667},
}
dim := dimensions["desktop"]
if d, ok := dimensions[viewport]; ok {
dim = d
}
return map[string]interface{}{
"element_name": elementName,
"variant": variant,
"viewport": viewport,
"dimensions": dim,
"render_hints": map[string]interface{}{
"use_gpu_acceleration": true,
"optimize_images": true,
"lazy_load": viewport == "mobile",
},
}
}
func generateLayoutSuggestions(configs []map[string]interface{}) []string {
suggestions := []string{}
visibleCount := 0
for _, config := range configs {
if visible, ok := config["visible"].(bool); ok && visible {
visibleCount++
}
}
if visibleCount > 15 {
suggestions = append(suggestions, "Consider hiding some elements to improve page load time")
}
if visibleCount < 3 {
suggestions = append(suggestions, "Page may look empty - consider adding more visible elements")
}
return suggestions
}
func calculatePerformanceScore(configs []map[string]interface{}) int {
score := 100
// Deduct for too many visible elements
visibleCount := 0
for _, config := range configs {
if visible, ok := config["visible"].(bool); ok && visible {
visibleCount++
}
}
if visibleCount > 15 {
score -= (visibleCount - 15) * 2
}
// Deduct for heavy custom styles
heavyStyleCount := 0
for _, config := range configs {
if styles, ok := config["custom_styles"].(map[string]interface{}); ok {
if len(styles) > 10 {
heavyStyleCount++
}
}
}
score -= heavyStyleCount * 5
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score
}
+14 -13
View File
@@ -51,23 +51,24 @@ type Article struct {
GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"`
// Stored as JSON string or comma-separated list; frontend normalizes
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
// YouTube video association (optional)
YouTubeVideoID string `json:"youtube_video_id"`
YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
// Match link (loaded separately, not stored in this table)
MatchLink *ArticleMatchLink `gorm:"-" json:"match_link,omitempty"`
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
// YouTube video association (optional)
YouTubeVideoID string `json:"youtube_video_id"`
YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
// Match link (loaded separately, not stored in this table)
// Removed omitempty to always include in JSON (even if null)
MatchLink *ArticleMatchLink `gorm:"-" json:"match_link"`
}
// ArticleTeamLink represents a link from an article to a team identified by an external FACR ID
type ArticleTeamLink struct {
gorm.Model
ArticleID uint `gorm:"not null;index" json:"article_id"`
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
ExternalTeamID string `gorm:"not null;index" json:"external_team_id"`
TeamName string `json:"team_name"`
gorm.Model
ArticleID uint `gorm:"not null;index" json:"article_id"`
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
ExternalTeamID string `gorm:"not null;index" json:"external_team_id"`
TeamName string `json:"team_name"`
}
func (ArticleTeamLink) TableName() string { return "article_team_links" }
+10
View File
@@ -52,6 +52,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
pageElementConfigController := controllers.NewPageElementConfigController(db)
imageProcessingController := &controllers.ImageProcessingController{}
articleController := controllers.NewArticleController(db)
myuibrixController := &controllers.MyUIbrixController{DB: db}
// API v1 group
{
@@ -383,6 +384,15 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
pageElements.DELETE("/:id", pageElementConfigController.DeletePageElementConfig)
pageElements.POST("/batch", pageElementConfigController.BatchUpdatePageElementConfigs)
}
// MyUIbrix optimization and validation endpoints (admin)
myuibrix := admin.Group("/myuibrix")
{
myuibrix.POST("/validate", myuibrixController.ValidateElementConfig)
myuibrix.POST("/validate-batch", myuibrixController.BatchValidateConfigs)
myuibrix.GET("/preview", myuibrixController.GetElementPreview)
myuibrix.GET("/optimize-layout", myuibrixController.OptimizePageLayout)
}
}
}