This commit is contained in:
Tomas Dvorak
2025-10-22 20:35:22 +02:00
parent e47059385c
commit e6bc2eedb3
55 changed files with 1751 additions and 1378 deletions
Binary file not shown.
+3
View File
@@ -7,6 +7,8 @@ require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.5.0 github.com/google/uuid v1.5.0
github.com/mattn/go-sqlite3 v1.14.19 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
) )
require ( require (
@@ -31,6 +33,7 @@ require (
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.21.0 // indirect golang.org/x/crypto v0.21.0 // indirect
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
golang.org/x/net v0.22.0 // indirect golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
+8
View File
@@ -63,6 +63,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -82,14 +86,18 @@ golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 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 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
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 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sys v0.5.0/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 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.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 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/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/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 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= 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= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+65 -12
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -369,13 +370,43 @@ func getLogoWithMetadata(c *gin.Context) {
// List all logos // List all logos
func listLogos(c *gin.Context) { func listLogos(c *gin.Context) {
rows, err := db.Query(` q := strings.TrimSpace(c.Query("q"))
SELECT id, club_name, club_city, club_type, club_website, sortParam := c.DefaultQuery("sort", "name")
has_svg, has_png, primary_format, limitStr := c.Query("limit")
created_at, updated_at pageStr := c.Query("page")
FROM logos
ORDER BY club_name base := "SELECT id, club_name, club_city, club_type, club_website, has_svg, has_png, primary_format, created_at, updated_at FROM logos"
`) where := ""
args := []interface{}{}
if q != "" {
where = " WHERE LOWER(club_name) LIKE ? OR LOWER(club_city) LIKE ? OR id LIKE ?"
like := "%" + strings.ToLower(q) + "%"
args = append(args, like, like, "%"+q+"%")
}
order := " ORDER BY club_name"
if sortParam == "recent" {
order = " ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC"
}
limitClause := ""
if limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
limitClause = " LIMIT ?"
args = append(args, limit)
if pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
if page < 1 {
page = 1
}
offset := (page - 1) * limit
limitClause += " OFFSET ?"
args = append(args, offset)
}
}
}
}
query := base + where + order + limitClause
rows, err := db.Query(query, args...)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return return
@@ -392,8 +423,7 @@ func listLogos(c *gin.Context) {
for rows.Next() { for rows.Next() {
var logo LogoMetadata var logo LogoMetadata
var hasSVG, hasPNG int var hasSVG, hasPNG int
if err := rows.Scan(
err := rows.Scan(
&logo.ID, &logo.ID,
&logo.ClubName, &logo.ClubName,
&logo.ClubCity, &logo.ClubCity,
@@ -404,14 +434,12 @@ func listLogos(c *gin.Context) {
&logo.PrimaryFormat, &logo.PrimaryFormat,
&logo.CreatedAt, &logo.CreatedAt,
&logo.UpdatedAt, &logo.UpdatedAt,
) ); err != nil {
if err != nil {
continue continue
} }
logo.HasSVG = hasSVG == 1 logo.HasSVG = hasSVG == 1
logo.HasPNG = hasPNG == 1 logo.HasPNG = hasPNG == 1
if logo.HasPNG { if logo.HasPNG {
logo.LogoURL = fmt.Sprintf("%s/logos/%s?format=png", baseURL, logo.ID) logo.LogoURL = fmt.Sprintf("%s/logos/%s?format=png", baseURL, logo.ID)
} else if logo.HasSVG { } else if logo.HasSVG {
@@ -424,6 +452,31 @@ func listLogos(c *gin.Context) {
c.JSON(http.StatusOK, logos) c.JSON(http.StatusOK, logos)
} }
func deleteLogo(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "logo ID is required"})
return
}
if _, err := uuid.Parse(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid UUID format"})
return
}
_, err := db.Exec("DELETE FROM logos WHERE id = ?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
pngPath := filepath.Join("./logos/png", id+".png")
svgPath := filepath.Join("./logos/svg", id+".svg")
os.Remove(pngPath)
os.Remove(svgPath)
c.JSON(http.StatusOK, gin.H{"success": true, "id": id})
}
func uploadLogo(c *gin.Context) { func uploadLogo(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if id == "" { if id == "" {
+59 -2
View File
@@ -9,6 +9,9 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
) )
// ConvertSVGToPNG converts an SVG file to PNG format // ConvertSVGToPNG converts an SVG file to PNG format
@@ -24,8 +27,12 @@ func ConvertSVGToPNG(svgPath, pngPath string, width int) error {
return nil return nil
} }
// If no converter available, copy SVG as fallback and log warning // Try pure-Go conversion
return fmt.Errorf("no SVG converter available (install ImageMagick or Inkscape)") if err := convertWithGoRenderer(svgPath, pngPath, width); err == nil {
return nil
}
return fmt.Errorf("no SVG converter available (install ImageMagick or Inkscape, or ensure Go renderer deps)")
} }
// ConvertPDFToPNG converts a PDF file to PNG format // ConvertPDFToPNG converts a PDF file to PNG format
@@ -87,6 +94,56 @@ func convertWithInkscape(svgPath, pngPath string, width int) error {
return nil return nil
} }
func convertWithGoRenderer(svgPath, pngPath string, width int) error {
f, err := os.Open(svgPath)
if err != nil {
return fmt.Errorf("open svg: %w", err)
}
defer f.Close()
icon, err := oksvg.ReadIconStream(f)
if err != nil {
return fmt.Errorf("parse svg: %w", err)
}
vb := icon.ViewBox
targetW := width
if targetW <= 0 {
targetW = int(vb.W)
if targetW <= 0 {
targetW = 512
}
}
var targetH int
if vb.W != 0 {
targetH = int(float64(targetW) * (vb.H / vb.W))
} else {
targetH = targetW
}
if targetH <= 0 {
targetH = targetW
}
icon.SetTarget(0, 0, float64(targetW), float64(targetH))
rgba := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
scanner := rasterx.NewScannerGV(targetW, targetH, rgba, rgba.Bounds())
raster := rasterx.NewDasher(targetW, targetH, scanner)
icon.Draw(raster, 1.0)
out, err := os.Create(pngPath)
if err != nil {
return fmt.Errorf("create png: %w", err)
}
defer out.Close()
if err := png.Encode(out, rgba); err != nil {
os.Remove(pngPath)
return fmt.Errorf("encode png: %w", err)
}
return nil
}
// OptimizePNG optimizes a PNG file (basic implementation) // OptimizePNG optimizes a PNG file (basic implementation)
func OptimizePNG(pngPath string) error { func OptimizePNG(pngPath string) error {
// Open the file // Open the file
+1
View File
@@ -88,6 +88,7 @@ func setupRoutes(r *gin.Engine) {
logos.GET("/:id", getLogo) logos.GET("/:id", getLogo)
logos.GET("/:id/json", getLogoWithMetadata) logos.GET("/:id/json", getLogoWithMetadata)
logos.POST("/:id", uploadLogo) logos.POST("/:id", uploadLogo)
logos.DELETE("/:id", deleteLogo)
} }
} }
+1
View File
@@ -15,6 +15,7 @@
<a href="/" class="text-2xl font-bold gradient-text">🇨🇿 České Kluby Loga</a> <a href="/" class="text-2xl font-bold gradient-text">🇨🇿 České Kluby Loga</a>
<div class="flex gap-4"> <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="/" 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="/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> <a href="/admin.html" class="nav-link px-4 py-2 rounded-lg hover:bg-dark-border transition-smooth">Admin</a>
</div> </div>
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="cs" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Všechna Loga - České Kluby Loga API</title>
<link rel="stylesheet" href="/src/style.css">
</head>
<body class="bg-dark-bg text-white min-h-screen">
<nav class="border-b border-dark-border bg-dark-card/50 backdrop-blur-sm sticky top-0 z-50">
<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>
</div>
</div>
</nav>
<header class="border-b border-dark-border bg-dark-card">
<div class="container mx-auto px-6 py-8">
<h1 class="text-3xl font-bold gradient-text mb-2">Všechna Loga</h1>
<p class="text-gray-400">Procházejte všechna dostupná loga, vyhledávejte a spravujte</p>
</div>
</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>
<div id="allLoading" class="text-center py-12">
<div class="spinner mx-auto"></div>
<p class="mt-4 text-gray-400">Načítání log...</p>
</div>
<div id="allEmpty" class="text-center py-16 hidden">
<div class="text-6xl mb-4"></div>
<p class="text-xl text-gray-400 mb-4">Žádná loga nenalezena</p>
</div>
<div id="allLogoGrid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-6"></div>
<div class="text-center mt-10">
<button id="loadMoreBtn" class="px-6 py-3 bg-dark-card border border-dark-border rounded-lg hover:bg-dark-border transition-smooth hidden">Načíst další</button>
</div>
</main>
<footer class="border-t border-dark-border mt-20">
<div class="container mx-auto px-6 py-8 text-center text-gray-400">
<p>🇨🇿 České Kluby Loga API</p>
</div>
</footer>
<script type="module" src="/src/logos.js"></script>
</body>
</html>
+26 -31
View File
@@ -44,27 +44,19 @@ const browseBtn = document.getElementById('browseBtn')
let allLogos = [] let allLogos = []
// Load logos async function loadRecentLogos() {
async function loadLogos() {
try { try {
const response = await fetch(`${API_BASE_URL}/logos`) const response = await fetch(`${API_BASE_URL}/logos?sort=recent&limit=8`)
if (!response.ok) throw new Error('Failed to fetch recent logos')
if (!response.ok) {
throw new Error('Failed to fetch logos')
}
allLogos = await response.json() allLogos = await response.json()
loadingState.classList.add('hidden') loadingState.classList.add('hidden')
if (allLogos.length === 0) { if (allLogos.length === 0) {
emptyState.classList.remove('hidden') emptyState.classList.remove('hidden')
} else { } else {
displayLogos(allLogos) displayLogos(allLogos)
} }
} catch (error) { } catch (error) {
console.error('Error loading logos:', error) console.error('Error loading recent logos:', error)
loadingState.classList.add('hidden') loadingState.classList.add('hidden')
emptyState.classList.remove('hidden') emptyState.classList.remove('hidden')
} }
@@ -116,20 +108,26 @@ function displayLogos(logos) {
} }
// Filter logos // Filter logos
function filterLogos(query) { async function filterLogos(query) {
const filtered = allLogos.filter(logo => const q = query.trim()
logo.club_name.toLowerCase().includes(query.toLowerCase()) || if (!q) {
(logo.club_city && logo.club_city.toLowerCase().includes(query.toLowerCase())) displayLogos(allLogos)
) return
}
displayLogos(filtered) try {
const resp = await fetch(`${API_BASE_URL}/logos?q=${encodeURIComponent(q)}&limit=50`)
if (filtered.length === 0 && query) { if (!resp.ok) throw new Error('Search failed')
logoGrid.innerHTML = ` const results = await resp.json()
<div class="col-span-full text-center py-16"> displayLogos(results)
<p class="text-xl text-gray-400">No logos found matching "${query}"</p> if (results.length === 0) {
</div> logoGrid.innerHTML = `
` <div class="col-span-full text-center py-16">
<p class="text-xl text-gray-400">No logos found matching "${q}"</p>
</div>
`
}
} catch (e) {
console.warn('Search error:', e.message)
} }
} }
@@ -158,10 +156,7 @@ if (gallerySearch) {
// Browse button - scroll to gallery // Browse button - scroll to gallery
if (browseBtn) { if (browseBtn) {
browseBtn.addEventListener('click', () => { browseBtn.addEventListener('click', () => {
document.getElementById('logoGallery').scrollIntoView({ window.location.href = '/logos.html'
behavior: 'smooth',
block: 'start'
})
}) })
} }
@@ -202,7 +197,7 @@ console.log('🇨🇿 Czech Clubs Logos API - Home')
console.log('Backend API:', API_BASE_URL) console.log('Backend API:', API_BASE_URL)
// Load logos on page load // Load logos on page load
loadLogos() loadRecentLogos()
// Show welcome notification // Show welcome notification
setTimeout(() => { setTimeout(() => {
+132
View File
@@ -0,0 +1,132 @@
import './style.css'
const API_BASE_URL = '/api'
const grid = document.getElementById('allLogoGrid')
const loading = document.getElementById('allLoading')
const empty = document.getElementById('allEmpty')
const loadMoreBtn = document.getElementById('loadMoreBtn')
const searchInput = document.getElementById('allLogoSearch')
let page = 1
const limit = 20
let query = ''
let isLoading = false
let hasMore = true
async function loadPage(reset = false) {
if (isLoading) return
if (reset) {
page = 1
hasMore = true
grid.innerHTML = ''
empty.classList.add('hidden')
loading.classList.remove('hidden')
} else {
loadMoreBtn.disabled = true
loadMoreBtn.textContent = 'Načítání...'
}
isLoading = true
try {
const url = new URL(`${API_BASE_URL}/logos`, window.location.origin)
url.searchParams.set('sort', 'recent')
url.searchParams.set('limit', String(limit))
url.searchParams.set('page', String(page))
if (query) url.searchParams.set('q', query)
const resp = await fetch(url.toString().replace(window.location.origin, ''))
if (!resp.ok) throw new Error('Failed to fetch logos')
const data = await resp.json()
if (reset) loading.classList.add('hidden')
if (Array.isArray(data) && data.length > 0) {
appendCards(data)
page += 1
if (data.length < limit) {
hasMore = false
loadMoreBtn.classList.add('hidden')
} else {
loadMoreBtn.classList.remove('hidden')
}
} else {
if (reset) empty.classList.remove('hidden')
hasMore = false
loadMoreBtn.classList.add('hidden')
}
} catch (_) {
if (reset) {
loading.classList.add('hidden')
empty.classList.remove('hidden')
}
} finally {
isLoading = false
loadMoreBtn.disabled = false
loadMoreBtn.textContent = 'Načíst další'
}
}
function appendCards(items) {
const html = items.map(logo => {
const logoUrl = `${API_BASE_URL}/logos/${logo.id}`
return `
<div class="logo-card bg-dark-card rounded-xl p-4 border border-dark-border hover:border-accent-blue transition-smooth group" data-id="${logo.id}">
<div class="aspect-square bg-dark-bg rounded-lg flex items-center justify-center mb-3 overflow-hidden cursor-pointer">
<img src="${logoUrl}" alt="${logo.club_name}" class="max-w-full max-h-full object-contain p-2 group-hover:scale-110 transition-transform duration-300" loading="lazy" onerror="this.parentElement.innerHTML='<svg class=\'w-8 h-8 text-gray-500\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\'></path></svg>'">
</div>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0 cursor-pointer">
<h3 class="font-semibold text-sm truncate mb-0.5">${logo.club_name}</h3>
<p class="text-xs text-gray-400 truncate">${logo.club_type || 'fotbal'}</p>
</div>
<button class="delete-logo px-3 py-1.5 text-xs bg-red-600 rounded hover:bg-red-500 transition-smooth">Smazat</button>
</div>
</div>
`
}).join('')
grid.insertAdjacentHTML('beforeend', html)
}
grid.addEventListener('click', async (e) => {
const delBtn = e.target.closest('.delete-logo')
if (delBtn) {
const card = delBtn.closest('.logo-card')
const id = card.dataset.id
const ok = confirm('Smazat toto logo?')
if (!ok) return
delBtn.disabled = true
delBtn.textContent = 'Mazání...'
try {
const resp = await fetch(`${API_BASE_URL}/logos/${id}`, { method: 'DELETE' })
if (!resp.ok) throw new Error('Delete failed')
card.remove()
if (!grid.children.length) empty.classList.remove('hidden')
} catch (_) {
delBtn.textContent = 'Smazat'
delBtn.disabled = false
alert('Mazání selhalo')
}
return
}
const card = e.target.closest('.logo-card')
if (card && !e.target.closest('.delete-logo')) {
const id = card.dataset.id
window.location.href = `/logo.html?id=${id}`
}
})
let searchTimer
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
query = searchInput.value.trim()
loadPage(true)
}, 300)
})
loadMoreBtn.addEventListener('click', () => {
if (hasMore) loadPage(false)
})
loadPage(true)
+2 -1
View File
@@ -22,7 +22,8 @@ export default defineConfig({
main: resolve(__dirname, 'index.html'), main: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin.html'), admin: resolve(__dirname, 'admin.html'),
apiDocs: resolve(__dirname, 'api-docs.html'), apiDocs: resolve(__dirname, 'api-docs.html'),
logo: resolve(__dirname, 'logo.html') logo: resolve(__dirname, 'logo.html'),
logos: resolve(__dirname, 'logos.html')
} }
} }
} }
+5
View File
@@ -1,3 +1,8 @@
module club module club
go 1.25.1 go 1.25.1
require (
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
)
+6
View File
@@ -0,0 +1,6 @@
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+46 -2
View File
@@ -34,6 +34,7 @@ const CONFIG = {
}, },
// Directories to scan (relative to project root) // Directories to scan (relative to project root)
scanDirs: [ scanDirs: [
'backend/logos',
'data/logos', 'data/logos',
'frontend/dist', 'frontend/dist',
], ],
@@ -74,7 +75,7 @@ async function getFileSize(filePath) {
*/ */
async function optimizeSvgFile(filePath) { async function optimizeSvgFile(filePath) {
const svg = await fs.readFile(filePath, 'utf8'); const svg = await fs.readFile(filePath, 'utf8');
const result = optimize(svg, { const result = optimizeSvg(svg, {
path: filePath, path: filePath,
multipass: true, multipass: true,
plugins: [ plugins: [
@@ -100,6 +101,45 @@ async function optimizeSvgFile(filePath) {
return true; return true;
} }
async function convertSvgToPng(svgPath) {
try {
const normalized = path.normalize(svgPath);
let pngPath = normalized.replace(/\.svg$/i, '.png');
const mappings = [
{
from: path.join(path.sep, 'data', 'logos', 'svg') + path.sep,
to: path.join(path.sep, 'data', 'logos', 'png') + path.sep,
},
{
from: path.join(path.sep, 'backend', 'logos', 'svg') + path.sep,
to: path.join(path.sep, 'backend', 'logos', 'png') + path.sep,
},
];
for (const { from, to } of mappings) {
if (normalized.includes(from)) {
pngPath = normalized.replace(from, to).replace(/\.svg$/i, '.png');
break;
}
}
await fs.mkdir(path.dirname(pngPath), { recursive: true });
const image = sharp(svgPath, { density: 300 });
await image
.resize({ width: 512, fit: 'inside', withoutEnlargement: true })
.png({ quality: CONFIG.compression.png.quality, effort: CONFIG.compression.png.effort })
.toFile(pngPath);
console.log(chalk.green(` ✓ Converted to PNG: ${pngPath}`));
return true;
} catch (error) {
console.warn(chalk.yellow(` ⚠️ SVG to PNG conversion skipped: ${error.message}`));
return false;
}
}
/** /**
* Optimize PNG/JPG file * Optimize PNG/JPG file
*/ */
@@ -195,7 +235,11 @@ async function processFile(filePath) {
// Optimize based on file type // Optimize based on file type
switch (ext) { switch (ext) {
case 'svg': case 'svg':
optimized = await optimizeSvgFile(filePath); {
const didOptimize = await optimizeSvgFile(filePath);
const didConvert = await convertSvgToPng(filePath);
optimized = didOptimize || didConvert;
}
break; break;
case 'png': case 'png':
case 'jpg': case 'jpg':
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "optimize-assets.js", "main": "optimize-assets.js",
"scripts": { "scripts": {
"optimize": "node optimize-assets.js", "optimize": "node optimize-assets.js",
"optimize:watch": "nodemon --watch ../data --watch ../frontend/dist -e svg,png,pdf --exec npm run optimize" "optimize:watch": "nodemon --watch ../backend/logos --watch ../data --watch ../frontend/dist -e svg,pdf --exec npm run optimize"
}, },
"dependencies": { "dependencies": {
"chalk": "^4.1.2", "chalk": "^4.1.2",