This commit is contained in:
Tomas Dvorak
2025-12-02 13:55:30 +01:00
10 changed files with 423 additions and 66 deletions
Binary file not shown.
+5 -4
View File
@@ -3,6 +3,7 @@ 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
@@ -13,6 +14,7 @@ require (
)
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
@@ -33,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
)
+42 -6
View File
@@ -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=
+56 -38
View File
@@ -13,6 +13,9 @@ import (
"strings"
"time"
"unicode"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/PuerkitoBio/goquery"
@@ -25,12 +28,24 @@ import (
// ==================== Club Handlers ====================
func searchClubs(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
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))
clubs, err := scrapeFotbalSearch(q)
if err != nil || len(clubs) == 0 {
nq := removeDiacritics(strings.ToLower(q))
@@ -54,6 +69,8 @@ func getClub(c *gin.Context) {
return
}
club, err := fetchClubByID(id)
if err != nil || club == nil {
club, err := fetchClubByID(id)
if err != nil || club == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "club not found"})
@@ -317,18 +334,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 {
@@ -345,21 +362,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)
@@ -474,7 +491,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)
@@ -496,6 +513,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")
@@ -715,16 +733,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()
@@ -751,12 +769,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)
@@ -764,15 +782,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()
@@ -783,17 +801,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()
@@ -817,14 +835,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)
+3 -2
View File
@@ -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)
}
+133 -4
View File
@@ -11,8 +11,61 @@ const FACR_API_URL = 'https://facr.tdvorak.dev'
const clubSearch = document.getElementById('clubSearch')
const searchResults = document.getElementById('searchResults')
const uploadSection = document.getElementById('uploadSection')
const clubSportFilterButtons = document.querySelectorAll('[data-club-sport-filter]')
const selectedClubSummary = document.getElementById('selectedClubSummary')
const selectedClubNameEl = document.getElementById('selectedClubName')
const selectedClubTypeEl = document.getElementById('selectedClubType')
const selectedClubCityEl = document.getElementById('selectedClubCity')
const selectedClubWebsiteEl = document.getElementById('selectedClubWebsite')
const selectedClubLogoEl = document.getElementById('selectedClubLogo')
let searchTimeout
let activeIndex = -1
let lastClubs = []
let clubSportFilter = 'all'
function normalizeText(s) {
return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
function highlight(text, query) {
const t = String(text || '')
const nq = normalizeText(query)
if (!nq) return t
const nt = normalizeText(t)
const idx = nt.indexOf(nq)
if (idx === -1) return t
let i = 0, oi = 0, start = -1, end = -1
while (oi < t.length && i <= idx + nq.length) {
const ch = t[oi]
const n = normalizeText(ch)
if (i === idx) start = oi
if (n) i += n.length
oi += 1
if (i >= idx + nq.length) { end = oi; break }
}
if (start === -1 || end === -1) return t
return t.slice(0, start) + '<span class="bg-accent-blue/20">' + t.slice(start, end) + '</span>' + t.slice(end)
}
function updateActive() {
const items = searchResults.querySelectorAll('.club-result')
items.forEach((el, i) => {
if (i === activeIndex) el.classList.add('ring-2', 'ring-accent-blue')
else el.classList.remove('ring-2', 'ring-accent-blue')
})
}
function updateClubSportFilterButtons() {
if (!clubSportFilterButtons || !clubSportFilterButtons.length) return
clubSportFilterButtons.forEach(btn => {
const value = (btn.dataset.clubSportFilter || 'all').toLowerCase()
const isActive = value === clubSportFilter
btn.classList.toggle('bg-accent-blue', isActive)
btn.classList.toggle('text-white', isActive)
btn.classList.toggle('bg-dark-bg', !isActive)
btn.classList.toggle('text-gray-300', !isActive)
})
}
clubSearch.addEventListener('input', (e) => {
clearTimeout(searchTimeout)
@@ -28,6 +81,27 @@ clubSearch.addEventListener('input', (e) => {
}, 300)
})
clubSearch.addEventListener('keydown', (e) => {
const total = searchResults.querySelectorAll('.club-result').length
if (!total) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex = (activeIndex + 1) % total
updateActive()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex = (activeIndex - 1 + total) % total
updateActive()
} else if (e.key === 'Enter') {
e.preventDefault()
if (activeIndex >= 0 && activeIndex < total) {
const item = searchResults.querySelectorAll('.club-result')[activeIndex]
const btn = item.querySelector('.select-club')
if (btn) btn.click(); else item.click()
}
}
})
async function searchClubs(query) {
searchResults.innerHTML = '<div class="text-center py-4"><div class="spinner mx-auto"></div></div>'
@@ -45,7 +119,8 @@ async function searchClubs(query) {
}
const clubs = await response.json()
await displaySearchResults(clubs)
lastClubs = Array.isArray(clubs) ? clubs : []
await displaySearchResults(lastClubs)
} catch (error) {
// Suppress console spam from HTML responses
@@ -86,7 +161,22 @@ async function displaySearchResults(clubs) {
// Silently fail - this is optional data
}
searchResults.innerHTML = clubs.map(club => {
const q = clubSearch.value.trim()
const nq = normalizeText(q)
let filtered = Array.isArray(clubs) ? clubs : []
if (nq) {
filtered = filtered.filter(c => {
const name = normalizeText(c.name)
const city = normalizeText(c.city)
const id = String(c.id || '').toLowerCase()
return name.includes(nq) || city.includes(nq) || id.includes(q.toLowerCase())
})
}
if (clubSportFilter && clubSportFilter !== 'all') {
filtered = filtered.filter(c => (c.type || '').toLowerCase() === clubSportFilter)
}
activeIndex = -1
searchResults.innerHTML = filtered.map(club => {
// Check if we have this logo in our API
const existingLogo = existingLogos.find(l => l.id === club.id)
@@ -121,12 +211,13 @@ async function displaySearchResults(clubs) {
`
}
const clubData = { ...club, display_logo_url: logoUrl }
return `
<div class="club-result bg-dark-bg rounded-lg p-4 border border-dark-border hover:border-accent-blue transition-smooth cursor-pointer" data-club='${JSON.stringify(club)}' data-logo-url='${logoUrl}'>
<div class="club-result bg-dark-bg rounded-lg p-4 border border-dark-border hover:border-accent-blue transition-smooth cursor-pointer" data-club='${JSON.stringify(clubData)}'>
<div class="flex items-center gap-4">
${logoHtml}
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg truncate">${club.name}</h3>
<h3 class="font-semibold text-lg truncate">${highlight(club.name, q)}</h3>
<p class="text-sm text-gray-400">${club.type || 'football'}</p>
<p class="text-xs text-gray-500 font-mono mt-1 truncate">${club.id}</p>
${club.website ? `<p class="text-xs text-blue-400 mt-1 truncate">${club.website}</p>` : ''}
@@ -169,6 +260,27 @@ function selectClub(club) {
document.getElementById('clubName').value = club.name
document.getElementById('clubType').value = club.type || 'football'
document.getElementById('clubWebsite').value = club.website || ''
// Update summary card
if (selectedClubSummary && selectedClubNameEl && selectedClubTypeEl && selectedClubCityEl && selectedClubWebsiteEl && selectedClubLogoEl) {
selectedClubNameEl.textContent = club.name || ''
selectedClubTypeEl.textContent = (club.type || 'football').toUpperCase()
selectedClubCityEl.textContent = club.city || ''
if (club.website) {
selectedClubWebsiteEl.innerHTML = `<a href="${club.website}" target="_blank" class="hover:underline">${club.website}</a>`
} else {
selectedClubWebsiteEl.textContent = ''
}
const displayLogo = club.display_logo_url || club.logo_url || ''
if (displayLogo) {
selectedClubLogoEl.innerHTML = `<img src="${displayLogo}" alt="${club.name || ''}" class="max-w-full max-h-full object-contain rounded-md">`
} else {
selectedClubLogoEl.textContent = '🏟️'
}
selectedClubSummary.classList.remove('hidden')
}
// Show upload section
uploadSection.classList.remove('hidden')
@@ -187,6 +299,23 @@ function selectClub(club) {
showNotification(`Vybráno: ${club.name}`, 'success')
}
if (clubSportFilterButtons && clubSportFilterButtons.length) {
updateClubSportFilterButtons()
clubSportFilterButtons.forEach(btn => {
btn.addEventListener('click', () => {
const value = (btn.dataset.clubSportFilter || 'all').toLowerCase()
if (value === clubSportFilter) return
clubSportFilter = value
updateClubSportFilterButtons()
if (lastClubs.length) {
displaySearchResults(lastClubs)
} else if (clubSearch.value.trim().length >= 2) {
searchClubs(clubSearch.value.trim())
}
})
})
}
// ==================== Website Search ====================
const searchWebsiteBtn = document.getElementById('searchWebsite')
+4
View File
@@ -0,0 +1,4 @@
import './style.css'
import './theme.js'
console.log('🇨🇿 České Kluby Loga API - API Docs')
+28
View File
@@ -8,12 +8,14 @@ const loading = document.getElementById('allLoading')
const empty = document.getElementById('allEmpty')
const loadMoreBtn = document.getElementById('loadMoreBtn')
const searchInput = document.getElementById('allLogoSearch')
const sportFilterButtons = document.querySelectorAll('[data-sport-filter]')
let page = 1
const limit = 20
let query = ''
let isLoading = false
let hasMore = true
let sport = 'all'
async function loadPage(reset = false) {
if (isLoading) return
@@ -35,6 +37,7 @@ async function loadPage(reset = false) {
url.searchParams.set('limit', String(limit))
url.searchParams.set('page', String(page))
if (query) url.searchParams.set('q', query)
if (sport && sport !== 'all') url.searchParams.set('sport', sport)
const resp = await fetch(url.toString().replace(window.location.origin, ''))
if (!resp.ok) throw new Error('Failed to fetch logos')
@@ -89,6 +92,18 @@ function appendCards(items) {
grid.insertAdjacentHTML('beforeend', html)
}
function updateSportFilterButtons() {
if (!sportFilterButtons || !sportFilterButtons.length) return
sportFilterButtons.forEach(btn => {
const value = btn.dataset.sportFilter || 'all'
const isActive = value === sport
btn.classList.toggle('bg-accent-blue', isActive)
btn.classList.toggle('text-white', isActive)
btn.classList.toggle('bg-dark-card', !isActive)
btn.classList.toggle('text-gray-300', !isActive)
})
}
grid.addEventListener('click', async (e) => {
const delBtn = e.target.closest('.delete-logo')
if (delBtn) {
@@ -126,6 +141,19 @@ searchInput.addEventListener('input', () => {
}, 300)
})
if (sportFilterButtons && sportFilterButtons.length) {
updateSportFilterButtons()
sportFilterButtons.forEach(btn => {
btn.addEventListener('click', () => {
const value = btn.dataset.sportFilter || 'all'
if (value === sport) return
sport = value
updateSportFilterButtons()
loadPage(true)
})
})
}
loadMoreBtn.addEventListener('click', () => {
if (hasMore) loadPage(false)
})
+79 -12
View File
@@ -101,6 +101,42 @@ uploadBtn.addEventListener('click', () => {
const searchInput = document.getElementById('searchInput')
const searchResults = document.getElementById('searchResults')
let searchTimeout
let activeIndex = -1
let currentClubs = []
function normalizeText(s) {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
function highlight(text, query) {
const t = String(text)
const nq = normalizeText(query)
if (!nq) return t
const nt = normalizeText(t)
const idx = nt.indexOf(nq)
if (idx === -1) return t
let i = 0, oi = 0, start = -1, end = -1
while (oi < t.length && i <= idx + nq.length) {
const ch = t[oi]
const n = normalizeText(ch)
if (i === idx) start = oi
if (n) i += n.length
oi += 1
if (i >= idx + nq.length) { end = oi; break }
}
if (start === -1 || end === -1) return t
return t.slice(0, start) + '<span class="bg-accent-blue/20">' + t.slice(start, end) + '</span>' + t.slice(end)
}
function updateActive() {
const items = searchResults.querySelectorAll('.club-result')
items.forEach((el, i) => {
if (i === activeIndex) {
el.classList.add('ring-2', 'ring-accent-blue')
} else {
el.classList.remove('ring-2', 'ring-accent-blue')
}
})
}
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout)
@@ -111,12 +147,31 @@ searchInput.addEventListener('input', (e) => {
return
}
// Debounce search
searchTimeout = setTimeout(() => {
searchClubs(query)
}, 300)
})
searchInput.addEventListener('keydown', (e) => {
const total = searchResults.querySelectorAll('.club-result').length
if (!total) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex = (activeIndex + 1) % total
updateActive()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex = (activeIndex - 1 + total) % total
updateActive()
} else if (e.key === 'Enter') {
e.preventDefault()
if (activeIndex >= 0 && activeIndex < total) {
const item = searchResults.querySelectorAll('.club-result')[activeIndex]
item.click()
}
}
})
async function searchClubs(query) {
searchResults.innerHTML = '<div class="text-center py-4"><div class="spinner mx-auto"></div></div>'
@@ -130,12 +185,19 @@ async function searchClubs(query) {
}
const data = await response.json()
displaySearchResults(data)
const nq = normalizeText(query)
const filtered = data.filter(c => {
const name = normalizeText(c.name || '')
const city = normalizeText(c.city || '')
const id = String(c.id || '').toLowerCase()
return name.includes(nq) || city.includes(nq) || id.includes(query.toLowerCase())
})
displaySearchResults(filtered, query)
} catch (error) {
console.log('Backend not available, showing demo data')
// Demo data when backend is not ready
displaySearchResults(getDemoClubs(query))
const demo = getDemoClubs(query)
displaySearchResults(demo, query)
}
}
@@ -167,12 +229,15 @@ function getDemoClubs(query) {
}
]
return demoClubs.filter(club =>
club.name.toLowerCase().includes(query.toLowerCase())
)
const nq = normalizeText(query)
return demoClubs.filter(club => {
const name = normalizeText(club.name)
const city = normalizeText(club.city)
return name.includes(nq) || city.includes(nq)
})
}
function displaySearchResults(clubs) {
function displaySearchResults(clubs, query) {
if (clubs.length === 0) {
searchResults.innerHTML = `
<div class="text-center py-8 text-gray-400">
@@ -182,12 +247,14 @@ function displaySearchResults(clubs) {
return
}
searchResults.innerHTML = clubs.map(club => `
<div class="club-result bg-dark-bg rounded-lg p-4 border border-dark-border hover:border-accent-blue transition-smooth cursor-pointer">
activeIndex = -1
currentClubs = clubs
searchResults.innerHTML = clubs.map((club, idx) => `
<div class="club-result bg-dark-bg rounded-lg p-4 border border-dark-border hover:border-accent-blue transition-smooth cursor-pointer" data-index="${idx}">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="font-semibold text-lg">${club.name}</h3>
<p class="text-sm text-gray-400">${club.city || 'N/A'}${club.type || 'football'}</p>
<h3 class="font-semibold text-lg">${highlight(club.name, query)}</h3>
<p class="text-sm text-gray-400">${highlight(club.city || 'N/A', query)}${club.type || 'football'}</p>
<p class="text-xs text-gray-500 font-mono mt-1">${club.id}</p>
</div>
<button
+73
View File
@@ -55,3 +55,76 @@ if (document.readyState === 'loading') {
} else {
initThemeToggle()
}
// Global light/dark theme handling for Czech Clubs Logos frontend
const THEME_KEY = 'clublogos-theme'
function getPreferredTheme() {
try {
const stored = localStorage.getItem(THEME_KEY)
if (stored === 'light' || stored === 'dark') return stored
} catch (_) {}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
return 'light'
}
return 'dark'
}
function applyTheme(theme) {
const root = document.documentElement
const mode = theme === 'light' ? 'light' : 'dark'
root.classList.remove('theme-light', 'theme-dark', 'dark')
if (mode === 'light') {
root.classList.add('theme-light')
} else {
root.classList.add('theme-dark', 'dark')
}
try {
localStorage.setItem(THEME_KEY, mode)
} catch (_) {}
const toggle = document.getElementById('themeToggle')
if (toggle) {
if (mode === 'light') {
toggle.innerHTML = `
<span class="inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
<span class="hidden sm:inline">Tmavý režim</span>
</span>
`
} else {
toggle.innerHTML = `
<span class="inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v2m0 14v2m9-9h-2M5 12H3m15.364-6.364l-1.414 1.414M8.05 17.95l-1.414 1.414m0-12.728L8.05 8.05m9.9 9.9l-1.414-1.414M12 8a4 4 0 100 8 4 4 0 000-8z" />
</svg>
<span class="hidden sm:inline">Světlý režim</span>
</span>
`
}
}
}
function setupThemeToggle() {
const toggle = document.getElementById('themeToggle')
if (!toggle) return
toggle.addEventListener('click', () => {
const isLight = document.documentElement.classList.contains('theme-light')
applyTheme(isLight ? 'dark' : 'light')
})
}
if (typeof window !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
const initial = getPreferredTheme()
applyTheme(initial)
setupThemeToggle()
})
}