diff --git a/backend/czech-clubs-logos-api b/backend/czech-clubs-logos-api index 8adbcf6..3ae9897 100644 Binary files a/backend/czech-clubs-logos-api and b/backend/czech-clubs-logos-api differ diff --git a/backend/data/db.sqlite b/backend/data/db.sqlite new file mode 100644 index 0000000..5cc399a Binary files /dev/null and b/backend/data/db.sqlite differ diff --git a/backend/go.mod b/backend/go.mod index 5f36217..018bfa6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,15 +3,18 @@ module czech-clubs-logos-api go 1.21 require ( + github.com/PuerkitoBio/goquery v1.9.2 github.com/gin-contrib/cors v1.7.0 github.com/gin-gonic/gin v1.9.1 github.com/google/uuid v1.5.0 github.com/mattn/go-sqlite3 v1.14.19 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef + golang.org/x/text v0.14.0 ) require ( + github.com/andybalholm/cascadia v1.3.2 // indirect github.com/bytedance/sonic v1.11.2 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect @@ -32,11 +35,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.7.0 // indirect - golang.org/x/crypto v0.21.0 // indirect + golang.org/x/crypto v0.22.0 // indirect golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index a7210f5..bbacf8f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A= @@ -81,23 +85,55 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= diff --git a/backend/handlers.go b/backend/handlers.go index e593146..36d3088 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -1,35 +1,45 @@ package main import ( + "bytes" "database/sql" "fmt" "log" "net/http" + neturl "net/url" "os" "path/filepath" "strconv" "strings" "time" + "unicode" + + "github.com/PuerkitoBio/goquery" "github.com/gin-gonic/gin" "github.com/google/uuid" + "golang.org/x/text/unicode/norm" ) -var facrClient = NewFACRClient() - // ==================== Club Handlers ==================== func searchClubs(c *gin.Context) { - query := c.Query("q") - if query == "" { + q := strings.TrimSpace(c.Query("q")) + if q == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"}) return } - clubs, err := facrClient.SearchClubs(query) - if err != nil { - // Return demo data if FAČR API is unavailable - c.JSON(http.StatusOK, getDemoClubs(query)) + clubs, err := scrapeFotbalSearch(q) + if err != nil || len(clubs) == 0 { + nq := removeDiacritics(strings.ToLower(q)) + if nq != strings.ToLower(q) { + if c2, err2 := scrapeFotbalSearch(nq); err2 == nil && len(c2) > 0 { + c.JSON(http.StatusOK, c2) + return + } + } + c.JSON(http.StatusOK, getDemoClubs(q)) return } @@ -43,8 +53,8 @@ func getClub(c *gin.Context) { return } - club, err := facrClient.GetClub(id) - if err != nil { + club, err := fetchClubByID(id) + if err != nil || club == nil { c.JSON(http.StatusNotFound, gin.H{"error": "club not found"}) return } @@ -52,6 +62,198 @@ func getClub(c *gin.Context) { c.JSON(http.StatusOK, club) } +type ClubSearchWithLogoResult struct { + ID string `json:"id"` + Name string `json:"name"` + LogoURL string `json:"logo_url,omitempty"` + HasLocalLogo bool `json:"has_local_logo"` +} + +func searchClubsWithLogos(c *gin.Context) { + q := strings.TrimSpace(c.Query("q")) + if q == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"}) + return + } + + sport := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sport", c.DefaultQuery("type", "")))) + + base := "SELECT id, club_name, has_svg, has_png FROM logos" + where := "" + args := []interface{}{} + if q != "" { + where = " WHERE (LOWER(club_name) LIKE ? OR id LIKE ?)" + like := "%" + strings.ToLower(q) + "%" + args = append(args, like, "%"+q+"%") + } + if sport != "" && sport != "all" { + if where == "" { + where = " WHERE " + } else { + where += " AND " + } + where += "LOWER(club_type) = ?" + args = append(args, sport) + } + + query := base + where + " ORDER BY club_name" + rows, err := db.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + defer rows.Close() + + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host) + + results := []ClubSearchWithLogoResult{} + for rows.Next() { + var id, name string + var hasSVG, hasPNG int + if err := rows.Scan(&id, &name, &hasSVG, &hasPNG); err != nil { + continue + } + logoURL := "" + if hasPNG == 1 { + logoURL = fmt.Sprintf("%s/logos/%s?format=png", baseURL, id) + } else if hasSVG == 1 { + logoURL = fmt.Sprintf("%s/logos/%s?format=svg", baseURL, id) + } + res := ClubSearchWithLogoResult{ + ID: id, + Name: name, + LogoURL: logoURL, + HasLocalLogo: hasSVG == 1 || hasPNG == 1, + } + results = append(results, res) + } + + if err := rows.Err(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + c.JSON(http.StatusOK, results) +} + +func scrapeFotbalSearch(q string) ([]Club, error) { + vals := neturl.Values{} + vals.Set("q", q) + searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode() + req, _ := http.NewRequest("GET", searchURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8") + client := &http.Client{Timeout: 12 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + vals2 := neturl.Values{} + vals2.Set("q", "\""+q+"\"") + searchURL = "https://www.fotbal.cz/club/hledej?" + vals2.Encode() + req2, _ := http.NewRequest("GET", searchURL, nil) + req2.Header = req.Header.Clone() + resp2, err2 := client.Do(req2) + if err2 != nil { + return nil, err2 + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + return []Club{}, nil + } + resp = resp2 + } + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(buf.Bytes())) + if err != nil { + return nil, err + } + clubs := []Club{} + doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) { + a := li.Find("a.Link--inverted").First() + href := strings.TrimSpace(a.AttrOr("href", "")) + if href == "" { + return + } + name := strings.TrimSpace(a.Find("span.H7").First().Text()) + if name == "" { + name = strings.TrimSpace(a.Text()) + } + logoURL := strings.TrimSpace(a.Find("img").First().AttrOr("src", "")) + address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text()) + clubType := "football" + if strings.Contains(strings.ToLower(href), "/futsal/") { + clubType = "futsal" + } + parts := strings.Split(strings.TrimRight(href, "/"), "/") + clubID := "" + if len(parts) > 0 { + clubID = parts[len(parts)-1] + } + if !strings.HasPrefix(href, "http://") && !strings.HasPrefix(href, "https://") { + href = "https://www.fotbal.cz" + href + } + city := extractCityFromAddress(address) + clubs = append(clubs, Club{ID: clubID, Name: name, City: city, Type: clubType, Website: href, LogoURL: logoURL}) + }) + return clubs, nil +} + +func fetchClubByID(id string) (*Club, error) { + tryFetch := func(base string, typ string) (*Club, error) { + url := fmt.Sprintf("%s/%s", base, id) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8") + client := &http.Client{Timeout: 12 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, err + } + name := strings.TrimSpace(doc.Find("h1.H4 span").First().Text()) + address := strings.TrimSpace(doc.Find(".ClubAddress p").First().Text()) + city := extractCityFromAddress(address) + logo := fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", id, id) + return &Club{ID: id, Name: name, City: city, Type: typ, Website: "", LogoURL: logo}, nil + } + if club, err := tryFetch("https://www.fotbal.cz/souteze/club/club", "football"); err == nil && club != nil && club.Name != "" { + return club, nil + } + if club, err := tryFetch("https://www.fotbal.cz/futsal/club/club", "futsal"); err == nil && club != nil && club.Name != "" { + return club, nil + } + return nil, fmt.Errorf("not found") +} + +func removeDiacritics(s string) string { + d := norm.NFD.String(s) + b := make([]rune, 0, len(d)) + for _, r := range d { + if unicode.Is(unicode.Mn, r) { + continue + } + b = append(b, r) + } + return string(b) +} + // Demo data fallback func getDemoClubs(query string) []Club { demoClubs := []Club{ @@ -192,18 +394,18 @@ func getDemoClubs(query string) []Club { var results []Club lowerQuery := strings.ToLower(query) - + // Fuzzy matching: check contains in name, city, and partial matches for _, club := range demoClubs { lowerName := strings.ToLower(club.Name) lowerCity := strings.ToLower(club.City) - + // Exact contains match in name or city if strings.Contains(lowerName, lowerQuery) || strings.Contains(lowerCity, lowerQuery) { results = append(results, club) continue } - + // Fuzzy match: check if query matches start of any word in name words := strings.Fields(lowerName) for _, word := range words { @@ -220,21 +422,21 @@ func getDemoClubs(query string) []Club { // ==================== Logo Handlers ==================== type LogoMetadata struct { - ID string `json:"id"` - ClubName string `json:"club_name"` - ClubCity string `json:"club_city,omitempty"` - ClubType string `json:"club_type,omitempty"` - ClubWebsite string `json:"club_website,omitempty"` - HasSVG bool `json:"has_svg"` - HasPNG bool `json:"has_png"` - PrimaryFormat string `json:"primary_format"` - LogoURL string `json:"logo_url"` - LogoURLSVG string `json:"logo_url_svg,omitempty"` - LogoURLPNG string `json:"logo_url_png,omitempty"` - FileSizeSVG int64 `json:"file_size_svg,omitempty"` - FileSizePNG int64 `json:"file_size_png,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + ClubName string `json:"club_name"` + ClubCity string `json:"club_city,omitempty"` + ClubType string `json:"club_type,omitempty"` + ClubWebsite string `json:"club_website,omitempty"` + HasSVG bool `json:"has_svg"` + HasPNG bool `json:"has_png"` + PrimaryFormat string `json:"primary_format"` + LogoURL string `json:"logo_url"` + LogoURLSVG string `json:"logo_url_svg,omitempty"` + LogoURLPNG string `json:"logo_url_png,omitempty"` + FileSizeSVG int64 `json:"file_size_svg,omitempty"` + FileSizePNG int64 `json:"file_size_png,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // getLogo returns the logo file (PNG preferred, SVG fallback) @@ -349,7 +551,7 @@ func getLogoWithMetadata(c *gin.Context) { scheme = "https" } baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host) - + // Primary URL (PNG preferred) if metadata.HasPNG { metadata.LogoURL = fmt.Sprintf("%s/logos/%s?format=png", baseURL, id) @@ -371,6 +573,7 @@ func getLogoWithMetadata(c *gin.Context) { // List all logos func listLogos(c *gin.Context) { q := strings.TrimSpace(c.Query("q")) + sport := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sport", c.DefaultQuery("type", "")))) sortParam := c.DefaultQuery("sort", "name") limitStr := c.Query("limit") pageStr := c.Query("page") @@ -383,6 +586,14 @@ func listLogos(c *gin.Context) { like := "%" + strings.ToLower(q) + "%" args = append(args, like, like, "%"+q+"%") } + if sport != "" && sport != "all" { + if where == "" { + where = " WHERE LOWER(club_type) = ?" + } else { + where += " AND LOWER(club_type) = ?" + } + args = append(args, sport) + } order := " ORDER BY club_name" if sortParam == "recent" { order = " ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC" @@ -449,6 +660,65 @@ func listLogos(c *gin.Context) { logos = append(logos, logo) } + if q != "" && len(logos) == 0 { + limitClause2 := "" + args2 := []interface{}{} + if limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { + limitClause2 = " LIMIT ?" + args2 = append(args2, limit) + if pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil { + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + limitClause2 += " OFFSET ?" + args2 = append(args2, offset) + } + } + } + } + q2 := base + order + limitClause2 + rows2, err2 := db.Query(q2, args2...) + if err2 == nil { + defer rows2.Close() + normQ := removeDiacritics(strings.ToLower(q)) + tmp := []LogoMetadata{} + for rows2.Next() { + var logo LogoMetadata + var hasSVG2, hasPNG2 int + if err := rows2.Scan( + &logo.ID, + &logo.ClubName, + &logo.ClubCity, + &logo.ClubType, + &logo.ClubWebsite, + &hasSVG2, + &hasPNG2, + &logo.PrimaryFormat, + &logo.CreatedAt, + &logo.UpdatedAt, + ); err != nil { + continue + } + logo.HasSVG = hasSVG2 == 1 + logo.HasPNG = hasPNG2 == 1 + if logo.HasPNG { + logo.LogoURL = fmt.Sprintf("%s/logos/%s?format=png", baseURL, logo.ID) + } else if logo.HasSVG { + logo.LogoURL = fmt.Sprintf("%s/logos/%s?format=svg", baseURL, logo.ID) + } + nameN := removeDiacritics(strings.ToLower(logo.ClubName)) + cityN := removeDiacritics(strings.ToLower(logo.ClubCity)) + if strings.Contains(nameN, normQ) || strings.Contains(cityN, normQ) || strings.Contains(strings.ToLower(logo.ID), strings.ToLower(q)) { + tmp = append(tmp, logo) + } + } + logos = tmp + } + } + c.JSON(http.StatusOK, logos) } @@ -496,9 +766,8 @@ func uploadLogo(c *gin.Context) { clubType := c.PostForm("club_type") clubWebsite := c.PostForm("club_website") - // Derive metadata if missing if clubName == "" { - if club, err := facrClient.GetClub(id); err == nil && club != nil { + if club, err := fetchClubByID(id); err == nil && club != nil { if club.Name != "" { clubName = club.Name } @@ -536,16 +805,16 @@ func uploadLogo(c *gin.Context) { if ext == ".svg" || ext == ".pdf" { pngPath = filepath.Join("./logos/png", id+".png") - + if ext == ".svg" { svgPath = filepath.Join("./logos/svg", id+".svg") - + // Save SVG if err := c.SaveUploadedFile(file, svgPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save SVG file"}) return } - + // Get SVG file size if stat, err := os.Stat(svgPath); err == nil { sizeSVG = stat.Size() @@ -572,12 +841,12 @@ func uploadLogo(c *gin.Context) { // PDF file - convert directly to PNG pdfTempPath := filepath.Join("./logos/temp", id+".pdf") os.MkdirAll("./logos/temp", 0755) - + if err := c.SaveUploadedFile(file, pdfTempPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save PDF file"}) return } - + log.Printf("Converting PDF to PNG for club: %s", clubName) if err := ConvertPDFToPNG(pdfTempPath, pngPath, 512); err != nil { log.Printf("Error: Failed to convert PDF to PNG: %v", err) @@ -585,15 +854,15 @@ func uploadLogo(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to convert PDF to PNG"}) return } - + // Clean up temp PDF os.Remove(pdfTempPath) - + // Optimize PNG if err := OptimizePNG(pngPath); err != nil { log.Printf("Warning: Failed to optimize PNG: %v", err) } - + // Get PNG file size if stat, err := os.Stat(pngPath); err == nil { sizePNG = stat.Size() @@ -604,17 +873,17 @@ func uploadLogo(c *gin.Context) { } else { // PNG upload pngPath = filepath.Join("./logos/png", id+".png") - + if err := c.SaveUploadedFile(file, pngPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save PNG file"}) return } - + // Optimize PNG if err := OptimizePNG(pngPath); err != nil { log.Printf("Warning: Failed to optimize PNG: %v", err) } - + // Get PNG file size if stat, err := os.Stat(pngPath); err == nil { sizePNG = stat.Size() @@ -638,14 +907,14 @@ func uploadLogo(c *gin.Context) { } response := gin.H{ - "success": true, - "id": id, - "club_name": clubName, - "has_svg": hasSVG == 1, - "has_png": hasPNG == 1, - "size_svg": sizeSVG, - "size_png": sizePNG, - "message": "logo uploaded successfully", + "success": true, + "id": id, + "club_name": clubName, + "has_svg": hasSVG == 1, + "has_png": hasPNG == 1, + "size_svg": sizeSVG, + "size_png": sizePNG, + "message": "logo uploaded successfully", } c.JSON(http.StatusOK, response) diff --git a/backend/main.go b/backend/main.go index 5431d1b..ea9a68c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -3,8 +3,8 @@ package main import ( "database/sql" "log" - "os" "net/http" + "os" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -46,7 +46,7 @@ func main() { AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "Range", "Accept-Language", "Accept-Encoding", "Cache-Control", "Pragma", "If-Modified-Since"}, ExposeHeaders: []string{"*"}, AllowCredentials: false, - AllowOriginFunc: func(origin string) bool { return true }, + AllowOriginFunc: func(origin string) bool { return true }, })) // Routes @@ -89,6 +89,7 @@ func setupRoutes(r *gin.Engine) { clubs := r.Group("/clubs") { clubs.GET("/search", searchClubs) + clubs.GET("/search-with-logos", searchClubsWithLogos) clubs.GET("/:id", getClub) } diff --git a/frontend/admin.html b/frontend/admin.html index 4fd3dfc..fda1a31 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1,5 +1,5 @@ - +
@@ -13,10 +13,15 @@