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 7a146f0..1d5bfdc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) 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 8e55583..1c377fd 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -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) 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/src/admin.js b/frontend/src/admin.js index e4cee01..bff4a77 100644 --- a/frontend/src/admin.js +++ b/frontend/src/admin.js @@ -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) + '' + t.slice(start, end) + '' + 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 = '
' @@ -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 ` -
+
${logoHtml}
-

${club.name}

+

${highlight(club.name, q)}

${club.type || 'football'}

${club.id}

${club.website ? `

${club.website}

` : ''} @@ -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 = `${club.website}` + } else { + selectedClubWebsiteEl.textContent = '' + } + + const displayLogo = club.display_logo_url || club.logo_url || '' + if (displayLogo) { + selectedClubLogoEl.innerHTML = `${club.name || ''}` + } 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') diff --git a/frontend/src/api-docs.js b/frontend/src/api-docs.js new file mode 100644 index 0000000..a08fdcf --- /dev/null +++ b/frontend/src/api-docs.js @@ -0,0 +1,4 @@ +import './style.css' +import './theme.js' + +console.log('🇨🇿 České Kluby Loga API - API Docs') diff --git a/frontend/src/logos.js b/frontend/src/logos.js index 311249b..adba854 100644 --- a/frontend/src/logos.js +++ b/frontend/src/logos.js @@ -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) }) diff --git a/frontend/src/main.js b/frontend/src/main.js index bbb7156..eba39cd 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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) + '' + t.slice(start, end) + '' + 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 = '
' @@ -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 = `
@@ -182,12 +247,14 @@ function displaySearchResults(clubs) { return } - searchResults.innerHTML = clubs.map(club => ` -
+ activeIndex = -1 + currentClubs = clubs + searchResults.innerHTML = clubs.map((club, idx) => ` +
-

${club.name}

-

${club.city || 'N/A'} • ${club.type || 'football'}

+

${highlight(club.name, query)}

+

${highlight(club.city || 'N/A', query)} • ${club.type || 'football'}

${club.id}