This commit is contained in:
Tomas Dvorak
2025-10-31 18:22:04 +01:00
parent 16e4533202
commit ac886502e0
65 changed files with 3211 additions and 553 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"items":[],"page":1,"page_size":10,"total":0}
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
+1
View File
@@ -0,0 +1 @@
[{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"A1A","alias":"SATUM 5. liga mužů","original_name":"SATUM 5. liga mužů","display_order":1},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"C1A","alias":"KALMAN TRADE Krajský přebor starší dorost","original_name":"KALMAN TRADE Krajský přebor starší dorost","display_order":2},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"D1A","alias":"KALMAN TRADE Krajský přebor mladší dorost","original_name":"KALMAN TRADE Krajský přebor mladší dorost","display_order":3},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E1S","alias":"2.MSŽL-U 15 sk. E","original_name":"2.MSŽL-U 15 sk. E","display_order":4},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E2S","alias":"2.MSŽL-U 14 sk. E","original_name":"2.MSŽL-U 14 sk. E","display_order":5},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F1S","alias":"1. liga SpSM-U 13 SEVER","original_name":"1. liga SpSM-U 13 SEVER","display_order":6},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F2S","alias":"1. liga SpSM-U 12 SEVER","original_name":"1. liga SpSM-U 12 SEVER","display_order":7},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"G1D","alias":"Starší přípravka 1+5 sk.D","original_name":"Starší přípravka 1+5 sk.D","display_order":8},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1A","alias":"Okresní přebor mladší přípravky (4+1)","original_name":"Okresní přebor mladší přípravky (4+1)","display_order":9},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1C","alias":"Mladší přípravka 1+4 sk.C","original_name":"Mladší přípravka 1+4 sk.C","display_order":10},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"U1E","alias":"PC U1E U-10 Šumperk","original_name":"PC U1E U-10 Šumperk","display_order":11},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V5B","alias":"PC V5B U-9 Hlučín","original_name":"PC V5B U-9 Hlučín","display_order":12},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V1C","alias":"PC V1C U-8 Nový Jičín","original_name":"PC V1C U-8 Nový Jičín","display_order":13},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V2B","alias":"PC V2B U-8 Uničov","original_name":"PC V2B U-8 Uničov","display_order":14}]
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:39Z","last_modified":""}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:44Z","last_modified":""}
+1
View File
@@ -0,0 +1 @@
null
+244
View File
@@ -0,0 +1,244 @@
[
{
"away": "Darkovičky",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/8e207b30-7b68-44bb-ad08-bc25495dd094/8e207b30-7b68-44bb-ad08-bc25495dd094_crop.jpg",
"competition": "SATUM 5. liga mužů",
"date": "2025-11-02",
"home": "FK Kofola Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "243d0ef5-1d92-45cd-b1ce-f4c71bd34fba",
"time": "14:00",
"venue": "Krnov-tráva"
},
{
"away": "FK Kofola Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "SATUM 5. liga mužů",
"date": "2025-11-09",
"home": "FC Vřesina",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/dc05f9c5-a436-4fce-b9cb-06c7ff85d019/dc05f9c5-a436-4fce-b9cb-06c7ff85d019_crop.jpg",
"id": "03347fa2-2d39-49e0-840b-b5a1fea723e2",
"time": "14:00",
"venue": "Vřesina - tráva"
},
{
"away": "FK Kofola Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "SATUM 5. liga mužů",
"date": "2025-11-15",
"home": "Kobeřice",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/55f96307-c916-4801-948b-bc84f46f21bd/55f96307-c916-4801-948b-bc84f46f21bd_crop.jpg",
"id": "761a2e5a-8b0f-4514-b35c-ba019c957a3e",
"time": "13:30",
"venue": "Kobeřice - tráva"
},
{
"away": "Brušperk",
"away_logo_url": "/dist/img/logo-club-empty.svg",
"competition": "KALMAN TRADE Krajský přebor starší dorost",
"date": "2025-11-02",
"home": "Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "145f789c-ba87-4e25-9992-91a0db096319",
"time": "09:30",
"venue": "Krnov-tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "KALMAN TRADE Krajský přebor starší dorost",
"date": "2025-11-09",
"home": "Frýdlant n. O.",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "afbe0993-ae23-4bf2-9253-1aea603d8c4f",
"time": "12:00",
"venue": "Frýdlant n. O. - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "KALMAN TRADE Krajský přebor starší dorost",
"date": "2025-11-16",
"home": "FK H\u0026P Staré Město",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg",
"id": "8211e3c7-3cef-4be8-88b7-367fa5960506",
"time": "10:00",
"venue": "Chlebovice - tráva"
},
{
"away": "Brušperk",
"away_logo_url": "/dist/img/logo-club-empty.svg",
"competition": "KALMAN TRADE Krajský přebor mladší dorost",
"date": "2025-11-02",
"home": "Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "80185774-6646-41b8-8eed-a7d020e009c8",
"time": "11:45",
"venue": "Krnov-tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "KALMAN TRADE Krajský přebor mladší dorost",
"date": "2025-11-09",
"home": "Frýdlant n. O.",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "8e5e969d-a6e4-4f79-afe1-1e666b6c931f",
"time": "14:15",
"venue": "Frýdlant n. O. - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "KALMAN TRADE Krajský přebor mladší dorost",
"date": "2025-11-16",
"home": "FK H\u0026P Staré Město",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg",
"id": "3ac0d48d-0353-4e85-b313-695db2909cff",
"time": "12:15",
"venue": "Chlebovice - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 15 sk. E",
"date": "2025-11-19",
"home": "Karviná",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/4cbe25e6-57f3-41c0-8d92-782b19b61731/4cbe25e6-57f3-41c0-8d92-782b19b61731_crop.jpg",
"id": "8604ff36-b0df-46c1-92a1-10c04d01ce07",
"time": "17:30",
"venue": "UMT Kovona"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 15 sk. E",
"date": "2025-11-02",
"home": "Hranice",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "00e7326e-4511-4c0a-b054-482d85235db0",
"time": "10:00",
"venue": "Žáčkova, tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 15 sk. E",
"date": "2025-11-16",
"home": "Valašské Meziříčí",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "42b21b39-2f7e-466c-98ac-3969afd46b75",
"time": "10:00",
"venue": "Valašské Meziříčí"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 14 sk. E",
"date": "2025-11-19",
"home": "Karviná",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/4cbe25e6-57f3-41c0-8d92-782b19b61731/4cbe25e6-57f3-41c0-8d92-782b19b61731_crop.jpg",
"id": "883313c6-7766-4496-a1f4-aa0365e683b6",
"time": "17:30",
"venue": "UT - Městský stadion"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 14 sk. E",
"date": "2025-11-02",
"home": "Hranice",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "9afa685b-0537-47e1-ac74-d85c9e39ff76",
"time": "12:15",
"venue": "Žáčkova, tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 14 sk. E",
"date": "2025-11-16",
"home": "Valašské Meziříčí",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "fe82ff0c-75e9-4ff0-9834-8a42a5053427",
"time": "12:00",
"venue": "Valašské Meziříčí"
},
{
"away": "Přerov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/1fd1a047-4cf5-47cc-a712-915928cba6fb/1fd1a047-4cf5-47cc-a712-915928cba6fb_crop.jpg",
"competition": "1. liga SpSM-U 13 SEVER",
"date": "2025-11-02",
"home": "Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "fff13fd1-e688-4274-83be-78b94854938d",
"time": "10:00",
"venue": "Atletický stadion Krnov - tráva"
},
{
"away": "Baník Ostrava",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/e68e68c6-c263-43ce-a247-20ee1d323b55/e68e68c6-c263-43ce-a247-20ee1d323b55_crop.jpg",
"competition": "1. liga SpSM-U 13 SEVER",
"date": "2025-11-09",
"home": "Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "fbba2e97-9cde-441c-961e-39d601fb7d1d",
"time": "10:00",
"venue": "Atletický stadion Krnov - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "1. liga SpSM-U 13 SEVER",
"date": "2025-11-15",
"home": "VÍTKOVICE",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/a3ff17d6-0888-47e7-9dee-0a98ec8734d0/a3ff17d6-0888-47e7-9dee-0a98ec8734d0_crop.jpg",
"id": "3090d0e0-2d1e-44df-8312-f223673fedcb",
"time": "10:00",
"venue": "UT Vista"
},
{
"away": "Přerov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/1fd1a047-4cf5-47cc-a712-915928cba6fb/1fd1a047-4cf5-47cc-a712-915928cba6fb_crop.jpg",
"competition": "1. liga SpSM-U 12 SEVER",
"date": "2025-11-02",
"home": "Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "c2fcf6d5-806d-4efb-b424-40cdead7eb24",
"time": "11:45",
"venue": "Atletický stadion Krnov - tráva"
},
{
"away": "Baník Ostrava",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/e68e68c6-c263-43ce-a247-20ee1d323b55/e68e68c6-c263-43ce-a247-20ee1d323b55_crop.jpg",
"competition": "1. liga SpSM-U 12 SEVER",
"date": "2025-11-09",
"home": "Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "91c885fd-8490-49f2-863e-ac7ba3082f70",
"time": "11:45",
"venue": "Atletický stadion Krnov - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "1. liga SpSM-U 12 SEVER",
"date": "2025-11-15",
"home": "VÍTKOVICE",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/a3ff17d6-0888-47e7-9dee-0a98ec8734d0/a3ff17d6-0888-47e7-9dee-0a98ec8734d0_crop.jpg",
"id": "8fed4192-b8df-4301-a2b9-f97c46f7cacc",
"time": "12:00",
"venue": "UT Vista"
},
{
"away": "Břidličná",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/47899f56-22a7-4a71-9fd7-c94adbcead76/47899f56-22a7-4a71-9fd7-c94adbcead76_crop.jpg",
"competition": "Okresní přebor mladší přípravky (4+1)",
"date": "2025-11-04",
"home": "Krnov",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"id": "12594085-a1a6-4539-92e0-d768c33c83a8",
"time": "16:00",
"venue": "Atletický stadion Krnov - tráva"
}
]
+1
View File
@@ -0,0 +1 @@
{"lastUpdated":"2025-10-31T17:21:44Z"}
+52
View File
@@ -0,0 +1,52 @@
{
"baseURL": "http://localhost:8080/api/v1",
"duration_ms": 8740,
"endpoints": [
{
"path": "/settings",
"file": "settings.json",
"ok": true
},
{
"path": "/seo",
"file": "seo.json",
"ok": true
},
{
"path": "/articles?page=1\u0026page_size=10\u0026published=true",
"file": "articles.json",
"ok": true
},
{
"path": "/sponsors",
"file": "sponsors.json",
"ok": true
},
{
"path": "/events/upcoming",
"file": "events_upcoming.json",
"ok": true
},
{
"path": "/public/team-logo-overrides",
"file": "team_logo_overrides.json",
"ok": true
},
{
"path": "/competition-aliases",
"file": "competition_aliases.json",
"ok": true
},
{
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
"file": "facr_club_info.json",
"ok": true
},
{
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58/table",
"file": "facr_tables.json",
"ok": true
}
],
"lastUpdated": "2025-10-31T17:21:44Z"
}
+1
View File
@@ -0,0 +1 @@
{"additional_meta":"","canonical_base_url":"http://localhost:3000","default_og_image_url":"http://logoapi.sportcreative.eu/logos/7eacd9f0-bfa0-4928-a9b6-936140168f58?format=svg","enable_indexing":true,"meta_keywords":"","site_description":"Fotbalový klub Krnov oficiální klubový web: aktuality, zápasy, tabulky, hráči.","site_title":"Fotbalový klub Krnov","twitter_handle":""}
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
+1
View File
@@ -0,0 +1 @@
{"about_html":"","accent_color":"#ffae00","api_base_url":"http://localhost:8080/api/v1","background_color":"#ffffff","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_logo_url":"/uploads/logos/club/7eacd9f0-bfa0-4928-a9b6-936140168f58/club-logo.svg","club_name":"Fotbalový klub Krnov","club_type":"football","club_url":"https://www.fotbal.cz/souteze/club/club/7eacd9f0-bfa0-4928-a9b6-936140168f58","contact_address":"Smetanův okruh","contact_city":"Krnov","contact_country":"Česko","contact_email":"info@tdvorak.dev","contact_phone":"+420778701838","contact_zip":"794 01","custom_nav":null,"facebook_url":"https://www.facebook.com/people/FK-Kofola-Krnov/61561103731912","font_body":"Archivo","font_heading":"Archivo","frontend_base_url":"http://localhost:3000","gallery_label":"","gallery_url":"https://eu.zonerama.com/FKKofolaKrnov/1470757","instagram_url":"https://www.instagram.com/fkkofolakrnov/","location_latitude":50.0944622,"location_longitude":17.6999758,"map_style":"voyager","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","primary_color":"#ffdd00","secondary_color":"#004cff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":[{"length":"","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","uploaded_at":"2025-10-17","url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/_OsRmfYOXJ4/maxresdefault.jpg","title":"Bizoni UH-Atraps Brno 6:5/3:4/-4.kolo 2.futs.liga Východ-UH 10.10.25","uploaded_at":"2025-10-17","url":"https://www.youtube.com/watch?v=_OsRmfYOXJ4"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg","title":"Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25","uploaded_at":"2025-10-01","url":"https://www.youtube.com/watch?v=h_-TS6oVvKA"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg","title":"Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH","uploaded_at":"2025-10-01","url":"https://www.youtube.com/watch?v=ozH8xE7V458"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg","title":"Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti","uploaded_at":"2025-10-01","url":"https://www.youtube.com/watch?v=nrj6_1IoYoo"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","youtube_url":"https://www.youtube.com/@FCBizoniUH"}
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
+1
View File
@@ -0,0 +1 @@
{"by_id":{"eb9e21fd-42a0-4ff5-b253-a028343da896":{"logo_url":"http://logoapi.sportcreative.eu/logos/eb9e21fd-42a0-4ff5-b253-a028343da896?format=png","name":"Spolek SK Brušperk"}},"by_name":{"Spolek SK Brušperk":"http://logoapi.sportcreative.eu/logos/eb9e21fd-42a0-4ff5-b253-a028343da896?format=png"}}
+1
View File
@@ -0,0 +1 @@
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"fetched_at":"2025-10-31T15:21:39Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
+102
View File
@@ -0,0 +1,102 @@
[
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-31T15:21:51Z"
}
]
+1
View File
@@ -0,0 +1 @@
null
+4
View File
@@ -0,0 +1,4 @@
{
"fetched_at": "2025-10-31T15:21:51Z",
"link": ""
}
File diff suppressed because it is too large Load Diff
+16 -5
View File
@@ -287,7 +287,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
</Drawer>
);
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth = false, variant = 'unified' }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -726,6 +726,17 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
return out;
}, [navSplit]);
// Variant-driven styles for main nav bar
const headerVariant = variant || 'unified';
const navBgDefault = useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)');
const navBgMinimal = useColorModeValue('white', '#0f1115');
const isTransparent = headerVariant === 'transparent';
const isMinimal = headerVariant === 'minimal';
const navBg = isTransparent ? 'transparent' : (isMinimal ? navBgMinimal : navBgDefault);
const navBackdrop = (isTransparent || isMinimal) ? 'none' : 'saturate(180%) blur(10px)';
const navBorderBottomWidth = isTransparent ? '0px' : '1px';
const navBoxShadow = (isTransparent || isMinimal) ? 'none' : (scrolled ? 'sm' : 'none');
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
@@ -758,11 +769,11 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
{/* Main Nav Bar */}
<Box
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')}
backdropFilter="saturate(180%) blur(10px)"
borderBottomWidth="1px"
bg={navBg}
backdropFilter={navBackdrop}
borderBottomWidth={navBorderBottomWidth}
borderColor="border.subtle"
boxShadow={scrolled ? 'sm' : 'none'}
boxShadow={navBoxShadow}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
@@ -0,0 +1,199 @@
import React from 'react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box } from '@chakra-ui/react';
import { Share2 } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink } from '../../services/shortlinks';
import { Article, getArticleMatchLink } from '../../services/articles';
import { API_URL } from '../../services/api';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot } from '../../services/instagram';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface Props {
article?: Article;
activity?: any;
match?: MatchSnapshot | null;
targetUrl?: string;
size?: 'sm' | 'md' | 'lg';
placement?: 'fixed' | 'inline';
mr?: number | string;
mb?: number | string;
zIndex?: number;
variant?: 'button' | 'icon';
onGenerated?: (text: string, shortUrl: string) => void;
}
const InstagramGeneratorButton: React.FC<Props> = ({
article,
activity,
match,
targetUrl,
size = 'md',
placement = 'fixed',
mr = 6,
mb = 6,
zIndex = 40,
variant = 'icon',
onGenerated,
}) => {
const { user } = useAuth();
const role = String(user?.role || '').toLowerCase();
const isAdmin = role === 'admin';
const { data: publicSettings } = usePublicSettings();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const [text, setText] = React.useState('');
const [shortUrl, setShortUrl] = React.useState('');
const [loading, setLoading] = React.useState(false);
if (!isAdmin) return null;
const computeTarget = () => {
if (targetUrl) return targetUrl;
if (typeof window !== 'undefined') return window.location.href;
return '';
};
const withUtm = (urlStr: string) => {
try {
const u = new URL(urlStr, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
if (!u.searchParams.get('utm_source')) u.searchParams.set('utm_source', 'instagram');
if (!u.searchParams.get('utm_medium')) u.searchParams.set('utm_medium', 'social');
const campaignBase = article ? `article-${article.id}` : (activity ? `activity-${activity.id}` : 'share');
if (!u.searchParams.get('utm_campaign')) u.searchParams.set('utm_campaign', campaignBase);
return u.toString();
} catch {
return urlStr;
}
};
const handleGenerate = async (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
setLoading(true);
const fullUrl = withUtm(computeTarget());
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
} as any;
const res = await createShortLink(payload);
const sUrl = res?.short_url || '';
setShortUrl(sUrl || fullUrl);
const clubName = publicSettings?.club_name || undefined;
let composed = '';
if (article) {
let resolvedMatch = match || null;
if (!resolvedMatch && article?.id) {
try {
const link = await getArticleMatchLink(article.id);
const extId = (link as any)?.external_match_id;
if (extId) {
try {
const origin = new URL(API_URL, window.location.origin).origin;
const resp = await fetch(`${origin}/cache/prefetch/facr_club_info.json`, { cache: 'no-cache' });
if (resp.ok) {
const json = await resp.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
let snapshot: MatchSnapshot | null = null;
for (const c of comps) {
const matches = Array.isArray(c.matches) ? c.matches : [];
for (const m of matches) {
const mid = String(m.match_id || m.id);
if (mid === String(extId)) {
let score = '';
if (m?.score && m.score !== 'vs') score = String(m.score);
else if (m?.result_home != null && m?.result_away != null) score = `${m.result_home}:${m.result_away}`;
snapshot = {
external_match_id: String(extId),
competition: String(c.name || ''),
date_time: String(m.date_time || m.date || ''),
venue: m.venue ? String(m.venue) : undefined,
home: String(m.home || m.home_team || ''),
away: String(m.away || m.away_team || ''),
score,
};
break;
}
}
if (snapshot) break;
}
if (snapshot) resolvedMatch = snapshot;
}
} catch {}
}
} catch {}
}
composed = composeInstagramPostFromArticle({ article, trackingUrl: sUrl || fullUrl, clubName, match: resolvedMatch });
} else if (activity) {
composed = composeInstagramPostFromActivity({ activity, trackingUrl: sUrl || fullUrl, clubName });
} else {
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
}
setText(composed);
onGenerated?.(composed, sUrl || fullUrl);
onOpen();
} catch (err: any) {
toast({ status: 'error', title: 'Nelze vygenerovat příspěvek', description: err?.message || 'Zkuste to prosím znovu.' });
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
toast({ status: 'success', title: 'Zkopírováno', description: 'Text příspěvku byl zkopírován do schránky.' });
} catch {
toast({ status: 'warning', title: 'Nelze kopírovat', description: 'Zkopírujte prosím ručně.' });
}
};
const ButtonEl = (
<Tooltip label="Vygenerovat Instagram příspěvek" placement="left">
{variant === 'icon' ? (
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
) : (
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size}>
Instagram post
</Button>
)}
</Tooltip>
);
return (
<>
{placement === 'fixed' ? (
<Box position="fixed" right={mr} bottom={mb} zIndex={zIndex}>
{ButtonEl}
</Box>
) : (
<Box position="absolute" top={2} right={2} zIndex={zIndex}>
{ButtonEl}
</Box>
)}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Instagram post</ModalHeader>
<ModalBody>
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={12} fontFamily="mono" />
</ModalBody>
<ModalFooter gap={3}>
<Button variant="outline" onClick={handleCopy}>Kopírovat</Button>
<Button onClick={onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default InstagramGeneratorButton;
+65 -36
View File
@@ -415,6 +415,71 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
{/* Tab 2: Create new poll */}
<TabPanel px={0} py={3}>
<VStack spacing={3} align="stretch">
<HStack spacing={2} flexWrap="wrap">
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
style: 'rating-stars',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}> 5</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení (110)',
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
style: 'rating-scale',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}>110</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka',
description: 'Dej vědět, zda dorazíš.',
type: 'single',
style: 'choices-chips',
allow_multiple: false,
max_choices: 1,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
]
}))}>Docházka</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka (více možností)',
description: 'Vyberte jednu nebo dvě možnosti.',
type: 'multiple',
style: 'choices-cards',
allow_multiple: true,
max_choices: 2,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Pozdě dorazím', display_order: 1 },
{ text: 'Ne', display_order: 2 },
]
}))}>Docházka (multi)</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Výběr možností',
description: 'Vyber až tři možnosti.',
type: 'multiple',
style: 'choices-list',
allow_multiple: true,
max_choices: 3,
options: [
{ text: 'A', display_order: 0 },
{ text: 'B', display_order: 1 },
{ text: 'C', display_order: 2 },
{ text: 'D', display_order: 3 },
]
}))}>Multi (3)</Button>
</HStack>
<FormControl isRequired>
<FormLabel fontSize="sm">Název ankety</FormLabel>
<Input
@@ -503,42 +568,6 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
>
Přidat možnost
</Button>
<HStack>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
style: 'rating-stars',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}> 5</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Hodnocení (110)',
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
style: 'rating-scale',
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
}))}>110</Button>
<Button size="xs" onClick={() => setNewPollData(prev => ({
...prev,
title: 'Docházka',
description: 'Dej vědět, zda dorazíš.',
type: 'single',
style: 'choices-chips',
allow_multiple: false,
max_choices: 1,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
]
}))}>Docházka</Button>
</HStack>
</VStack>
</FormControl>
@@ -105,11 +105,11 @@ import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../
const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
news: ['grid_one', 'grid_two', 'grid', 'scroller'],
news: ['grid_one', 'grid_two', 'grid', 'list', 'scroller'],
matches: ['compact'],
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
gallery: ['grid'],
videos: ['grid'],
videos: ['grid', 'carousel'],
merch: ['grid'],
table: ['split_news'],
banner: ['top'],
@@ -426,11 +426,16 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant)
}));
// Load saved custom CSS from settings
// Load saved styles and custom CSS from settings
const cssByElement: Record<string, string> = {};
const stylesByElement: Record<string, Record<string, any>> = {};
sanitizedConfigs.forEach(cfg => {
const css = (cfg.settings && (cfg.settings as any).customCSS) || '';
if (css) cssByElement[cfg.element_name] = String(css);
const css = String(((cfg.settings as any)?.customCSS) || ((cfg.settings as any)?.styles?.customCSS) || '');
if (css) cssByElement[cfg.element_name] = css;
const st = (cfg.settings && (cfg.settings as any).styles) || {};
if (st && typeof st === 'object' && Object.keys(st).length > 0) {
stylesByElement[cfg.element_name] = st as Record<string, any>;
}
});
setConfigs(sanitizedConfigs);
const changes: Record<string, string> = {};
@@ -448,16 +453,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setLocalChanges(changes);
setVisibleElements(visible);
setElementOrder(order);
// Prime style state with saved custom CSS
if (Object.keys(cssByElement).length > 0) {
// Prime style state with saved styles + custom CSS
if (Object.keys(stylesByElement).length > 0 || Object.keys(cssByElement).length > 0) {
setElementStyles(prev => {
const next = { ...prev } as Record<string, any>;
Object.entries(stylesByElement).forEach(([name, st]) => {
next[name] = { ...(next[name] || {}), ...st };
});
Object.entries(cssByElement).forEach(([name, css]) => {
next[name] = { ...(next[name] || {}), customCSS: css };
});
return next;
});
// Inject saved CSS for preview (admin only)
// Inject saved custom CSS for preview (admin only)
Object.entries(cssByElement).forEach(([name, css]) => {
try {
const styleId = `custom-css-${name}`;
@@ -7,6 +7,7 @@ import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
import { Eye, Clock } from 'lucide-react';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const Card: React.FC<{ a: Article }> = ({ a }) => {
const cardBg = useColorModeValue('white', 'gray.800');
@@ -90,6 +91,14 @@ const Card: React.FC<{ a: Article }> = ({ a }) => {
)}
</HStack>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</Box>
);
};
+10
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
@@ -24,6 +25,7 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
borderColor={border}
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
>
<Box position="relative" overflow="hidden">
<Image
@@ -70,6 +72,14 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
)}
</HStack>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</VStack>
);
};
+18 -1
View File
@@ -5,6 +5,7 @@ import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { Eye, Clock } from 'lucide-react';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const FeaturedBlog: React.FC = () => {
const { data, isLoading } = useQuery({
@@ -51,13 +52,21 @@ const FeaturedBlog: React.FC = () => {
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
<Heading size="md">{main.title}</Heading>
</VStack>
<Box position="absolute" top={3} right={3} zIndex={2}>
<InstagramGeneratorButton
article={main as any}
targetUrl={typeof window !== 'undefined' ? new URL(main.slug ? `/news/${main.slug}` : `/articles/${main.id}`, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</Box>
)}
</GridItem>
<GridItem>
<VStack spacing={4} align="stretch">
{[side1, side2].filter(Boolean).map((a) => (
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`} position="relative">
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
<VStack align="stretch" spacing={2} flex={1}>
<HStack spacing={2} flexWrap="wrap">
@@ -80,6 +89,14 @@ const FeaturedBlog: React.FC = () => {
</HStack>
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL((a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</HStack>
))}
</VStack>
+11 -1
View File
@@ -19,7 +19,7 @@ const TeamScroller: React.FC = () => {
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
{p.date_of_birth ? (
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
Věk: {calculateAge(p.date_of_birth)} let
Věk: {(() => { const a = calculateAge(p.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}
</Text>
) : null}
</VStack>
@@ -43,4 +43,14 @@ function calculateAge(dob: string): number | null {
}
}
// Czech pluralization for years
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
return 'let';
}
export default TeamScroller;
@@ -8,8 +8,8 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { useEffect, useMemo, useState } from 'react';
type Props = {
// optional manual override
videos?: string[];
variant?: 'grid' | 'carousel';
};
type RenderItem = {
@@ -39,7 +39,7 @@ const toEmbed = (idOrUrl: string): string => {
return `https://www.youtube.com/embed/${idOrUrl}`;
};
const VideosSection: React.FC<Props> = ({ videos }) => {
const VideosSection: React.FC<Props> = ({ videos, variant }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
@@ -55,7 +55,11 @@ const VideosSection: React.FC<Props> = ({ videos }) => {
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
? Boolean((settings as any)?.videos_module_enabled)
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
const style = settings?.videos_style || 'slider';
const style = (() => {
if (variant === 'carousel') return 'slider';
if (variant === 'grid') return 'grid';
return settings?.videos_style || 'slider';
})();
const source = settings?.videos_source || 'auto';
// Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
@@ -50,7 +50,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
)}
</Box>
{children}
@@ -70,7 +70,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
)}
</Box>
<Container maxW="container.xl" py={8}>
+48 -1
View File
@@ -25,7 +25,8 @@ const MatchesSlider: React.FC<{
onActiveChange: (idx: number) => void;
onMatchClick?: (m: SliderMatch, compName?: string) => void;
elementProps?: any;
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps }) => {
variant?: 'carousel' | 'scroller' | 'ticker' | 'compact_split';
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
const trackRef = useRef<HTMLDivElement | null>(null);
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
@@ -56,6 +57,52 @@ const MatchesSlider: React.FC<{
} catch {}
}, [activeIndex, JSON.stringify(current?.matches)]);
// Ticker variant - continuous belt animation
if (variant === 'ticker') {
const items = (current?.matches || []);
const looped = [...items, ...items, ...items];
return (
<section className="matches-slider matches-ticker" {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="ticker-belt">
{looped.map((m, idx) => (
<div
key={`${m.id || idx}-ticker`}
className="match-card"
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
<div className="teams">
<div className="team">
<TeamLogo teamId={m.home_id} teamName={m.home} facrLogo={m.home_logo_url} size="custom" alt={m.home} borderRadius="full" />
<div className="name">{sanitizeClubName(m.home || '')}</div>
</div>
<div className="score">
{m.score ? (
<>
<span className="home">{String(m.score).split(':')[0]}</span>
<span className="sep">:</span>
<span className="away">{String(m.score).split(':')[1]}</span>
</>
) : (
<span className="time">{m.time}</span>
)}
</div>
<div className="team">
<TeamLogo teamId={m.away_id} teamName={m.away} facrLogo={m.away_logo_url} size="custom" alt={m.away} borderRadius="full" />
<div className="name">{sanitizeClubName(m.away || '')}</div>
</div>
</div>
</div>
))}
</div>
</section>
);
}
return (
<section className="matches-slider" {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
+96 -2
View File
@@ -71,6 +71,67 @@ export const useAllPageElementConfigs = (pageType: string) => {
});
};
// Helper: inject style properties for each element into a single <style> tag
// This ensures style applying/previewing works even if components do not spread getStyles()
const updateInjectedStyleProps = (stylesMap: Record<string, Record<string, any>>) => {
try {
const styleId = 'myuibrix-style-props';
let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
document.head.appendChild(styleEl);
}
const cssBlocks: string[] = [];
const toPx = (v: any): string => (typeof v === 'number' ? `${v}px` : `${v}`);
const addDecl = (decls: string[], prop: string, val: any, unit?: 'px' | '') => {
if (val === undefined || val === null || val === '') return;
const needsPx = unit === 'px';
const v = typeof val === 'number' && needsPx ? `${val}px` : `${val}`;
decls.push(`${prop}: ${v} !important;`);
};
Object.entries(stylesMap || {}).forEach(([name, st]) => {
if (!st || typeof st !== 'object') return;
const decls: string[] = [];
addDecl(decls, 'font-family', st.fontFamily);
addDecl(decls, 'font-size', st.fontSize, 'px');
addDecl(decls, 'font-weight', st.fontWeight);
addDecl(decls, 'line-height', st.lineHeight);
addDecl(decls, 'letter-spacing', st.letterSpacing, 'px');
addDecl(decls, 'text-transform', st.textTransform);
addDecl(decls, 'color', st.color);
addDecl(decls, 'background-color', st.backgroundColor);
addDecl(decls, 'padding-top', st.paddingTop, 'px');
addDecl(decls, 'padding-right', st.paddingRight, 'px');
addDecl(decls, 'padding-bottom', st.paddingBottom, 'px');
addDecl(decls, 'padding-left', st.paddingLeft, 'px');
addDecl(decls, 'margin-top', st.marginTop, 'px');
addDecl(decls, 'margin-right', st.marginRight, 'px');
addDecl(decls, 'margin-bottom', st.marginBottom, 'px');
addDecl(decls, 'margin-left', st.marginLeft, 'px');
// width/height may be numbers (px) or strings (%, auto, etc.)
if (st.width !== undefined) addDecl(decls, 'width', st.width, typeof st.width === 'number' ? 'px' : '');
if (st.height !== undefined) addDecl(decls, 'height', st.height, typeof st.height === 'number' ? 'px' : '');
addDecl(decls, 'display', st.display);
addDecl(decls, 'grid-template-columns', st.gridTemplateColumns);
addDecl(decls, 'grid-template-rows', st.gridTemplateRows);
addDecl(decls, 'grid-auto-flow', st.gridAutoFlow);
addDecl(decls, 'grid-column-gap', st.gridColumnGap, 'px');
addDecl(decls, 'grid-row-gap', st.gridRowGap, 'px');
addDecl(decls, 'align-items', st.alignItems);
addDecl(decls, 'justify-items', st.justifyItems);
if (decls.length > 0) {
cssBlocks.push(`[data-element="${name}"] { ${decls.join(' ')} }`);
}
});
styleEl.textContent = cssBlocks.join('\n');
} catch {}
};
const loadConfigs = async () => {
try {
const data = await getPageElementConfigs(pageType);
@@ -123,8 +184,22 @@ export const useAllPageElementConfigs = (pageType: string) => {
setVisibility(visMap);
if (Object.keys(stylesMap).length > 0) {
setStyles(stylesMap);
} else {
setStyles({});
}
setElementOrder(order);
// Apply style-pack body class
try {
const sp = configMap['style-pack'] || 'default';
const body = document.body;
// Remove any previous style-pack-* classes
Array.from(body.classList)
.filter(cls => cls.startsWith('style-pack-'))
.forEach(cls => body.classList.remove(cls));
body.classList.add(`style-pack-${sp}`);
} catch {}
// Inject style properties so they apply even without inline spreading
updateInjectedStyleProps(stylesMap);
// Apply initial order to DOM only in editor/preview mode
const isEditingMode = (() => {
@@ -172,6 +247,16 @@ export const useAllPageElementConfigs = (pageType: string) => {
...prev,
[elementName]: visible
}));
// If style-pack changed, toggle body class for live preview
if (elementName === 'style-pack') {
try {
const body = document.body;
Array.from(body.classList)
.filter(cls => cls.startsWith('style-pack-'))
.forEach(cls => body.classList.remove(cls));
body.classList.add(`style-pack-${variant || 'default'}`);
} catch {}
}
// Force React to re-render by incrementing refresh key
setRefreshKey(prev => prev + 1);
@@ -199,10 +284,15 @@ export const useAllPageElementConfigs = (pageType: string) => {
if (previewMode) {
// Only update state - let React apply the styles through component rendering
// This prevents conflicts with React's virtual DOM
setStyles(prev => ({
setStyles(prev => {
const next = {
...prev,
[elementName]: newStyles
}));
};
// Also update injected CSS for global preview application
updateInjectedStyleProps(next);
return next;
});
}
}) as EventListener;
@@ -215,6 +305,10 @@ export const useAllPageElementConfigs = (pageType: string) => {
window.removeEventListener('myuibrix-change', handleMyUIbrixChange);
window.removeEventListener('myuibrix-reorder', handleMyUIbrixReorder);
window.removeEventListener('myuibrix-style-change', handleMyUIbrixStyleChange);
try {
const s = document.getElementById('myuibrix-style-props');
if (s) s.remove();
} catch {}
};
}, [pageType]);
+2
View File
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client';
import './index.css';
import './styles/global-enhancements.css';
import './styles/admin-enhancements.css';
import './styles/home-style-pack.css';
import './styles/sparta-styles.css';
// Quill editor styles (MUST be imported globally) - CRITICAL for rich text editor
import 'react-quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
+19 -4
View File
@@ -24,7 +24,7 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek } from 'date-fns';
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek, addDays, parse } from 'date-fns';
import { cs } from 'date-fns/locale';
import { getEvents } from '../services/eventService';
@@ -69,7 +69,7 @@ const ActivitiesCalendarPage: React.FC = () => {
const weeks = useMemo(() => {
const start = startOfWeek(startOfMonth(monthRef), { weekStartsOn: 1 });
const days: Date[] = [];
for (let i = 0; i < 42; i++) days.push(new Date(start.getTime() + i * 86400000));
for (let i = 0; i < 42; i++) days.push(addDays(start, i));
return days;
}, [monthRef]);
@@ -227,7 +227,22 @@ const ActivitiesCalendarPage: React.FC = () => {
const key = format(day, 'yyyy-MM-dd');
const list = byDate.get(key) || [];
const faded = !isSameMonth(day, monthRef);
const today = isSameDay(day, new Date());
const today = (() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) {
const pragueToday = parse(`${y}-${m}-${d}`, 'yyyy-MM-dd', new Date());
return isSameDay(day, pragueToday);
}
} catch {}
return isSameDay(day, new Date());
})();
return (
<Box
key={idx}
@@ -290,7 +305,7 @@ const ActivitiesCalendarPage: React.FC = () => {
<Box px={3} py={2} bg={listHeaderBg} borderLeftWidth="4px" borderLeftColor={'brand.primary'}>
<Flex align="center" gap={2}>
<Text fontWeight="semibold">
{format(new Date(k), 'EEEE d. M. yyyy', { locale: cs })}
{format(parse(k, 'yyyy-MM-dd', new Date()), 'EEEE d. M. yyyy', { locale: cs })}
</Text>
<Badge colorScheme="purple" borderRadius="full">{dayEvents.length}</Badge>
</Flex>
@@ -11,6 +11,7 @@ import { trackEvent as umamiTrackEvent } from '../utils/umami';
import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import FilePreview from '../components/common/FilePreview';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
const ActivityDetailPage: React.FC = () => {
const { id } = useParams();
@@ -116,6 +117,12 @@ const ActivityDetailPage: React.FC = () => {
return (
<MainLayout>
<Box py={10} bg="transparent">
<InstagramGeneratorButton
activity={data}
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
placement="fixed"
size="md"
/>
<Container maxW="3xl">
{loading && (
<HStack><Spinner size="sm" /><Text>Načítání</Text></HStack>
+123 -1
View File
@@ -1,7 +1,7 @@
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView } from '../services/articles';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
import MainLayout from '../components/layout/MainLayout';
import DOMPurify from 'dompurify';
import { Helmet } from 'react-helmet-async';
@@ -18,6 +18,11 @@ import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import FilePreview from '../components/common/FilePreview';
import { usePublicSettings } from '../hooks/usePublicSettings';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
import { MatchSnapshot } from '../services/instagram';
import { Widget } from '../components/widgets/Widget';
import { MatchesWidget } from '../components/widgets/MatchesWidget';
import { getUpcomingEvents } from '../services/eventService';
const toText = (html?: string) => {
if (!html) return '';
@@ -123,6 +128,38 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
// Build a snapshot usable for sharing if available (FACR data or article fallback)
const matchSnapshot: MatchSnapshot | null = React.useMemo(() => {
const m: any = facrMatchQuery?.data as any;
if (m) {
let score = '';
if (m?.score && m.score !== 'vs') score = String(m.score);
else if (m?.result_home != null && m?.result_away != null) score = `${m.result_home}:${m.result_away}`;
return {
external_match_id: String((matchLinkQuery.data as any)?.external_match_id || ''),
competition: String(m.competitionName || ''),
date_time: String(m.date_time || m.date || ''),
venue: m.venue ? String(m.venue) : undefined,
home: String(m.home || m.home_team || ''),
away: String(m.away || m.away_team || ''),
score,
};
}
const snap: any = (data as any)?.match_snapshot;
if (snap) {
return {
external_match_id: snap.external_match_id,
competition: snap.competition || snap.competitionName,
date_time: snap.date_time || snap.date,
venue: snap.venue,
home: snap.home,
away: snap.away,
score: snap.score,
} as MatchSnapshot;
}
return null;
}, [facrMatchQuery?.data, (matchLinkQuery.data as any)?.external_match_id, (data as any)?.match_snapshot]);
// Fetch gallery album if article has one (fallback to URL when ID is missing)
const galleryAlbumQuery = useQuery({
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id || (data as any)?.gallery_album_url],
@@ -239,6 +276,24 @@ const ArticleDetailPage: React.FC = () => {
});
}, [(data as any)?.content, toAbsoluteUploads]);
const relatedArticlesQuery = useQuery({
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
enabled: Boolean((data as any)?.id),
queryFn: () => getArticles({
page: 1,
page_size: 6,
published: true,
...(((data as any)?.category?.id) ? { category_id: (data as any).category.id } : {}),
}),
staleTime: 60_000,
});
const upcomingEventsQuery = useQuery({
queryKey: ['upcoming-events-sidebar'],
queryFn: getUpcomingEvents,
staleTime: 60_000,
});
if (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
@@ -256,6 +311,13 @@ const ArticleDetailPage: React.FC = () => {
return (
<MainLayout>
<Box>
<InstagramGeneratorButton
article={data as any}
match={matchSnapshot}
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
placement="fixed"
size="md"
/>
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
@@ -340,6 +402,8 @@ const ArticleDetailPage: React.FC = () => {
</Container>
</Box>
<Container maxW="7xl">
<SimpleGrid columns={{ base: 1, lg: 12 }} spacing={6}>
<Box gridColumn={{ base: '1 / -1', lg: 'span 8' }}>
<Stack spacing={6}>
{/* Featured Image - smaller with subtle overlay */}
{data.image_url && (
@@ -517,6 +581,64 @@ const ArticleDetailPage: React.FC = () => {
{/* Embedded Poll - directly under content/gallery */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
</Stack>
</Box>
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
<Widget title="Podobné články">
{relatedArticlesQuery.isLoading ? (
<Text color={textMuted}>Načítám</Text>
) : (() => {
const list = ((relatedArticlesQuery.data as any)?.data || [])
.filter((a: any) => a?.id !== (data as any)?.id)
.slice(0, 4);
if (!list.length) return <Text color={textMuted}>Žádné související články</Text>;
return (
<VStack spacing={3} align="stretch">
{list.map((a: any) => {
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
return (
<HStack key={a.id} align="flex-start" spacing={3} as={RouterLink} to={link} _hover={{ textDecoration: 'none' }}>
<Image src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'} alt={a.title} boxSize="64px" objectFit="cover" borderRadius="md" />
<VStack align="start" spacing={1} flex={1} minW={0}>
<Text fontWeight="600" noOfLines={2}>{a.title}</Text>
{a.published_at && (
<Text fontSize="sm" color={textMuted}>{new Date(a.published_at).toLocaleDateString('cs-CZ')}</Text>
)}
</VStack>
</HStack>
);
})}
</VStack>
);
})()}
</Widget>
<MatchesWidget />
<Widget title="Nejbližší aktivity">
{upcomingEventsQuery.isLoading ? (
<Text color={textMuted}>Načítám</Text>
) : (() => {
const items = Array.isArray(upcomingEventsQuery.data) ? (upcomingEventsQuery.data as any[]).slice(0, 3) : [];
if (!items.length) return <Text color={textMuted}>Žádné plánované aktivity</Text>;
return (
<VStack spacing={3} align="stretch">
{items.map((ev: any) => (
<HStack key={ev.id} as={RouterLink} to={`/aktivita/${ev.id}`} _hover={{ textDecoration: 'none' }} align="flex-start" spacing={3}>
<Box flex={1} minW={0}>
<Text fontWeight="600" noOfLines={2}>{ev.title}</Text>
<Text fontSize="sm" color={textMuted}>
{(() => { try { const d = new Date(ev.start_time); return d.toLocaleDateString('cs-CZ') + (ev.location ? `${ev.location}` : ''); } catch { return ev.start_time; } })()}
</Text>
</Box>
</HStack>
))}
</VStack>
);
})()}
</Widget>
</VStack>
</SimpleGrid>
</Container>
</Box>
+10
View File
@@ -9,6 +9,7 @@ import { getCategories, CategoryItem } from '../services/categories';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { Eye, Clock, Search, X } from 'lucide-react';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
@@ -32,6 +33,7 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
borderWidth="0"
_hover={{ boxShadow: 'xl', transform: 'translateY(-3px)' }}
transition="all 0.25s ease"
position="relative"
>
<Box position="relative">
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={imageH} objectFit="cover" />
@@ -106,6 +108,14 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
{article.title}
</Heading>
</Box>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
</LinkBox>
);
};
+87 -10
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import MainLayout from '../components/layout/MainLayout';
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Flex, Badge, Stack, Spinner, Grid, IconButton, ButtonGroup, Button, Link, Image, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, useToast, Input, useColorModeValue } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek } from 'date-fns';
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek, addDays, parse } from 'date-fns';
import { cs } from 'date-fns/locale';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { useSearchParams } from 'react-router-dom';
@@ -466,7 +466,21 @@ const CalendarPage: React.FC = () => {
const target = e.currentTarget as HTMLElement & { dataset?: any };
const href = (target.getAttribute && target.getAttribute('data-href')) || (target as any).dataset?.href;
if (href) {
window.open(href as string, '_blank', 'noopener');
try {
e.preventDefault();
e.stopPropagation();
const link = document.createElement('a');
link.href = href as string;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => { try { window.focus(); } catch {} }, 0);
} catch {
window.open(href as string, '_blank', 'noopener,noreferrer');
}
}
}
};
@@ -483,7 +497,7 @@ const CalendarPage: React.FC = () => {
// Build 6 weeks x 7 days
const days: Date[] = [];
for (let i = 0; i < 42; i++) {
days.push(new Date(start.getTime() + i * 86400000));
days.push(addDays(start, i));
}
return days;
}, [monthRef]);
@@ -664,7 +678,19 @@ const CalendarPage: React.FC = () => {
{latestResults.map((m) => {
const href = mkHref(m);
return (
<Box key={`latest-${c.id}-${m.id}`} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
<Box key={`latest-${c.id}-${m.id}`} position="relative" data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
@@ -758,7 +784,22 @@ const CalendarPage: React.FC = () => {
const key = format(day, 'yyyy-MM-dd');
const list = byDate.get(key) || [];
const faded = !isSameMonth(day, monthRef);
const today = isSameDay(day, new Date());
const today = (() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) {
const pragueToday = parse(`${y}-${m}-${d}`, 'yyyy-MM-dd', new Date());
return isSameDay(day, pragueToday);
}
} catch {}
return isSameDay(day, new Date());
})();
return (
<Box
key={idx}
@@ -783,7 +824,19 @@ const CalendarPage: React.FC = () => {
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const countdown = liveCountdowns[String(m.id)];
return (
<Box key={m.id} _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
{!isPast && countdown ? (
<>
@@ -832,7 +885,19 @@ const CalendarPage: React.FC = () => {
<Stack spacing={4}>
{(() => {
const keys = Array.from(byDate.keys());
const todayStr = format(new Date(), 'yyyy-MM-dd');
const todayStr = (() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) return `${y}-${m}-${d}`;
} catch {}
return format(new Date(), 'yyyy-MM-dd');
})();
const pastKeys = keys.filter(k => k < todayStr).sort().reverse();
const futureKeys = keys.filter(k => k >= todayStr).sort();
const renderGroup = (dKey: string, highlight: boolean) => {
@@ -848,7 +913,7 @@ const CalendarPage: React.FC = () => {
>
<Flex align="center" gap={2}>
<Text fontWeight="semibold" color={highlight ? 'brand.primary' : listGroupHeaderText}>
{format(new Date(dKey), 'EEEE d. M. yyyy', { locale: cs })}
{format(parse(dKey, 'yyyy-MM-dd', new Date()), 'EEEE d. M. yyyy', { locale: cs })}
</Text>
{highlight && (
<Badge colorScheme="blue" variant="subtle" borderRadius="full">Dnes</Badge>
@@ -862,7 +927,19 @@ const CalendarPage: React.FC = () => {
const sentiment = isPast ? getSentiment(m) : null;
const countdown = liveCountdowns[String(m.id)];
return (
<Box key={m.id} _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Flex
align="center"
justify="space-between"
@@ -1064,7 +1141,7 @@ const CalendarPage: React.FC = () => {
<Text fontSize="lg" fontWeight="semibold" color="gray.800" mb={1}>
{(() => {
try {
return format(new Date(selected.match.date), 'EEEE d. MMMM yyyy', { locale: cs });
return format(parse(selected.match.date, 'yyyy-MM-dd', new Date()), 'EEEE d. MMMM yyyy', { locale: cs });
} catch {
return selected.match.date;
}
+17 -3
View File
@@ -1049,7 +1049,7 @@ const HomePage: React.FC = () => {
<div className="name">{p.name}</div>
<div className="role">{p.position || 'Hráč'}</div>
{typeof p.number !== 'undefined' && <div className="number">#{p.number}</div>}
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
{typeof p.age === 'number' && <div className="age">{p.age} {czYears(p.age)}</div>}
</div>
))}
</div>
@@ -1338,7 +1338,7 @@ const HomePage: React.FC = () => {
// }
return (
<MainLayout headerInsideContainer showSponsorsSection={false}>
<MainLayout showSponsorsSection={false}>
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
{/* Above-hero club bar (MyUIbrix managed) */}
@@ -1520,6 +1520,7 @@ const HomePage: React.FC = () => {
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
setIsMatchModalOpen(true);
}}
variant={getVariant('matches-slider', 'carousel') as any}
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
/>
)}
@@ -1554,7 +1555,11 @@ const HomePage: React.FC = () => {
<h3>Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
{newsVariant === 'scroller' ? (
<BlogCardsScroller />
) : (
<NewsList items={news as any} />
)}
</section>
)}
@@ -1642,7 +1647,7 @@ const HomePage: React.FC = () => {
{isVisible('videos', false) && (
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<VideosSection />
<VideosSection variant={(getVariant('videos', 'grid') as any) as 'grid' | 'carousel'} />
</div>
</section>
)}
@@ -1831,4 +1836,13 @@ const HomePage: React.FC = () => {
);
};
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
return 'let';
}
export default HomePage;
+26 -5
View File
@@ -23,6 +23,8 @@ const PlayerDetailPage: React.FC = () => {
);
}
if (isError || !data) {
return (
<MainLayout>
@@ -73,7 +75,7 @@ const PlayerDetailPage: React.FC = () => {
<Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text>
)}
{data.date_of_birth && (
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')} {calculateAge(data.date_of_birth)} let</Text>
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')} {(() => { const a = calculateAge(data.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}</Text>
)}
{(data.height || data.weight) && (
<Text>
@@ -81,10 +83,10 @@ const PlayerDetailPage: React.FC = () => {
</Text>
)}
{data.email && (
<Text><b>Email:</b> {data.email}</Text>
<Text><b>Email:</b> <a href={`mailto:${data.email}`}>{data.email}</a></Text>
)}
{data.phone && (
<Text><b>Telefon:</b> {data.phone}</Text>
<Text><b>Telefon:</b> <a href={`tel:${normalizeTel(data.phone)}`}>{data.phone}</a></Text>
)}
{typeof data.team_id === 'number' && data.team_id > 0 && (
<Text><b>Tým ID:</b> {data.team_id}</Text>
@@ -95,10 +97,8 @@ const PlayerDetailPage: React.FC = () => {
</VStack>
</Container>
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</Box>
</MainLayout>
@@ -119,4 +119,25 @@ function calculateAge(iso: string): number | null {
}
}
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
return 'let';
}
function normalizeTel(input: string): string {
if (!input) return '';
let s = String(input).trim();
s = s.replace(/[\s\-()]/g, '');
if (s.startsWith('00')) s = '+' + s.slice(2);
s = s.replace(/(?!^)[^\d]/g, '');
if (s[0] !== '+' && s.startsWith('+')) {
// keep + if present
}
return s;
}
export default PlayerDetailPage;
+74 -2
View File
@@ -53,6 +53,7 @@ const SearchPage: React.FC = () => {
teams: [],
contacts: [],
gallery: [],
categories: [],
total: 0,
});
const [activeTab, setActiveTab] = useState<string>('all');
@@ -87,6 +88,7 @@ const SearchPage: React.FC = () => {
teams: [],
contacts: [],
gallery: [],
categories: [],
total: 0,
});
return;
@@ -186,6 +188,33 @@ const SearchPage: React.FC = () => {
<Button type="submit" mt={3} colorScheme="blue" size="lg">Vyhledat</Button>
</Box>
{/* Quick type filter chips */}
{!loading && hasAny && (
<HStack spacing={2} wrap="wrap">
<Button size="sm" variant={activeTab === 'all' ? 'solid' : 'outline'} colorScheme="blue" leftIcon={<FaSearch />} onClick={() => setActiveTab('all')}>
Vše ({results.total})
</Button>
<Button size="sm" variant={activeTab === 'clubs' ? 'solid' : 'outline'} onClick={() => setActiveTab('clubs')}>
Kluby ({validClubs.length})
</Button>
<Button size="sm" variant={activeTab === 'matches' ? 'solid' : 'outline'} onClick={() => setActiveTab('matches')}>
Zápasy ({results.matches.length + results.matchesPast.length})
</Button>
<Button size="sm" variant={activeTab === 'articles' ? 'solid' : 'outline'} onClick={() => setActiveTab('articles')}>
Články ({results.articles.length})
</Button>
<Button size="sm" variant={activeTab === 'players' ? 'solid' : 'outline'} leftIcon={<FaUsers />} onClick={() => setActiveTab('players')}>
Hráči ({results.players.length})
</Button>
<Button size="sm" variant={activeTab === 'events' ? 'solid' : 'outline'} leftIcon={<FaCalendar />} onClick={() => setActiveTab('events')}>
Akce ({results.events.length})
</Button>
<Button size="sm" variant={activeTab === 'other' ? 'solid' : 'outline'} onClick={() => setActiveTab('other')}>
Ostatní ({results.teams.length + results.sponsors.length + results.contacts.length + results.gallery.length + results.categories.length})
</Button>
</HStack>
)}
{loading && (
<Flex justify="center" my={12}>
@@ -227,7 +256,7 @@ const SearchPage: React.FC = () => {
<Tab>Články ({results.articles.length})</Tab>
<Tab><Icon as={FaUsers} mr={2} />Hráči ({results.players.length})</Tab>
<Tab><Icon as={FaCalendar} mr={2} />Akce ({results.events.length})</Tab>
<Tab>Ostatní ({results.teams.length + results.sponsors.length + results.contacts.length + results.gallery.length})</Tab>
<Tab>Ostatní ({results.teams.length + results.sponsors.length + results.contacts.length + results.gallery.length + results.categories.length})</Tab>
</TabList>
<TabPanels>
@@ -260,6 +289,29 @@ const SearchPage: React.FC = () => {
</Box>
)}
{/* Categories */}
{results.categories.length > 0 && (
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="md">Kategorie</Heading>
<Badge colorScheme="pink" fontSize="md">{results.categories.length}</Badge>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{results.categories.slice(0, 8).map((c) => (
<Button
key={c.id}
as={RouterLink}
to={c.url || '#'}
variant="outline"
colorScheme="pink"
justifyContent="flex-start"
>
{highlight(c.title, q)}
</Button>
))}
</SimpleGrid>
</Box>
)}
{/* Players */}
{results.players.length > 0 && (
<Box>
@@ -572,6 +624,26 @@ const SearchPage: React.FC = () => {
{/* Other Tab - Teams, Sponsors, Contacts, Gallery */}
<TabPanel px={0}>
<VStack align="stretch" spacing={8}>
{results.categories.length > 0 && (
<Box>
<Heading size="sm" mb={3}>Kategorie</Heading>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={3}>
{results.categories.map((c) => (
<Button
key={c.id}
as={RouterLink}
to={c.url || '#'}
variant="outline"
size="sm"
colorScheme="pink"
justifyContent="flex-start"
>
{highlight(c.title, q)}
</Button>
))}
</SimpleGrid>
</Box>
)}
{results.teams.length > 0 && (
<Box>
<Heading size="sm" mb={3}>Týmy</Heading>
@@ -681,7 +753,7 @@ const SearchPage: React.FC = () => {
</Box>
)}
{results.teams.length === 0 && results.sponsors.length === 0 && results.contacts.length === 0 && results.gallery.length === 0 && (
{results.categories.length === 0 && results.teams.length === 0 && results.sponsors.length === 0 && results.contacts.length === 0 && results.gallery.length === 0 && (
<Text color="gray.500">Žádné další výsledky</Text>
)}
</VStack>
+96 -1
View File
@@ -118,6 +118,12 @@ const SetupPage: React.FC = () => {
const [youtubeUrl, setYoutubeUrl] = useState('');
const [galleryUrl, setGalleryUrl] = useState('');
const [frontendBaseUrl, setFrontendBaseUrl] = useState('');
const [apiBaseUrl, setApiBaseUrl] = useState('');
const [isDomainHost, setIsDomainHost] = useState(false);
const [showAdvancedApi, setShowAdvancedApi] = useState(false);
const [apiUrlTouched, setApiUrlTouched] = useState(false);
const toast = useToast();
const navigate = useNavigate();
const bg = useColorModeValue('white', 'gray.800');
@@ -166,6 +172,26 @@ const SetupPage: React.FC = () => {
return () => { mounted = false; };
}, []);
useEffect(() => {
try {
const loc = window.location;
const host = loc.hostname;
const origin = loc.origin;
const isLocal = /^(localhost|127\.0\.0\.1)$/i.test(host);
const isIPv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
if (isLocal || isIPv4) {
setIsDomainHost(false);
setFrontendBaseUrl(origin);
const apiOrigin = `${loc.protocol}//${host}:8080`;
setApiBaseUrl(apiOrigin.replace(/\/$/, '') + '/api/v1');
} else {
setIsDomainHost(true);
setFrontendBaseUrl(origin);
setApiBaseUrl(origin.replace(/\/$/, '') + '/api/v1');
}
} catch {}
}, []);
// Auto-generate JWT secret when setup is required
useEffect(() => {
if (requiresSetup && !jwtSecret) {
@@ -183,6 +209,14 @@ const SetupPage: React.FC = () => {
return () => clearTimeout(t);
}, [clubQuery, searchClubs]);
useEffect(() => {
if (isDomainHost && !showAdvancedApi) {
if (frontendBaseUrl) {
setApiBaseUrl(frontendBaseUrl.replace(/\/$/, '') + '/api/v1');
}
}
}, [isDomainHost, showAdvancedApi, frontendBaseUrl]);
// Load and apply selected font for preview
useEffect(() => {
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
@@ -279,6 +313,8 @@ const SetupPage: React.FC = () => {
club_name: clubName || undefined,
club_logo_url: clubLogoUrl || undefined,
club_url: clubUrl || undefined,
frontend_base_url: frontendBaseUrl || undefined,
api_base_url: apiBaseUrl || undefined,
frontpage_style: frontpageStyle || undefined,
primary_color: primaryColor || undefined,
secondary_color: secondaryColor || undefined,
@@ -313,19 +349,49 @@ const SetupPage: React.FC = () => {
use_tls: smtpTLS,
} : null,
};
try {
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
let ab = (apiBaseUrl || '').trim();
if (fb || ab) {
try {
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
ab = u.toString();
} catch {}
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
try { localStorage.setItem('api_base_url', ab); } catch {}
try { (await import('../services/api')).default.defaults.baseURL = ab; } catch {}
}
} catch {}
await initializeSetup(payload);
// Set sensible default SEO based on setup data
try {
const origin = (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/$/, '');
const canonical = (frontendBaseUrl || origin || '').replace(/\/$/, '');
await updateSeoSettings({
site_title: clubName || 'Fotbal Club',
site_description: clubName ? `${clubName} oficiální klubový web: aktuality, zápasy, tabulky, hráči.` : 'Oficiální klubový web: aktuality, zápasy, tabulky, hráči.',
default_og_image_url: clubLogoUrl || undefined,
canonical_base_url: origin || undefined,
canonical_base_url: canonical || undefined,
enable_indexing: true,
});
} catch {}
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
try {
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
let ab = (apiBaseUrl || '').trim();
if (fb || ab) {
try {
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
ab = u.toString();
} catch {}
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
try { localStorage.setItem('api_base_url', ab); } catch {}
}
} catch {}
navigate('/login', { replace: true });
// Force full reload to ensure app picks up fresh server state and env
setTimeout(() => {
@@ -599,6 +665,35 @@ const SetupPage: React.FC = () => {
{/* removed overall style preview per request */}
<>
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🌐 Adresa webu</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Zadejte adresu, kde bude web dostupný.</Text>
{!isDomainHost && (
<Alert status="warning" borderRadius="md" mb={4}>
<AlertIcon />
<Box>
<Text fontSize="sm" fontWeight="medium">Doporučujeme nastavit finální doménu</Text>
<Text fontSize="sm">Aktuálně používáte localhost/IP. Nastavení bez vlastní domény nemusí fungovat správně (CORS, cookies, přihlášení, galerie). Doménu můžete doplnit nyní nebo kdykoli později v Nastavení nebo zde.</Text>
</Box>
</Alert>
)}
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>URL webu</FormLabel>
<Input placeholder="https://www.vasklub.cz" value={frontendBaseUrl} onChange={(e) => { const val = e.target.value; setFrontendBaseUrl(val); let h = ''; try { const u = /^https?:\/\//i.test(val) ? new URL(val) : new URL('https://' + val); h = u.hostname; } catch {} const isLocal = /^(localhost|127\.0\.0\.1)$/i.test(h); const isIPv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h); const isDomain = !!h && !isLocal && !isIPv4; if (isDomain) { setShowAdvancedApi(true); if (!apiUrlTouched) { let base = (val || '').trim(); if (base && !/^https?:\/\//i.test(base)) base = 'https://' + base; try { const u2 = new URL(base); if (!/\/api\//.test(u2.pathname)) { u2.pathname = u2.pathname.replace(/\/$/, '') + '/api/v1'; } setApiBaseUrl(u2.toString()); } catch { setApiBaseUrl(base.replace(/\/$/, '') + '/api/v1'); } } } }} />
</FormControl>
<Checkbox isChecked={showAdvancedApi} onChange={(e) => setShowAdvancedApi(e.target.checked)}>Zadat vlastní API URL</Checkbox>
{showAdvancedApi && (
<FormControl>
<FormLabel>API URL</FormLabel>
<Input placeholder="https://api.vasklub.cz/api/v1" value={apiBaseUrl} onChange={(e) => { setApiUrlTouched(true); setApiBaseUrl(e.target.value); }} />
<FormHelperText>Výchozí: {frontendBaseUrl ? `${frontendBaseUrl.replace(/\/$/, '')}/api/v1` : ''}</FormHelperText>
</FormControl>
)}
</VStack>
</>
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🎨 Barvy a vzhled webu</Heading>
@@ -85,7 +85,7 @@ const AdminActivitiesPage: React.FC = () => {
const [draftKey, setDraftKey] = useState<string>('');
const [aiPrompt, setAiPrompt] = useState<string>('');
const [aiLoading, setAiLoading] = useState<boolean>(false);
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('informative');
const [aiOverwrite, setAiOverwrite] = useState<boolean>(true);
// Location coordinates for map preview
const [locationLat, setLocationLat] = useState<number | undefined>(undefined);
@@ -93,6 +93,8 @@ const AdminActivitiesPage: React.FC = () => {
// YouTube videos from club channel
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
const [savedLocations, setSavedLocations] = useState<Array<{ id: string; label: string; address: string; lat?: number; lng?: number }>>([]);
const [selectedSavedId, setSelectedSavedId] = useState<string>('');
// Auto-save hook - saves draft automatically
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
@@ -153,6 +155,66 @@ const AdminActivitiesPage: React.FC = () => {
staleTime: 5 * 60_000,
});
useEffect(() => {
try {
const raw = localStorage.getItem('admin_saved_locations');
const base: Array<{ id: string; label: string; address: string; lat?: number; lng?: number }> = raw ? JSON.parse(raw) : [];
const s: any = settingsQ.data || {};
const clubAddrParts = [s.contact_address, s.contact_city, s.contact_zip].filter((x: any) => String(x || '').trim());
const clubAddr = clubAddrParts.join(', ');
const hasCoords = typeof s.location_latitude === 'number' && typeof s.location_longitude === 'number' && !isNaN(s.location_latitude) && !isNaN(s.location_longitude);
const label = s.club_name ? `Klub ${s.club_name}` : 'Klub Hlavní místo';
if (clubAddr || hasCoords) {
const exists = base.some((it) => (clubAddr && it.address === clubAddr) || (hasCoords && it.lat === s.location_latitude && it.lng === s.location_longitude));
if (!exists) {
base.unshift({ id: 'club-main', label, address: clubAddr || (s.contact_city || 'Klub'), lat: hasCoords ? s.location_latitude : undefined, lng: hasCoords ? s.location_longitude : undefined });
}
}
setSavedLocations(base);
} catch {
setSavedLocations([]);
}
}, [settingsQ.data]);
const persistSavedLocations = (list: Array<{ id: string; label: string; address: string; lat?: number; lng?: number }>) => {
try { localStorage.setItem('admin_saved_locations', JSON.stringify(list)); } catch {}
setSavedLocations(list);
};
const addCurrentLocationToSaved = () => {
const address = String(editing?.location || '').trim();
const hasCoords = typeof locationLat === 'number' || typeof locationLng === 'number';
if (!address && !hasCoords) {
toast({ title: 'Nelze uložit místo', description: 'Zadejte název/adresu nebo vyberte souřadnice.', status: 'warning' });
return;
}
const id = String(Date.now());
const label = address || 'Uložené místo';
const next = [...savedLocations, { id, label, address, lat: locationLat, lng: locationLng }];
persistSavedLocations(next);
setSelectedSavedId(id);
toast({ title: 'Místo uloženo', status: 'success', duration: 2000 });
};
const applySavedLocation = (id: string) => {
setSelectedSavedId(id);
const item = savedLocations.find((x) => x.id === id);
if (!item) return;
setLocationLat(item.lat);
setLocationLng(item.lng);
setEditing(prev => ({ ...(prev || {}), location: item.address, latitude: item.lat as any, longitude: item.lng as any } as any));
toast({ title: 'Místo vybráno', description: item.label, status: 'success', duration: 1500 });
};
const deleteSelectedSaved = () => {
if (!selectedSavedId) return;
if (selectedSavedId === 'club-main') { toast({ title: 'Nelze smazat klubové místo', status: 'info' }); return; }
const next = savedLocations.filter((x) => x.id !== selectedSavedId);
persistSavedLocations(next);
setSelectedSavedId('');
toast({ title: 'Uložené místo smazáno', status: 'success', duration: 1500 });
};
const openCreate = () => {
// Check for existing draft
const key = 'draft-activity-new';
@@ -279,14 +341,18 @@ const AdminActivitiesPage: React.FC = () => {
if (e.type) lines.push(`Typ: ${e.type}`);
if (e.description) lines.push(`Poznámky: ${e.description}`);
const base = lines.join('\n');
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
const toneText = aiTone === 'informative'
? 'neutrálním, věcným a stručným stylem (bez nadsázky)'
: aiTone === 'formal'
? 'formálním a profesionálním stylem (bez příkras)'
: 'přátelským, ale věcným a stručným stylem (bez nadsázky)';
const safeUserPrompt = (aiPrompt || 'Napiš krátkou neutrální pozvánku na klubovou aktivitu.').trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 12 krátké odstavce nebo stručné odrážky. Dbej na věcný a střízlivý tón.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
const { data } = await api.post('/ai/blog/generate', {
prompt,
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
min_words: 120,
min_words: 60,
});
// Handle potential JSON string response from AI (defensive parsing)
@@ -831,6 +897,30 @@ const AdminActivitiesPage: React.FC = () => {
<Box mt={4}>
<Heading size="sm" mb={3}>Místo konání</Heading>
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
<FormControl>
<FormLabel fontSize="sm">Uložená místa (rychlý výběr)</FormLabel>
<HStack spacing={2} align="center">
<Select
size="sm"
placeholder="Vyberte uložené místo..."
value={selectedSavedId}
onChange={(e) => applySavedLocation(e.target.value)}
flex={1}
>
{savedLocations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.label}{loc.address ? `${loc.address}` : ''}
</option>
))}
</Select>
<Button size="sm" variant="outline" onClick={addCurrentLocationToSaved}>Uložit aktuální</Button>
<Button size="sm" variant="ghost" colorScheme="red" onClick={deleteSelectedSaved} isDisabled={!selectedSavedId || selectedSavedId === 'club-main'}>Smazat</Button>
</HStack>
<Text fontSize="xs" color={textSecondary} mt={1}>Vyberte klubové nebo dříve uložené místo. Uložit aktuální přidá současný název/adresu a souřadnice.</Text>
</FormControl>
</Box>
{/* MapLinkImporter */}
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
<Text fontSize="sm" fontWeight="semibold" mb={2}>Importovat z odkazu na mapu</Text>
+91 -266
View File
@@ -39,12 +39,12 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { putMatchOverride, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
import { parse, format } from 'date-fns';
import { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import { API_URL } from '../../services/api';
@@ -53,15 +53,10 @@ const MatchesAdminPage = () => {
const queryClient = useQueryClient();
const toast = useToast();
const [isOpen, setIsOpen] = useState(false);
const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null);
const [selected, setSelected] = useState<any | null>(null);
const [form, setForm] = useState({
home_name_override: '',
away_name_override: '',
venue_override: '',
date_time_override: '',
home_logo_url: '',
away_logo_url: '',
date_time_edit: '',
notes: '',
});
@@ -70,6 +65,7 @@ const MatchesAdminPage = () => {
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const normalizeName = (s: string) => {
let out = String(s || '');
@@ -102,56 +98,58 @@ const MatchesAdminPage = () => {
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
// Build name index from overrides by_id for cases where team_id is missing in cached data
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(overridesById)) {
const name = String((v as any)?.name || '').trim();
const logo = String((v as any)?.logo_url || '').trim();
if (!name) continue;
const norm = normalizeName(name);
if (!norm) continue;
idx[norm] = { id, name, logo_url: logo };
}
} catch {}
return idx;
}, [overridesById]);
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// 0) Admin override by team ID takes precedence
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
// 0.5) If no ID, but override exists for normalized name, use it
try {
const hit = overridesNameIndex[normalizeName(teamName)];
if (hit && hit.logo_url) {
const u = String(hit.logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
} catch {}
// 1) LogoAPI map by team ID
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
// 2) Local/legacy overrides by name
let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl;
}
// 3) FACR original if provided
if (facrOriginal) return facrOriginal;
// Fallback placeholder
return '/dist/img/logo-club-empty.svg';
};
// External logo upload helpers/state
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
const [homeUploadedFile, setHomeUploadedFile] = useState<File | null>(null);
const [awayUploadedFile, setAwayUploadedFile] = useState<File | null>(null);
// Team search state
const [homeQuery, setHomeQuery] = useState('');
const [awayQuery, setAwayQuery] = useState('');
const [debouncedHome, setDebouncedHome] = useState('');
const [debouncedAway, setDebouncedAway] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebouncedHome(homeQuery), 300);
return () => clearTimeout(t);
}, [homeQuery]);
useEffect(() => {
const t = setTimeout(() => setDebouncedAway(awayQuery), 300);
return () => clearTimeout(t);
}, [awayQuery]);
const { data: homeResults = [] } = useQuery({
queryKey: ['club-search-home', debouncedHome],
queryFn: () => searchClubs(debouncedHome),
enabled: debouncedHome.trim().length >= 2,
});
const { data: awayResults = [] } = useQuery({
queryKey: ['club-search-away', debouncedAway],
queryFn: () => searchClubs(debouncedAway),
enabled: debouncedAway.trim().length >= 2,
});
// Upload refs
const homeFileRef = useRef<HTMLInputElement | null>(null);
const awayFileRef = useRef<HTMLInputElement | null>(null);
// Team name/logo editing removed
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
queryKey: ['admin-matches-list-cache'],
@@ -170,6 +168,17 @@ const MatchesAdminPage = () => {
// Optional: stable sort by date ascending
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatDisplayDate = (s: string): string => {
const str = String(s || '').trim();
if (!str) return '';
try {
const dt = parse(str, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
} catch {}
const d2 = new Date(str);
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
return str;
};
items.sort((a, b) => {
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
@@ -218,6 +227,17 @@ const MatchesAdminPage = () => {
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
const normalizedTeam = teamFilter.trim().toLowerCase();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatDisplayDate = (s: string): string => {
const str = String(s || '').trim();
if (!str) return '';
try {
const dt = parse(str, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
} catch {}
const d2 = new Date(str);
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
return str;
};
// Club name (for side filter)
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
@@ -410,78 +430,28 @@ const MatchesAdminPage = () => {
URL.revokeObjectURL(url);
};
// Datetime validation (RFC3339-ish)
const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override));
// Datetime validation for datetime-local
const isDateInvalid = form.date_time_edit.trim() !== '' && isNaN(new Date(form.date_time_edit).getTime());
const saveMutation = useMutation({
mutationFn: async () => {
const externalMatchId: string = selected?.match_id || selected?.id;
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = { ...form };
// normalize empty strings to null so backend can clear values
const payload: any = {
venue_override: form.venue_override,
date_time_override: form.date_time_edit,
notes: form.notes,
};
Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
});
// First store current overrides
await putMatchOverride(externalMatchId, payload);
// Best-effort upload to logoapi.sportcreative.eu for home/away
const results: { home?: { success: boolean; error?: string }; away?: { success: boolean; error?: string } } = {};
const processSide = async (
side: 'home' | 'away',
externalTeamId: string,
uploadedFile: File | null,
nameOverride: string,
logoUrl: string | null
) => {
try {
if (!externalTeamId) return { success: false, error: 'Chybí ID týmu' };
let file: File | Blob | null = uploadedFile;
if (!file && logoUrl) {
file = await fetchLogoAsBlob(logoUrl);
}
if (!file) return { success: false, error: 'Nelze získat soubor loga' };
const up = await uploadToLogaSportcreative(externalTeamId, file, {
filename: file instanceof File ? file.name : `${externalTeamId}.png`,
clubName: nameOverride || 'Neznámý klub',
clubType: 'football',
});
if (!up.success) return { success: false, error: up.error || 'Upload selhal' };
if (up.url) {
// Patch override to immediately use external URL
await patchMatchOverride(
externalMatchId,
side === 'home' ? { home_logo_url: up.url } : { away_logo_url: up.url }
);
}
return { success: true };
} catch (e: any) {
return { success: false, error: e?.message || 'Chyba při uploadu' };
}
};
if (homeExternalTeamId && (form.home_logo_url || homeUploadedFile)) {
results.home = await processSide('home', homeExternalTeamId, homeUploadedFile, form.home_name_override, form.home_logo_url);
}
if (awayExternalTeamId && (form.away_logo_url || awayUploadedFile)) {
results.away = await processSide('away', awayExternalTeamId, awayUploadedFile, form.away_name_override, form.away_logo_url);
}
return { ok: true, results };
return { ok: true };
},
onSuccess: (res: any) => {
const r = res?.results || {};
const parts: string[] = [];
if (r.home) parts.push(r.home.success ? 'Logo domácích nahráno' : `Domácí: ${r.home.error || 'chyba'}`);
if (r.away) parts.push(r.away.success ? 'Logo hostů nahráno' : `Hosté: ${r.away.error || 'chyba'}`);
const description = parts.length ? parts.join(' • ') : undefined;
toast({ title: 'Uloženo', description, status: 'success' });
onSuccess: () => {
toast({ title: 'Uloženo', status: 'success' });
setIsOpen(false);
setSelected(null);
setHomeUploadedFile(null);
setAwayUploadedFile(null);
// Invalidate the cache-backed list to refresh any merged overrides
queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] });
},
onError: (e: any) => {
@@ -489,57 +459,34 @@ const MatchesAdminPage = () => {
},
});
const openEdit = (m: any, side?: 'home' | 'away') => {
const openEdit = (m: any) => {
setSelected(m);
// Convert FACR-style date (e.g., 25.08.2025 18:30) to RFC3339 for backend
const facrStr: string = m.date_time || m.date || '';
let iso = '';
let localStr = '';
if (facrStr) {
try {
const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date());
if (!isNaN(dt.getTime())) iso = dt.toISOString();
if (!isNaN(dt.getTime())) {
const pad = (n: number) => String(n).padStart(2, '0');
localStr = `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
} catch (_) {
// If it's already ISO or another parseable format, keep as-is if valid
const d2 = new Date(facrStr);
if (!isNaN(d2.getTime())) iso = d2.toISOString();
if (!isNaN(d2.getTime())) {
const pad = (n: number) => String(n).padStart(2, '0');
localStr = `${d2.getFullYear()}-${pad(d2.getMonth() + 1)}-${pad(d2.getDate())}T${pad(d2.getHours())}:${pad(d2.getMinutes())}`;
}
}
}
setForm({
home_name_override: m.home || m.home_team || '',
away_name_override: m.away || m.away_team || '',
venue_override: m.venue || '',
date_time_override: iso,
home_logo_url: m.home_logo_url || '',
away_logo_url: m.away_logo_url || '',
date_time_edit: localStr,
notes: '',
});
setIsOpen(true);
setFocusSide(side ?? null);
// Reset external selections and uploaded files to avoid stale state
setHomeExternalTeamId('');
setAwayExternalTeamId('');
setHomeUploadedFile(null);
setAwayUploadedFile(null);
};
// Autofocus on the selected team input when drawer opens
const homeInputRef = useRef<HTMLInputElement | null>(null);
const awayInputRef = useRef<HTMLInputElement | null>(null);
const handleHomeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setHomeQuery(e.target.value);
};
const handleAwayInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setAwayQuery(e.target.value);
};
useEffect(() => {
if (isOpen && focusSide) {
const t = setTimeout(() => {
if (focusSide === 'home') homeInputRef.current?.focus();
if (focusSide === 'away') awayInputRef.current?.focus();
}, 50);
return () => clearTimeout(t);
}
}, [isOpen, focusSide]);
// Removed autofocus logic for team inputs
const drawerSize = useBreakpointValue({ base: 'full', md: 'md' });
// Horizontal scroll affordance
@@ -943,7 +890,7 @@ const MatchesAdminPage = () => {
>
<Td>
<HStack spacing={2}>
<Text>{m.date_time || m.date || ''}</Text>
<Text>{formatDisplayDate(String(m.date_time || m.date || ''))}</Text>
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
</HStack>
@@ -965,7 +912,7 @@ const MatchesAdminPage = () => {
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
<Button size="xs" variant="outline" onClick={() => openEdit(m)} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
</HStack>
</Td>
<Td textAlign="center">
@@ -985,7 +932,7 @@ const MatchesAdminPage = () => {
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
<Button size="xs" variant="outline" onClick={() => openEdit(m)} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
</HStack>
</Td>
<Td>{m.venue || ''}</Td>
@@ -1023,11 +970,11 @@ const MatchesAdminPage = () => {
) : (
<Stack spacing={4}>
<FormControl>
<FormLabel>Datum a čas (ISO)</FormLabel>
<FormLabel>Datum a čas</FormLabel>
<Input
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ"
value={form.date_time_override}
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))}
type="datetime-local"
value={form.date_time_edit}
onChange={(e) => setForm((f) => ({ ...f, date_time_edit: e.target.value }))}
/>
{isDateInvalid && (
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
@@ -1043,129 +990,7 @@ const MatchesAdminPage = () => {
/>
</FormControl>
{/* Home team */}
<FormControl>
<FormLabel>Domácí tým (název)</FormLabel>
<InputGroup>
<Input
ref={homeInputRef}
placeholder="Zadejte název týmu"
value={form.home_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, home_name_override: e.target.value }));
handleHomeInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => homeFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={homeFileRef}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const up = await uploadImage(file);
setForm((f) => ({ ...f, home_logo_url: up.url }));
setHomeUploadedFile(file);
toast({ title: 'Logo nahráno (domácí)', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
} finally {
if (homeFileRef.current) homeFileRef.current.value = '' as any;
}
}}
/>
{homeResults.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{homeResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, home_name_override: r.name, home_logo_url: r.logo_url || f.home_logo_url }));
setHomeQuery(r.name);
setHomeExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.home_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.home_logo_url} alt="home logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, home_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
{/* Away team */}
<FormControl>
<FormLabel>Hostující tým (název)</FormLabel>
<InputGroup>
<Input
ref={awayInputRef}
placeholder="Zadejte název týmu"
value={form.away_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, away_name_override: e.target.value }));
handleAwayInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => awayFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={awayFileRef}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const up = await uploadImage(file);
setForm((f) => ({ ...f, away_logo_url: up.url }));
setAwayUploadedFile(file);
toast({ title: 'Logo nahráno (hosté)', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
} finally {
if (awayFileRef.current) awayFileRef.current.value = '' as any;
}
}}
/>
{awayResults.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{awayResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, away_name_override: r.name, away_logo_url: r.logo_url || f.away_logo_url }));
setAwayQuery(r.name);
setAwayExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.away_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.away_logo_url} alt="away logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, away_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
{/* Team name/logo editing removed */}
<FormControl>
<FormLabel>Poznámka</FormLabel>
+14 -1
View File
@@ -438,7 +438,7 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</HStack>
<Box mt={2} fontSize="sm" color="gray.500">
{formatDobPreview(dobParts)}{calculateAgeFromParts(dobParts) != null ? `${calculateAgeFromParts(dobParts)} let` : ''}
{formatDobPreview(dobParts)}{(() => { const a = calculateAgeFromParts(dobParts); return a != null ? `${a} ${czYears(a)}` : ''; })()}
</Box>
</FormControl>
@@ -529,6 +529,9 @@ const PlayersAdminPage: React.FC = () => {
<FormLabel>Telefon (nepovinné)</FormLabel>
<Input type="tel" value={(editing as any)?.phone || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), phone: e.target.value }))} />
</FormControl>
<Box gridColumn="1 / -1" fontSize="sm" color="gray.600">
Upozornění: telefonní číslo a email budou viditelné na hlavní stránce. Údaje nejsou povinné pokud je nechcete zadávat, ponechte je prázdné.
</Box>
</SimpleGrid>
<FormControl>
@@ -615,6 +618,16 @@ const PlayersAdminPage: React.FC = () => {
return age;
}
// Czech pluralization for years: 1 rok, 24 roky, 5+ let (1114 let)
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
return 'let';
}
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
function updateDobPart(part: 'day'|'month'|'year', value: string) {
setDobParts((prev) => {
@@ -197,6 +197,8 @@ const SettingsAdminPage: React.FC = () => {
(typeof (settings as any).location_latitude === 'number') &&
(typeof (settings as any).location_longitude === 'number'),
map_style: (settings as any).map_style,
frontend_base_url: (settings as any).frontend_base_url,
api_base_url: (settings as any).api_base_url,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
};
@@ -205,6 +207,22 @@ const SettingsAdminPage: React.FC = () => {
toast({ title: 'Uloženo', description: 'Nastavení bylo úspěšně aktualizováno', status: 'success' });
// Try to refresh prefetch caches
try { await triggerPrefetch(); } catch {}
try {
const fb = String(((saved as any).frontend_base_url || (settings as any).frontend_base_url || '')).replace(/\/$/, '');
let ab = String(((saved as any).api_base_url || (settings as any).api_base_url || '')).trim();
if (fb || ab) {
try {
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
ab = u.toString();
} catch {}
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
try { localStorage.setItem('api_base_url', ab); } catch {}
try { (api as any).defaults.baseURL = ab; } catch {}
setTimeout(() => { try { window.location.reload(); } catch {} }, 600);
}
} catch {}
} catch (e: any) {
toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' });
} finally {
@@ -271,6 +289,17 @@ const SettingsAdminPage: React.FC = () => {
<Divider />
<Heading size="sm">Nastavení URL</Heading>
<FormControl>
<FormLabel>URL webu</FormLabel>
<Input value={(settings as any).frontend_base_url || ''} onChange={handleChange('frontend_base_url' as any)} placeholder="https://www.vasklub.cz" />
</FormControl>
<FormControl>
<FormLabel>API URL</FormLabel>
<Input value={(settings as any).api_base_url || ''} onChange={handleChange('api_base_url' as any)} placeholder="https://api.vasklub.cz/api/v1" />
<FormHelperText>Ujistěte se, že adresa končí na /api/v1</FormHelperText>
</FormControl>
<Heading size="sm">Zobrazení zápasů</Heading>
<FormControl>
<FormLabel>Počet dní zobrazení dokončených zápasů</FormLabel>
@@ -71,6 +71,7 @@ function normalize(s: string): string {
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'spolek',
'fotbal',
'futsal',
];
@@ -139,6 +140,21 @@ const TeamsAdminPage = () => {
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
// Build an index by normalized team name for overrides that carry an ID
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(overridesById)) {
const name = String((v as any)?.name || '').trim();
const logo = String((v as any)?.logo_url || '').trim();
if (!name) continue;
const norm = normalize(name);
if (!norm) continue;
idx[norm] = { id, name, logo_url: logo };
}
} catch {}
return idx;
}, [overridesById]);
// Fetch logos from logoapi.sportcreative.eu for all teams
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
@@ -186,6 +202,15 @@ const TeamsAdminPage = () => {
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
// Priority 0.5: Try match by override name when team_id is missing
try {
const hit = overridesNameIndex[normalize(teamName)];
if (hit && hit.logo_url) {
const u = String(hit.logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
} catch {}
// Priority 1: Local admin override (exact + normalized)
let overrideUrl = byName[teamName];
if (!overrideUrl) {
@@ -217,6 +242,15 @@ const TeamsAdminPage = () => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
}
// If no ID, but override exists for the normalized name, use canonical override name
try {
if (teamName) {
const hit = overridesNameIndex[normalize(teamName)];
if (hit && hit.name) {
return hit.name;
}
}
} catch {}
return String(teamName || '');
};
+13
View File
@@ -828,6 +828,19 @@ html {
flex-direction: column;
gap: 16px;
}
/* Ticker variant - continuous left-moving belt */
.matches-slider.matches-ticker .ticker-belt {
display: flex;
gap: 28px;
padding: 8px 2px 12px 2px;
width: max-content;
animation: matches-ticker-left 35s linear infinite;
}
.matches-slider.matches-ticker:hover .ticker-belt { animation-play-state: paused; }
@keyframes matches-ticker-left {
0% { transform: translateX(0); }
100% { transform: translateX(-33.333%); }
}
/* Variant: compact_split - two columns (slider left, tabs right) */
.matches-slider[data-variant="compact_split"] .matches-grid {
display: grid;
+8 -9
View File
@@ -1,24 +1,23 @@
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { getToken } from '../utils/auth';
// Resolve API URL. Some code uses REACT_APP_API_URL (full api path including /api/v1),
// others set REACT_APP_API_BASE_URL (backend origin). Normalize so baseURL always points to API root.
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
let API_URL = envApiUrl || '/api/v1';
function readStored(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
}
const storedApi = typeof window !== 'undefined' ? (readStored('fc_api_base_url') || readStored('api_base_url')) : null;
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
let API_URL = storedApi || envApiUrl || '/api/v1';
// If the provided base looks like a backend origin (no /api/), append /api/v1
try {
const maybe = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
if (!/\/api\//.test(maybe.pathname)) {
// ensure single trailing slash then append api/v1
maybe.pathname = maybe.pathname.replace(/\/$/, '') + '/api/v1';
API_URL = maybe.toString();
} else {
API_URL = maybe.toString();
}
} catch {
// If URL parsing fails, keep API_URL as-is
}
} catch {}
export const api: AxiosInstance = axios.create({
baseURL: API_URL,
+141
View File
@@ -0,0 +1,141 @@
import { Article } from './articles';
export interface MatchSnapshot {
external_match_id?: string;
competition?: string;
date_time?: string;
venue?: string;
home?: string;
away?: string;
score?: string;
}
export function stripHtml(html?: string): string {
if (!html) return '';
if (typeof window === 'undefined') {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
const div = document.createElement('div');
div.innerHTML = html;
return (div.textContent || div.innerText || '')?.replace(/\s+/g, ' ').trim();
}
export function composeInstagramPostFromArticle(params: {
article: Article;
trackingUrl: string;
clubName?: string;
hashtags?: string[];
match?: MatchSnapshot | null;
}): string {
const { article, trackingUrl, clubName, hashtags = [], match } = params;
const title = article.title?.trim() || '';
const plain = stripHtml(article.content).slice(0, 280);
const defaultTags = hashtags.length ? hashtags : [
`#${normalizeTag(clubName || 'FKKrnov')}`,
'#fotbal',
'#modrazluta',
];
if (match && (match.home || match.away)) {
const home = match.home || '';
const away = match.away || '';
const comp = match.competition ? `${match.competition}` : '';
const date = match.date_time ? formatDateTime(match.date_time) : '';
const score = match.score && /\d/.test(match.score) ? match.score : '';
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const lines = [
header,
'',
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
comp || date ? `${comp}${comp && date ? ' • ' : ''}${date}` : '',
match.venue ? `Místo: ${match.venue}` : '',
'',
plain ? `${plain}${plain.length === 280 ? '…' : ''}` : '',
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
'',
defaultTags.join(' '),
'💙💛'
].filter(Boolean);
return lines.join('\n');
}
// Informative/general article
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const lines = [
header,
'',
plain,
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
'',
defaultTags.join(' '),
'💙💛'
];
return lines.join('\n');
}
export function composeInstagramPostFromActivity(params: {
activity: any;
trackingUrl: string;
clubName?: string;
hashtags?: string[];
}): string {
const { activity, trackingUrl, clubName, hashtags = [] } = params;
const title = String(activity?.title || '').trim();
const desc = stripHtml(String(activity?.description || '')).slice(0, 280);
const date = activity?.start_time ? formatDateTime(activity.start_time) : '';
const place = activity?.location ? String(activity.location) : '';
const defaultTags = hashtags.length ? hashtags : [
`#${normalizeTag(clubName || 'FKKrnov')}`,
'#aktivity',
'#fotbal',
];
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const lines = [
header,
'',
date || place ? `${date}${date && place ? ' • ' : ''}${place}` : '',
desc,
'',
'📸 Více informací najdeš tady 👇',
`🔗 ${trackingUrl}`,
'',
defaultTags.join(' '),
'💙💛'
].filter(Boolean);
return lines.join('\n');
}
function formatDateTime(dt: string): string {
try {
const d = new Date(dt);
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
} catch {
return dt;
}
}
function normalizeTag(s: string): string {
return s
.replace(/[^\p{L}\p{N}]+/gu, '') // remove spaces/symbols
.replace(/[áä]/gi, 'a')
.replace(/[č]/gi, 'c')
.replace(/[ď]/gi, 'd')
.replace(/[éěë]/gi, 'e')
.replace(/[íï]/gi, 'i')
.replace(/[ľĺ]/gi, 'l')
.replace(/[ň]/gi, 'n')
.replace(/[óö]/gi, 'o')
.replace(/[ř]/gi, 'r')
.replace(/[š]/gi, 's')
.replace(/[ť]/gi, 't')
.replace(/[úůü]/gi, 'u')
.replace(/[ý]/gi, 'y')
.replace(/[ž]/gi, 'z');
}
+1
View File
@@ -215,6 +215,7 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'carousel', label: 'Karusel', description: 'Horizontální karusel zápasů' },
{ value: 'scroller', label: 'Posuvník', description: 'Plynulý horizontální posuvník' },
{ value: 'ticker', label: 'Ticker', description: 'Úzký ticker výsledků a zápasů' },
{ value: 'compact_split', label: 'Kompaktní rozdělený', description: 'Slider vlevo a taby jako svislé menu vpravo' },
],
sponsors: [
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
+76 -6
View File
@@ -1,5 +1,6 @@
import api, { API_URL } from './api';
import { getArticles } from './articles';
import { getCategories } from './categories';
import { getPlayers } from './public';
import { getUpcomingEvents } from './eventService';
import { getSponsors } from './sponsors';
@@ -7,7 +8,7 @@ import facrApi from './facr/facrApi';
import { getRelatedClubs, RelatedClub } from './relatedClubs';
export interface SearchResult {
type: 'club' | 'match' | 'match_past' | 'article' | 'player' | 'event' | 'sponsor' | 'team' | 'contact' | 'gallery';
type: 'club' | 'match' | 'match_past' | 'article' | 'player' | 'event' | 'sponsor' | 'team' | 'contact' | 'gallery' | 'category';
id: string | number;
title: string;
subtitle?: string;
@@ -32,6 +33,7 @@ export interface SearchResults {
teams: SearchResult[];
contacts: SearchResult[];
gallery: SearchResult[];
categories: SearchResult[];
total: number;
}
@@ -128,6 +130,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams: [],
contacts: [],
gallery: [],
categories: [],
total: 0,
};
}
@@ -149,6 +152,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teamsRes,
contactsRes,
galleryRes,
categoriesRes,
] = await Promise.allSettled([
relatedClubsPromise,
facrApi.searchClubs(query).catch(() => ({ results: [] })),
@@ -243,6 +247,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
return [];
}
})(),
getCategories(),
]);
const relatedClubs: RelatedClub[] = relatedClubsRes.status === 'fulfilled' ? relatedClubsRes.value : [];
@@ -336,6 +341,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
venue: m.venue,
external_match_id: m.match_id || m.id,
},
score: Math.max(
scoreMatch(m.home || '', q),
@@ -380,6 +386,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
venue: m.venue,
external_match_id: m.match_id || m.id,
},
score: Math.max(
scoreMatch(m.home || '', q),
@@ -389,14 +396,15 @@ export async function searchAll(query: string): Promise<SearchResults> {
),
})).sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
// Process articles
// Process articles (base by query)
const articlesData = articlesRes.status === 'fulfilled' ? articlesRes.value?.data || [] : [];
const articles: SearchResult[] = articlesData
const baseArticles: SearchResult[] = articlesData
.filter((a: any) => {
const titleMatch = scoreMatch(a.title || '', q);
const excerptMatch = scoreMatch(a.excerpt || '', q);
const contentMatch = scoreMatch(a.content || '', q);
return titleMatch > 0 || excerptMatch > 0 || contentMatch > 0;
const categoryMatch = scoreMatch((a?.category?.name || a?.category_name || '') as string, q);
return titleMatch > 0 || excerptMatch > 0 || contentMatch > 0 || categoryMatch > 0;
})
.map((a: any) => ({
type: 'article' as const,
@@ -409,11 +417,56 @@ export async function searchAll(query: string): Promise<SearchResults> {
score: Math.max(
scoreMatch(a.title || '', q),
scoreMatch(a.excerpt || '', q) * 0.7,
scoreMatch(a.content || '', q) * 0.3
scoreMatch(a.content || '', q) * 0.3,
scoreMatch((a?.category?.name || a?.category_name || '') as string, q) * 0.8
),
}))
.sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
// Enrichment: articles linked to top matched matches
const collectTopMatchIds = (arr: SearchResult[], limit: number): string[] => {
const out: string[] = [];
for (const item of arr) {
if (out.length >= limit) break;
const mid = String(item?.metadata?.external_match_id || '').trim();
if (mid && !out.includes(mid)) out.push(mid);
}
return out;
};
const topUpcoming = collectTopMatchIds(matches, 3);
const topPast = collectTopMatchIds(matchesPast, 3);
const matchIds = Array.from(new Set([...topUpcoming, ...topPast])).slice(0, 5);
let linkedArticlesData: any[] = [];
if (matchIds.length > 0) {
const linkedSettled = await Promise.allSettled(
matchIds.map((id) => getArticles({ match_id: id, published: true, page: 1, page_size: 10 }))
);
for (const r of linkedSettled) {
if (r.status === 'fulfilled' && r.value && Array.isArray((r.value as any).data)) {
linkedArticlesData.push(...((r.value as any).data as any[]));
}
}
}
const mapArticle = (a: any): SearchResult => ({
type: 'article' as const,
id: a.id,
title: a.title,
description: a.excerpt,
image_url: a.image_url,
url: `/blog/${a.slug || a.id}`,
date: a.published_at || a.created_at,
score: Math.max(
scoreMatch(a.title || '', q),
scoreMatch(a.excerpt || '', q) * 0.7,
scoreMatch(a.content || '', q) * 0.3,
scoreMatch((a?.category?.name || a?.category_name || '') as string, q) * 0.8
) + 5, // slight boost for match-linked relevance
});
const linkedMapped: SearchResult[] = (linkedArticlesData || []).map(mapArticle);
const seen = new Set<number | string>(baseArticles.map((a) => a.id));
const linkedUnique = linkedMapped.filter((a) => !seen.has(a.id));
const articles: SearchResult[] = [...baseArticles, ...linkedUnique].sort((a, b) => (b.score || 0) - (a.score || 0));
// Process players
const playersData = playersRes.status === 'fulfilled' ? playersRes.value : [];
const players: SearchResult[] = (Array.isArray(playersData) ? playersData : [])
@@ -551,6 +604,20 @@ export async function searchAll(query: string): Promise<SearchResults> {
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
// Process categories
const categoriesList: any[] = categoriesRes && categoriesRes.status === 'fulfilled' ? (categoriesRes.value as any[]) : [];
const categories: SearchResult[] = (Array.isArray(categoriesList) ? categoriesList : [])
.filter((c: any) => scoreMatch(c?.name || '', q) > 0)
.map((c: any) => ({
type: 'category' as const,
id: c.id,
title: c.name,
description: c.description,
url: `/blog?category_id=${c.id}`,
score: scoreMatch(c?.name || '', q),
}))
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
const total =
clubs.length +
matches.length +
@@ -561,7 +628,8 @@ export async function searchAll(query: string): Promise<SearchResults> {
sponsors.length +
teams.length +
contacts.length +
gallery.length;
gallery.length +
categories.length;
return {
clubs,
@@ -574,6 +642,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams,
contacts,
gallery,
categories,
total,
};
} catch (error) {
@@ -589,6 +658,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams: [],
contacts: [],
gallery: [],
categories: [],
total: 0,
};
}
+2 -2
View File
@@ -81,13 +81,13 @@ body.style-pack-modern .section-head h3::after { width: 64px; }
}
/* News list */
[data-element="news"] .blog-list {
[data-element="news"][data-variant="grid_two"] .blog-list {
display: grid;
grid-template-columns: 1fr;
gap: var(--pack-gap-md);
}
@media (min-width: 768px) {
[data-element="news"] .blog-list {
[data-element="news"][data-variant="grid_two"] .blog-list {
grid-template-columns: 1fr 1fr;
}
}
+71 -4
View File
@@ -95,6 +95,13 @@ func getPrefetchBaseURL() string {
return base
}
func foldAccents(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
t := transform.Chain(norm.NFD, transform.RemoveFunc(func(r rune) bool { return unicode.Is(unicode.Mn, r) }), norm.NFC)
out, _, _ := transform.String(t, s)
return out
}
// GetMatchesHistory returns cached past matches with overrides applied (public)
// Optional query: q= filters by home/away/venue/competition
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
@@ -183,6 +190,7 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
// Optional search filter
if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" {
sq := foldAccents(s)
filtered := make([]map[string]interface{}, 0, len(matches))
for _, m := range matches {
get := func(k string) string {
@@ -199,7 +207,7 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
if f == "" {
continue
}
if strings.Contains(strings.ToLower(f), s) {
if strings.Contains(foldAccents(f), sq) {
matched = true
break
}
@@ -211,6 +219,7 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
matches = filtered
}
// Respond with filtered/processed past matches
c.Header("Cache-Control", "public, max-age=60")
c.JSON(http.StatusOK, matches)
}
@@ -311,6 +320,35 @@ func (bc *BaseController) GetMatches(c *gin.Context) {
}
}
}
if raw := strings.TrimSpace(c.Query("q")); raw != "" {
sq := foldAccents(raw)
filtered := make([]map[string]any, 0, len(matches))
for _, m := range matches {
get := func(k string) string {
if v, ok := m[k]; ok {
if vs, ok2 := v.(string); ok2 {
return vs
}
}
return ""
}
fields := []string{get("home"), get("away"), get("venue"), get("competition"), get("competition_name"), get("league")}
matched := false
for _, f := range fields {
if f == "" {
continue
}
if strings.Contains(foldAccents(f), sq) {
matched = true
break
}
}
if matched {
filtered = append(filtered, m)
}
}
matches = filtered
}
c.Header("Cache-Control", "public, max-age=60")
c.JSON(http.StatusOK, matches)
}
@@ -318,6 +356,7 @@ func (bc *BaseController) GetMatches(c *gin.Context) {
func (bc *BaseController) GetStandings(c *gin.Context) {
p := filepath.Join("cache", "prefetch", "facr_tables.json")
f, err := os.Open(p)
// ... (rest of the code remains the same)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached standings"})
return
@@ -3503,12 +3542,25 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays
}
// Deployment base URLs
if body.FrontendBaseURL != nil {
s.FrontendBaseURL = strings.TrimSpace(*body.FrontendBaseURL)
v := strings.TrimSpace(*body.FrontendBaseURL)
if v != "" {
if u, err := url.Parse(v); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
u.Path = ""
s.FrontendBaseURL = u.String()
}
}
}
if body.APIBaseURL != nil {
s.APIBaseURL = strings.TrimSpace(*body.APIBaseURL)
v := strings.TrimSpace(*body.APIBaseURL)
if v != "" {
if u, err := url.Parse(v); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
if !strings.Contains(u.Path, "/api/") {
u.Path = strings.TrimRight(u.Path, "/") + "/api/v1"
}
s.APIBaseURL = u.String()
}
}
}
if s.ID == 0 {
@@ -3548,6 +3600,21 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(v)
}
}
if strings.TrimSpace(s.FrontendBaseURL) != "" && config.AppConfig != nil {
if u, err := url.Parse(s.FrontendBaseURL); err == nil {
origin := u.Scheme + "://" + u.Host
found := false
for _, ao := range config.AppConfig.AllowedOrigins {
if ao == origin {
found = true
break
}
}
if !found {
config.AppConfig.AllowedOrigins = append(config.AppConfig.AllowedOrigins, origin)
}
}
}
c.JSON(http.StatusOK, s)
}
+1 -1
View File
@@ -167,7 +167,7 @@ func main() {
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Cache-Control, X-Requested-With, X-Session-Token, X-Admin-Token, X-Dev-Admin")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)