mirror of
https://github.com/Dvorinka/ClubLogos.git
synced 2026-06-03 19:42:58 +00:00
update ui, search, new api endpoint
This commit is contained in:
Binary file not shown.
Binary file not shown.
+6
-4
@@ -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
|
||||
)
|
||||
|
||||
+42
-6
@@ -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=
|
||||
|
||||
+319
-50
@@ -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)
|
||||
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+49
-13
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs" class="dark">
|
||||
<html lang="cs" class="dark theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -13,10 +13,15 @@
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-2xl font-bold gradient-text">České Kluby Loga</a>
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg bg-accent-blue/20 transition-smooth">Admin</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg bg-accent-blue/20 transition-smooth">Admin</a>
|
||||
</div>
|
||||
<button id="themeToggle" type="button" class="px-3 py-1.5 text-xs md:text-sm rounded-full border border-dark-border bg-dark-bg/60 hover:bg-dark-border transition-smooth">
|
||||
☀️ <span class="hidden sm:inline">Světlý režim</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,14 +41,30 @@
|
||||
<section class="mb-12">
|
||||
<div class="bg-dark-card rounded-xl p-6 border border-dark-border">
|
||||
<h2 class="text-2xl font-bold mb-6">🔍 Vyhledat Klub</h2>
|
||||
|
||||
<div class="relative mb-6">
|
||||
<input
|
||||
type="text"
|
||||
id="clubSearch"
|
||||
placeholder="Hledat české kluby (např. Sparta, Slavia)..."
|
||||
class="w-full bg-dark-bg border border-dark-border rounded-lg px-4 py-3 text-white focus:outline-none focus:border-accent-blue transition-smooth"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-4">
|
||||
<div class="w-full md:max-w-lg">
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-500">
|
||||
<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 21l-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="clubSearch"
|
||||
placeholder="Hledat české kluby (např. Sparta, Slavia)..."
|
||||
class="w-full bg-dark-bg border border-dark-border rounded-lg pl-10 pr-4 py-3 text-white focus:outline-none focus:border-accent-blue transition-smooth"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start md:items-end gap-1 text-xs">
|
||||
<div class="inline-flex rounded-full bg-dark-bg border border-dark-border p-1">
|
||||
<button type="button" data-club-sport-filter="all" class="px-3 py-1.5 rounded-full bg-accent-blue text-white transition-smooth">Vše</button>
|
||||
<button type="button" data-club-sport-filter="football" class="px-3 py-1.5 rounded-full bg-dark-bg text-gray-300 hover:bg-dark-border transition-smooth">Fotbal</button>
|
||||
<button type="button" data-club-sport-filter="futsal" class="px-3 py-1.5 rounded-full bg-dark-bg text-gray-300 hover:bg-dark-border transition-smooth">Futsal</button>
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-500">Výsledky z FAČR • filtr dle druhu sportu</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
@@ -57,6 +78,21 @@
|
||||
<section id="uploadSection" class="hidden">
|
||||
<div class="bg-dark-card rounded-xl p-6 border border-dark-border">
|
||||
<h2 class="text-2xl font-bold mb-6"><span style="font-size: 30px; display: inline-block; vertical-align: middle; line-height: 1;">⬆️</span> Nahrát Logo</h2>
|
||||
<div id="selectedClubSummary" class="hidden mb-6">
|
||||
<div class="bg-dark-bg rounded-lg border border-dark-border p-4 flex items-start gap-4">
|
||||
<div id="selectedClubLogo" class="flex-shrink-0 w-14 h-14 rounded-lg bg-dark-border/40 flex items-center justify-center text-2xl">
|
||||
🏟️
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 id="selectedClubName" class="font-semibold truncate"></h3>
|
||||
<span id="selectedClubType" class="px-2 py-0.5 rounded-full bg-accent-blue/10 text-xs text-accent-blue uppercase tracking-wide"></span>
|
||||
</div>
|
||||
<p id="selectedClubCity" class="text-xs text-gray-400 truncate"></p>
|
||||
<p id="selectedClubWebsite" class="text-xs text-accent-blue mt-1 truncate"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="uploadForm" class="space-y-6">
|
||||
|
||||
<!-- Club UUID (Read-only) -->
|
||||
|
||||
+46
-5
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs" class="dark">
|
||||
<html lang="cs" class="dark theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -13,10 +13,15 @@
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-2xl font-bold gradient-text">🇨🇿 České Kluby Loga</a>
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg bg-accent-blue/20">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg bg-accent-blue/20">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
</div>
|
||||
<button id="themeToggle" type="button" class="px-3 py-1.5 text-xs md:text-sm rounded-full border border-dark-border bg-dark-bg/60 hover:bg-dark-border transition-smooth">
|
||||
☀️ <span class="hidden sm:inline">Světlý režim</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,6 +84,42 @@ curl http://localhost:3000/api/logos/{uuid}</code></pre>
|
||||
<section class="mb-16">
|
||||
<h2 class="text-3xl font-bold mb-6">📡 Endpointy</h2>
|
||||
|
||||
<!-- Search Clubs with Logos -->
|
||||
<div class="bg-dark-card rounded-xl p-6 border border-dark-border mb-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="px-3 py-1 bg-blue-600/20 text-blue-400 rounded font-mono text-sm">GET</span>
|
||||
<code class="text-lg">/clubs/search-with-logos</code>
|
||||
</div>
|
||||
<p class="text-gray-400 mb-4">Vyhledá kluby v lokální databázi nahraných log (podle názvu nebo ID) a vrátí kompaktní JSON obsahující pouze ID, název, URL loga a příznak lokálního loga.</p>
|
||||
|
||||
<h4 class="text-sm font-semibold mb-2">Query Parametry:</h4>
|
||||
<div class="bg-dark-bg rounded-lg p-4 mb-4 space-y-2">
|
||||
<div>
|
||||
<code class="text-sm">q</code> <span class="text-red-400">*</span> <span class="text-gray-500">string</span>
|
||||
<p class="text-xs text-gray-500 mt-1">Text pro hledání klubu (např. "Sparta", "Slavia", "Hranice").</p>
|
||||
</div>
|
||||
<div>
|
||||
<code class="text-sm">sport</code> <span class="text-gray-500">string</span>
|
||||
<p class="text-xs text-gray-500 mt-1">Volitelný filtr podle druhu sportu: <code>"football"</code>, <code>"futsal"</code> nebo <code>"all"</code> (výchozí).
|
||||
Alias: <code>type</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-dark-bg rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-400 mb-2">Response 200:</h4>
|
||||
<pre class="text-sm overflow-x-auto"><code>[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "AC Sparta Praha",
|
||||
"logo_url": "http://localhost:8080/logos/550e8400-e29b-41d4-a716-446655440000?format=png",
|
||||
"has_local_logo": true
|
||||
}
|
||||
]</code></pre>
|
||||
<p class="text-xs text-gray-500 mt-2">Pozn.: Výsledky se berou pouze z lokální databáze. <code>logo_url</code> vždy míří na tento backend
|
||||
a <code>has_local_logo</code> indikuje, že pro klub existuje alespoň jedno uložené logo.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Logos -->
|
||||
<div class="bg-dark-card rounded-xl p-6 border border-dark-border mb-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
|
||||
+11
-6
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs" class="dark">
|
||||
<html lang="cs" class="dark theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -13,11 +13,16 @@
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-2xl font-bold gradient-text">🇨🇿 České Kluby Loga</a>
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/logos.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Všechna Loga</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/logos.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Všechna Loga</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
</div>
|
||||
<button id="themeToggle" type="button" class="px-3 py-1.5 text-xs md:text-sm rounded-full border border-dark-border bg-dark-bg/60 hover:bg-dark-border transition-smooth">
|
||||
☀️ <span class="hidden sm:inline">Světlý režim</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-5
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs" class="dark">
|
||||
<html lang="cs" class="dark theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -13,10 +13,15 @@
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-2xl font-bold gradient-text">🇨🇿 České Kluby Loga</a>
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
</div>
|
||||
<button id="themeToggle" type="button" class="px-3 py-1.5 text-xs md:text-sm rounded-full border border-dark-border bg-dark-bg/60 hover:bg-dark-border transition-smooth">
|
||||
☀️ <span class="hidden sm:inline">Světlý režim</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+35
-14
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs" class="dark">
|
||||
<html lang="cs" class="dark theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -11,11 +11,16 @@
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-2xl font-bold gradient-text">České Kluby Loga</a>
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/logos.html" class="nav-link px-4 py-2 rounded-lg bg-accent-blue/20 transition-smooth">Všechna Loga</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Domů</a>
|
||||
<a href="/logos.html" class="nav-link px-4 py-2 rounded-lg bg-accent-blue/20 transition-smooth">Všechna Loga</a>
|
||||
<a href="/api-docs.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">API Docs</a>
|
||||
<a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
|
||||
</div>
|
||||
<button id="themeToggle" type="button" class="px-3 py-1.5 text-xs md:text-sm rounded-full border border-dark-border bg-dark-bg/60 hover:bg-dark-border transition-smooth">
|
||||
☀️ <span class="hidden sm:inline">Světlý režim</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,14 +34,30 @@
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-6 py-12">
|
||||
<div class="mb-6 flex flex-col md:flex-row gap-3 md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
id="allLogoSearch"
|
||||
placeholder="Hledat mezi všemi logy..."
|
||||
class="w-full md:max-w-lg bg-dark-card border border-dark-border rounded-lg px-4 py-3 text-white focus:outline-none focus:border-accent-blue transition-smooth"
|
||||
>
|
||||
<div class="text-xs text-gray-500">20 log na stránku • řazeno: nejnovější</div>
|
||||
<div class="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="w-full md:max-w-lg">
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-500">
|
||||
<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 21l-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="allLogoSearch"
|
||||
placeholder="Hledat mezi všemi logy..."
|
||||
class="w-full bg-dark-card border border-dark-border rounded-lg pl-10 pr-4 py-3 text-white focus:outline-none focus:border-accent-blue transition-smooth"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start md:items-end gap-2">
|
||||
<div class="inline-flex rounded-full bg-dark-card border border-dark-border p-1 text-xs">
|
||||
<button type="button" data-sport-filter="all" class="px-3 py-1.5 rounded-full bg-accent-blue text-white transition-smooth">Vše</button>
|
||||
<button type="button" data-sport-filter="football" class="px-3 py-1.5 rounded-full bg-dark-card text-gray-300 hover:bg-dark-border transition-smooth">Fotbal</button>
|
||||
<button type="button" data-sport-filter="futsal" class="px-3 py-1.5 rounded-full bg-dark-card text-gray-300 hover:bg-dark-border transition-smooth">Futsal</button>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-500">20 log na stránku • řazeno: nejnovější</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="allLoading" class="text-center py-12">
|
||||
|
||||
+134
-4
@@ -1,4 +1,5 @@
|
||||
import './style.css'
|
||||
import './theme.js'
|
||||
import gsap from 'gsap'
|
||||
|
||||
// Configuration
|
||||
@@ -10,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)
|
||||
@@ -27,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>'
|
||||
|
||||
@@ -44,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
|
||||
@@ -85,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)
|
||||
|
||||
@@ -120,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>` : ''}
|
||||
@@ -168,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')
|
||||
@@ -186,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')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './style.css'
|
||||
import './theme.js'
|
||||
import gsap from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './style.css'
|
||||
import './theme.js'
|
||||
import gsap from 'gsap'
|
||||
|
||||
// Configuration
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './style.css'
|
||||
import './theme.js'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
@@ -7,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
|
||||
@@ -34,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')
|
||||
@@ -88,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) {
|
||||
@@ -125,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)
|
||||
})
|
||||
|
||||
+80
-12
@@ -1,4 +1,5 @@
|
||||
import './style.css'
|
||||
import './theme.js'
|
||||
import gsap from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
|
||||
@@ -100,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)
|
||||
@@ -110,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>'
|
||||
|
||||
@@ -129,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,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">
|
||||
@@ -181,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
|
||||
|
||||
@@ -97,3 +97,48 @@ body {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Light/Dark theme overrides */
|
||||
.theme-dark body {
|
||||
background-color: #0a0e1a;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.theme-light body {
|
||||
background-color: #f9fafb;
|
||||
color: #020617;
|
||||
}
|
||||
|
||||
.theme-light .bg-dark-bg {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.theme-light .bg-dark-card {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-light .border-dark-border {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.theme-light .text-gray-400 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.theme-light .text-gray-500 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.theme-light .text-gray-600 {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.theme-light .spinner {
|
||||
border-color: rgba(15, 23, 42, 0.08);
|
||||
border-top-color: #3b82f6;
|
||||
}
|
||||
|
||||
.theme-light .bg-dark-card\/50 {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user