Compare commits

...

7 Commits

Author SHA1 Message Date
Your Name a89d6e1a63 feat(ui): add new sponsor and quote image
Add a new sponsor link to index and o-nas pages with corresponding logo, and include a new quote image.
2026-06-01 15:56:16 +02:00
Your Name 60a4b82931 feat(ui): add new sponsor and update text content
Add a new sponsor logo and link for NSA to the index and o-nas pages.
Update the header text in o-nas.html to reflect winner status.
2026-05-30 09:33:58 +02:00
Your Name efa35518ab feat(ui): update sponsor logos and links
Replace the alpacar.pl sponsor with kovosteel.cz and gracla.cz in index.html and o-nas.html, and add new sponsor image assets.
2026-05-25 17:41:17 +02:00
Tomas Dvorak cc6841e723 feat(backend): implement fallback API for club data retrieval
Add a fallback mechanism to use an alternative base URL and club ID
when the primary API request fails. This ensures data resilience by
attempting to fetch club details and standings from a secondary source
using a slug-based lookup.

The implementation also updates the logic to use the active club ID
(either primary or fallback) when overriding logo URLs in match
details and table standings.
2026-05-13 12:05:20 +02:00
Tomas Dvorak 4c904a1546 fix(backend): improve video resilience and add blog ordering validation
Implement fallback mechanisms for YouTube video data and add startup
validation for blog post ordering.

- Add `loadVideosJSON` to persist and reload video data from disk.
- Update `refreshVideos` to retain existing local videos if the YouTube
  API returns an empty response.
- Add `validateBlogOrdering` to perform a health check on blog post
  sequence during startup.
- Call video loading and blog validation in `main`.
2026-05-11 15:30:24 +02:00
Tomas Dvorak 76c447a395 fix(ui): add defensive sorting for blog posts
Implement client-side sorting for blog post lists to ensure they are
displayed in descending order by numeric ID. This prevents issues
where API response ordering or file modification timestamps might
cause older posts to appear newer than recent ones.

Also add backend tests to verify blog ordering logic.
2026-05-11 13:03:04 +02:00
Tomas Dvorak a6b47de1a4 refactor(backend): improve blog canonical ID resolution
Refactor the blog listing logic to use a canonical numeric ID when available. This ensures consistent identity and deduplication between numeric and slug-based filenames, and ensures image path resolution uses the correct identifier.
2026-05-11 12:33:26 +02:00
14 changed files with 386 additions and 81 deletions
+7 -1
View File
@@ -234,7 +234,13 @@
s.textContent = 'Načítám…';
const res = await fetch('/api/blog/latest?limit=12');
if (!res.ok) throw new Error('HTTP '+res.status);
const items = await res.json();
let items = await res.json();
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
items.sort((a,b)=>{
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
return (b.id||'').localeCompare(a.id||'');
});
grid.innerHTML='';
if (!Array.isArray(items) || items.length === 0) {
grid.innerHTML = '<div class="muted">Žádné příspěvky.</div>';
+7 -1
View File
@@ -53,7 +53,13 @@
try {
const res = await fetch('/api/blog/latest?limit=12');
if (!res.ok) throw new Error('HTTP '+res.status);
const items = await res.json();
let items = await res.json();
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
items.sort((a,b)=>{
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
return (b.id||'').localeCompare(a.id||'');
});
status.textContent = `Nalezeno: ${items.length}`;
mount.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
+123 -54
View File
@@ -30,6 +30,9 @@ const (
clubID = "441d3783-06aa-436a-b438-359300ee0371"
clubType = "futsal"
baseURL = "https://facr.tdvorak.dev"
fallbackBaseURL = "https://flashscore.tdvorak.dev"
fallbackClubID = "xzS3gX3T"
fallbackSlug = "uherske-hradiste"
)
// Paths
@@ -330,6 +333,14 @@ func refreshVideos(ctx context.Context) error {
return fmt.Errorf("yt get: %w", err)
}
items := resp.Videos
if len(items) == 0 {
// API returned empty data; keep existing local videos as fallback
vc.mu.RLock()
existingCount := len(vc.data.Items)
vc.mu.RUnlock()
log.Printf("warn: yt api returned 0 videos; keeping %d existing videos", existingCount)
return nil
}
if len(items) > 5 {
items = items[:5]
}
@@ -397,6 +408,28 @@ func writeVideosJSON() error {
return nil
}
func loadVideosJSON() error {
path := videosPath()
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read videos json: %w", err)
}
var payload struct {
FetchedAt time.Time `json:"fetched_at"`
Channel string `json:"channel"`
Items []YTVideo `json:"items"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return fmt.Errorf("unmarshal videos json: %w", err)
}
vc.mu.Lock()
vc.data.FetchedAt = payload.FetchedAt
vc.data.Channel = payload.Channel
vc.data.Items = payload.Items
vc.mu.Unlock()
return nil
}
func videosPath() string {
if p := os.Getenv("VIDEOS_PATH"); p != "" {
return p
@@ -588,7 +621,6 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
}
// Match both numeric (0001.html) and slug-based filenames
re := regexp.MustCompile(`^(\d{4}|[a-z0-9-]+)\.html$`)
numericRe := regexp.MustCompile(`^\d{4}$`)
var items []BlogItem
seenIDs := make(map[string]bool) // Track seen IDs to avoid duplicates
for _, e := range entries {
@@ -598,24 +630,37 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
}
id := strings.TrimSuffix(name, ".html")
// Skip if this ID was already processed (deduplication)
if seenIDs[id] {
continue
}
// Title and categories extraction from blog HTML
blogPath := filepath.Join(blogDir, name)
title := extractTitle(blogPath)
slug := extractSlug(blogPath, name)
cats := extractCategories(blogPath)
// Mark this ID as seen
seenIDs[id] = true
// Also mark slug/numeric counterpart to prevent duplicates
if slug != "" && slug != id {
seenIDs[slug] = true
// Determine canonical ID: prefer numeric when available.
// This ensures consistent item identity regardless of whether the
// numeric or slug filename is encountered first in the directory.
canonicalID := id
if regexp.MustCompile(`^[a-z]`).MatchString(id) {
numericFiles, _ := filepath.Glob(filepath.Join(blogDir, "????.html"))
for _, numericFile := range numericFiles {
numericID := strings.TrimSuffix(filepath.Base(numericFile), ".html")
numericPath := filepath.Join(blogDir, numericFile)
numericSlug := extractSlug(numericPath, numericFile)
if numericSlug == id {
canonicalID = numericID
break
}
if numericRe.MatchString(id) && slug != "" {
}
}
// Skip if this canonical ID or its slug was already processed (deduplication)
if seenIDs[canonicalID] || (slug != "" && seenIDs[slug]) {
continue
}
// Mark both canonical ID and slug as seen
seenIDs[canonicalID] = true
if slug != "" {
seenIDs[slug] = true
}
// Determine mod time - prefer image modtime if exists, else html
@@ -624,36 +669,23 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
if err1 == nil {
mtime = htmlInfo.ModTime()
}
// For image path, try to find corresponding numeric ID
imageID := id
if regexp.MustCompile(`^[a-z]`).MatchString(id) {
// This is a slug, try to find corresponding numeric file
numericFiles, _ := filepath.Glob(filepath.Join(blogDir, "????.html"))
for _, numericFile := range numericFiles {
numericID := strings.TrimSuffix(filepath.Base(numericFile), ".html")
numericPath := filepath.Join(blogDir, numericFile)
numericSlug := extractSlug(numericPath, numericFile)
if numericSlug == id {
imageID = numericID
break
}
}
}
// For image path, canonicalID is already numeric when a numeric file exists
imageID := canonicalID
if imgInfo, err2 := os.Stat(filepath.Join(imgDir, imageID+".png")); err2 == nil {
// If image is newer, use that as a proxy for recency
if imgInfo.ModTime().After(mtime) {
mtime = imgInfo.ModTime()
}
}
// Use slug-based link if slug exists and is not just numeric, otherwise use numeric
// Use slug-based link if slug exists and is not just numeric, otherwise use canonical numeric ID
var link string
if slug != "" && regexp.MustCompile(`[a-z]`).MatchString(slug) {
link = "/blog/" + slug
} else {
link = "/blog/" + id + ".html"
link = "/blog/" + canonicalID + ".html"
}
items = append(items, BlogItem{
ID: id,
ID: canonicalID,
Title: title,
Slug: slug,
Link: link,
@@ -663,32 +695,25 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
})
}
sort.Slice(items, func(i, j int) bool {
// Check if files were recently processed (all have same timestamp from setup script)
recentThreshold := time.Now().Add(-24 * time.Hour)
allRecent := items[i].MTime.After(recentThreshold) && items[j].MTime.After(recentThreshold)
if allRecent {
// If both files are recent (from setup script), sort by numeric ID (higher = newer)
// Always prefer numeric ID descending (higher ID = newer post).
// Numeric IDs monotonically increase via nextBlogID, so they are
// the authoritative ordering regardless of file MTime.
ii, err1 := strconv.Atoi(items[i].ID)
jj, err2 := strconv.Atoi(items[j].ID)
if err1 == nil && err2 == nil {
return ii > jj
}
// If not numeric, fall back to string comparison
return items[i].ID > items[j].ID
// If only one item has a numeric ID, it is newer
if err1 == nil {
return true
}
// Otherwise, use modification time (newest first)
if err2 == nil {
return false
}
// Both non-numeric: fall back to MTime, then string comparison
if !items[i].MTime.Equal(items[j].MTime) {
return items[i].MTime.After(items[j].MTime)
}
// If times are equal and not recent, fallback to numeric ID
ii, err1 := strconv.Atoi(items[i].ID)
jj, err2 := strconv.Atoi(items[j].ID)
if err1 == nil && err2 == nil {
return ii > jj
}
return items[i].ID > items[j].ID
})
if limit > 0 && len(items) > limit {
@@ -697,6 +722,26 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
return items, nil
}
// validateBlogOrdering performs a startup health check to catch ordering regressions early.
func validateBlogOrdering(siteRoot string) error {
items, err := listLatestBlogs(siteRoot, 12)
if err != nil {
return err
}
if len(items) < 2 {
return nil
}
for i := 0; i < len(items)-1; i++ {
ii, e1 := strconv.Atoi(items[i].ID)
jj, e2 := strconv.Atoi(items[i+1].ID)
if e1 == nil && e2 == nil && ii <= jj {
return fmt.Errorf("blog ordering suspect: %s (%d) should be newer than %s (%d)", items[i].ID, ii, items[i+1].ID, jj)
}
}
log.Printf("blog ordering validated: newest=%s total=%d", items[0].ID, len(items))
return nil
}
// extractTitle finds the first <h1>...</h1> and returns its inner text (very simple, best-effort)
func extractTitle(path string) string {
b, err := os.ReadFile(path)
@@ -832,11 +877,21 @@ func main() {
go scheduler(ctx)
go videosScheduler(ctx)
// Load previously persisted videos so we have a fallback if yt api fails
if err := loadVideosJSON(); err != nil {
log.Printf("no persisted videos to load: %v", err)
}
// Initial videos fetch on startup to warm cache
if err := refreshVideos(ctx); err != nil {
log.Printf("initial videos refresh error: %v", err)
}
// Startup validation: warn if blog ordering looks wrong
if err := validateBlogOrdering(staticPath()); err != nil {
log.Printf("blog ordering validation: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
@@ -1600,13 +1655,27 @@ func refresh(ctx context.Context) error {
urlTable := fmt.Sprintf("%s/club/%s/%s/table", baseURL, clubType, clubID)
var detail ClubDetail
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
return fmt.Errorf("detail: %w", err)
}
var table ClubTable
var activeClubID string
// Try primary API first
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
log.Printf("primary api detail failed (%v), trying fallback", err)
urlDetail = fmt.Sprintf("%s/club/%s/%s?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
urlTable = fmt.Sprintf("%s/club/%s/%s/table?slug=%s", fallbackBaseURL, clubType, fallbackClubID, fallbackSlug)
if err := getJSON(ctx, client, urlDetail, &detail); err != nil {
return fmt.Errorf("fallback detail: %w", err)
}
if err := getJSON(ctx, client, urlTable, &table); err != nil {
return fmt.Errorf("fallback table: %w", err)
}
activeClubID = fallbackClubID
} else {
if err := getJSON(ctx, client, urlTable, &table); err != nil {
return fmt.Errorf("table: %w", err)
}
activeClubID = clubID
}
// Override or inject facr_link based on match_id
for i := range detail.Competitions {
@@ -1616,10 +1685,10 @@ func refresh(ctx context.Context) error {
detail.Competitions[i].Matches[j].FacrLink = fmt.Sprintf("https://www.fotbal.cz/futsal/zapasy/futsal/%s", mid)
}
// Override logo URLs for our club in match details
if detail.Competitions[i].Matches[j].HomeID == clubID {
if detail.Competitions[i].Matches[j].HomeID == activeClubID {
detail.Competitions[i].Matches[j].HomeLogoURL = "/img/logo.png"
}
if detail.Competitions[i].Matches[j].AwayID == clubID {
if detail.Competitions[i].Matches[j].AwayID == activeClubID {
detail.Competitions[i].Matches[j].AwayLogoURL = "/img/logo.png"
}
}
@@ -1628,7 +1697,7 @@ func refresh(ctx context.Context) error {
// Override logo URLs for our club in the table standings
for i := range table.Competitions {
for j := range table.Competitions[i].Table.Overall {
if table.Competitions[i].Table.Overall[j].TeamID == clubID {
if table.Competitions[i].Table.Overall[j].TeamID == activeClubID {
table.Competitions[i].Table.Overall[j].TeamLogo = "/img/logo.png"
}
}
@@ -1646,7 +1715,7 @@ func refresh(ctx context.Context) error {
if err := writeDiskJSON(c.data); err != nil {
log.Printf("warn: write disk json: %v", err)
}
log.Printf("refreshed data: comps=%d", len(detail.Competitions))
log.Printf("refreshed data: comps=%d source=%s", len(detail.Competitions), map[bool]string{true: "fallback", false: "primary"}[activeClubID == fallbackClubID])
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"testing"
"time"
)
// TestListLatestBlogsOrdering verifies that listLatestBlogs returns items
// sorted by numeric ID descending, regardless of file timestamps or order.
func TestListLatestBlogsOrdering(t *testing.T) {
// Create a temp directory structure mimicking the remote server
tmpDir := t.TempDir()
blogDir := filepath.Join(tmpDir, "blog")
imgDir := filepath.Join(tmpDir, "img", "blog")
if err := os.MkdirAll(blogDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(imgDir, 0755); err != nil {
t.Fatal(err)
}
// Create numeric blog files with IDs spanning a wide range.
// We intentionally create them in non-numeric order and touch
// old IDs with newer timestamps to simulate a migration.
files := []struct {
id string
slug string
title string
}{
{"0031", "vstupujeme-spolecne-do-druhe-ligy", "VSTUPUJEME SPOLEČNĚ DO DRUHÉ LIGY!"},
{"0032", "nova-mise-pred-nami", "NOVÁ MISE PŘED NÁMI!"},
{"0033", "superpohar-divizi-je-zde", "SUPERPOHÁR DIVIZÍ JE ZDE!"},
{"0034", "superpohar-je-nas", "SUPERPOHÁR JE NÁŠ!"},
{"0035", "fotoreport-1", "FOTOREPORT"},
{"0036", "regionalni-finale-je-tady", "REGIONÁLNÍ FINÁLE JE TADY!"},
{"0037", "bizoni-slavi-postup", "BIZONI SLAVÍ POSTUP!"},
{"0038", "fotoreport-2", "FOTOREPORT"},
{"0039", "2-liga-je-tu", "2. LIGA JE TU!"},
{"0040", "pred-startem-sezony-1", "PŘED STARTEM SEZONY"},
{"0041", "pred-startem-sezony-2", "PŘED STARTEM SEZÓNY"},
{"0042", "podpora-futsalu", "Podpora Futsalu"},
{"0169", "stepan-stodulka-fanouskum-3", "Štěpán Stodůlka fanouškům: Budujeme klub, který bude dlouhodobě silný"},
{"0170", "martin-prokes-fanouskum", "Martin Prokeš fanouškům: První futsalová sezóna přinesla cenné zkušenosti"},
{"0171", "andrea-adamikova-fanouskum", "Andrea Adamíková fanouškům: Druhé místo je motivací do další práce"},
{"0172", "stepan-stodulka-fanouskum-2", "Štěpán Stodůlka fanouškům: Bizonky jsou hrdou součástí našeho klubu"},
{"0173", "martin-lapcik-fanouskum", "Martin Lapčík fanouškům: První rok bizoní mládeže nás všechny nadchl"},
{"0174", "marek-stojaspal-fanouskum", "Marek Stojaspal fanouškům: Mládež položila pevné základy budoucnosti"},
{"0175", "stepan-stodulka-fanouskum-1", "Štěpán Stodůlka fanouškům: Mládež ukázala velký potenciál"},
{"0176", "dekujeme-my-jsme-tu-diky-vam", "DĚKUJEME, MY JSME TU DÍKY VÁM!"},
}
for _, f := range files {
// Create numeric HTML file with slug meta tag
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta name="slug" content="%s">
</head>
<body>
<h1 class="lte-header">%s</h1>
<div class="text lte-text-page clearfix">content</div>
</body>
</html>`, f.slug, f.title)
numericPath := filepath.Join(blogDir, f.id+".html")
if err := os.WriteFile(numericPath, []byte(htmlContent), 0644); err != nil {
t.Fatal(err)
}
// Create corresponding slug file (duplicate content)
slugPath := filepath.Join(blogDir, f.slug+".html")
if err := os.WriteFile(slugPath, []byte(htmlContent), 0644); err != nil {
t.Fatal(err)
}
// Create a dummy image
imgPath := filepath.Join(imgDir, f.id+".png")
if err := os.WriteFile(imgPath, []byte("fake png"), 0644); err != nil {
t.Fatal(err)
}
}
// Touch some old IDs with a newer timestamp to simulate post-migration
// (this is the exact condition that broke the old sorting).
// We sleep briefly between creation and touch so the timestamp is definitely newer.
importTime := time.Now()
for _, oldID := range []string{"0031", "0032", "0042"} {
fpath := filepath.Join(blogDir, oldID+".html")
if err := os.Chtimes(fpath, importTime, importTime); err != nil {
// ignore errors on Chtimes
}
}
// Call listLatestBlogs
items, err := listLatestBlogs(tmpDir, 12)
if err != nil {
t.Fatalf("listLatestBlogs error: %v", err)
}
if len(items) != 12 {
t.Fatalf("expected 12 items, got %d", len(items))
}
// Verify IDs are sorted descending (newest first)
for i := 0; i < len(items)-1; i++ {
curr, _ := strconv.Atoi(items[i].ID)
next, _ := strconv.Atoi(items[i+1].ID)
if curr <= next {
t.Errorf("IDs not sorted descending at index %d: %s (%d) <= %s (%d)",
i, items[i].ID, curr, items[i+1].ID, next)
}
}
// Verify the first item is the newest (0176)
if items[0].ID != "0176" {
t.Errorf("expected first item ID to be 0176, got %s (title: %s)", items[0].ID, items[0].Title)
}
if items[1].ID != "0175" {
t.Errorf("expected second item ID to be 0175, got %s", items[1].ID)
}
// Verify deduplication: total unique posts should be 20 (not 40)
allItems, err := listLatestBlogs(tmpDir, 0)
if err != nil {
t.Fatalf("listLatestBlogs unlimited error: %v", err)
}
if len(allItems) != 20 {
t.Errorf("expected 20 unique items after dedup, got %d", len(allItems))
}
// Verify all items are sorted descending
sorted := sort.SliceIsSorted(allItems, func(i, j int) bool {
ii, _ := strconv.Atoi(allItems[i].ID)
jj, _ := strconv.Atoi(allItems[j].ID)
return ii > jj
})
if !sorted {
t.Error("allItems are not sorted by numeric ID descending")
}
}
// TestListLatestBlogsNoSlug verifies numeric-only blogs still sort correctly.
func TestListLatestBlogsNoSlug(t *testing.T) {
tmpDir := t.TempDir()
blogDir := filepath.Join(tmpDir, "blog")
imgDir := filepath.Join(tmpDir, "img", "blog")
os.MkdirAll(blogDir, 0755)
os.MkdirAll(imgDir, 0755)
for _, id := range []string{"0001", "0005", "0010", "0002"} {
content := fmt.Sprintf(`<html><head></head><body><h1 class="lte-header">Title %s</h1></body></html>`, id)
os.WriteFile(filepath.Join(blogDir, id+".html"), []byte(content), 0644)
os.WriteFile(filepath.Join(imgDir, id+".png"), []byte("png"), 0644)
}
items, err := listLatestBlogs(tmpDir, 0)
if err != nil {
t.Fatal(err)
}
expected := []string{"0010", "0005", "0002", "0001"}
if len(items) != len(expected) {
t.Fatalf("expected %d items, got %d", len(expected), len(items))
}
for i, exp := range expected {
if items[i].ID != exp {
t.Errorf("index %d: expected %s, got %s", i, exp, items[i].ID)
}
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+28 -7
View File
@@ -1774,13 +1774,6 @@
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://alpacar.pl/" target="_blank">
<img decoding="async" src="img/sponzor17.png" class="image">
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://vechra.cz/" target="_blank">
@@ -1865,6 +1858,34 @@
</a>
</div>
</div>
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://www.kovosteel.cz/" target="_blank">
<img decoding="async" src="img/sponzor30.png" class="image">
</a>
</div>
</div>
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="http://gracla.cz/" target="_blank">
<img decoding="async" src="img/sponzor31.png" class="image">
</a>
</div>
</div>
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://nsa.gov.cz/" target="_blank">
<img decoding="async" src="img/sponzor32.png" class="image">
</a>
</div>
</div>
<div class="col-sxl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://zlinskykraj.cz/" target="_blank">
<img decoding="async" src="img/sponzor33.png" class="image">
</a>
</div>
</div>
</div>
</div>
</div>
+6
View File
@@ -51,6 +51,12 @@
const res = await fetch('/api/blog/latest?limit=12', {credentials: 'omit'});
if (!res.ok) throw new Error('HTTP '+res.status);
let items = await res.json();
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
items.sort((a,b)=>{
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
return (b.id||'').localeCompare(a.id||'');
});
if (primary) primary.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
if (primary) primary.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#888;">Žádné příspěvky zatím nejsou.</div>';
+6
View File
@@ -6,6 +6,12 @@
if (!res.ok) throw new Error('HTTP '+res.status);
let items = await res.json();
if (!Array.isArray(items) || items.length === 0) items = [];
// Defensive sort: numeric ID descending ensures newest first regardless of API ordering
items.sort((a,b)=>{
const ai = parseInt(a.id,10); const bi = parseInt(b.id,10);
if (!isNaN(ai) && !isNaN(bi)) return bi-ai;
return (b.id||'').localeCompare(a.id||'');
});
// Update background images of zoom slider slides if present
const slides = document.querySelectorAll('.lte-slider-zoom .zs-slides .zs-slide');
+29 -8
View File
@@ -191,7 +191,7 @@
<div class="lte-heading lte-style-header-subheader lte-uppercase lte-subcolor-main has-subheader heading-tag-h6 heading-subtag-h6">
<div class="lte-heading-content">
<h6 class="lte-subheader">2025 / 2026</h6>
<h6 class="lte-header">Účastník 2. FUTSAL LIGY</h6>
<h6 class="lte-header">Výherce 2. FUTSAL LIGY</h6>
</div>
</div>
</div>
@@ -464,13 +464,6 @@
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://alpacar.pl/" target="_blank">
<img decoding="async" src="img/sponzor17.png" class="image">
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://vechra.cz/" target="_blank">
@@ -555,6 +548,34 @@
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://www.kovosteel.cz/" target="_blank">
<img decoding="async" src="img/sponzor30.png" class="image">
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="http://gracla.cz/" target="_blank">
<img decoding="async" src="img/sponzor31.png" class="image">
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://nsa.gov.cz/" target="_blank">
<img decoding="async" src="img/sponzor32.png" class="image">
</a>
</div>
</div>
<div class="col-xl-3 col-lg-3 col-md-6 col-sm-6 col-ms-6 col-xs-12 partners-wrap center-flex">
<div class="partners-item item center-flex">
<a href="https://zlinskykraj.cz/" target="_blank">
<img decoding="async" src="img/sponzor33.png" class="image">
</a>
</div>
</div>
</div>
</div>
</div>