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> </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 { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth(); const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@@ -726,6 +726,17 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
return out; return out;
}, [navSplit]); }, [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 ( return (
<Box position="sticky" top={0} zIndex={1000}> <Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */} {/* Top bar with socials and quick external links */}
@@ -758,11 +769,11 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
{/* Main Nav Bar */} {/* Main Nav Bar */}
<Box <Box
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')} bg={navBg}
backdropFilter="saturate(180%) blur(10px)" backdropFilter={navBackdrop}
borderBottomWidth="1px" borderBottomWidth={navBorderBottomWidth}
borderColor="border.subtle" 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" 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} /> <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 */} {/* Tab 2: Create new poll */}
<TabPanel px={0} py={3}> <TabPanel px={0} py={3}>
<VStack spacing={3} align="stretch"> <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> <FormControl isRequired>
<FormLabel fontSize="sm">Název ankety</FormLabel> <FormLabel fontSize="sm">Název ankety</FormLabel>
<Input <Input
@@ -503,42 +568,6 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
> >
Přidat možnost Přidat možnost
</Button> </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> </VStack>
</FormControl> </FormControl>
@@ -105,11 +105,11 @@ import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../
const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = { const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
hero: ['grid', 'scroller', 'swiper', 'swiper_full'], hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
news: ['grid_one', 'grid_two', 'grid', 'scroller'], news: ['grid_one', 'grid_two', 'grid', 'list', 'scroller'],
matches: ['compact'], matches: ['compact'],
sponsors: ['grid', 'slider', 'scroller', 'pyramid'], sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
gallery: ['grid'], gallery: ['grid'],
videos: ['grid'], videos: ['grid', 'carousel'],
merch: ['grid'], merch: ['grid'],
table: ['split_news'], table: ['split_news'],
banner: ['top'], banner: ['top'],
@@ -426,11 +426,16 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
...cfg, ...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant) 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 cssByElement: Record<string, string> = {};
const stylesByElement: Record<string, Record<string, any>> = {};
sanitizedConfigs.forEach(cfg => { sanitizedConfigs.forEach(cfg => {
const css = (cfg.settings && (cfg.settings as any).customCSS) || ''; const css = String(((cfg.settings as any)?.customCSS) || ((cfg.settings as any)?.styles?.customCSS) || '');
if (css) cssByElement[cfg.element_name] = String(css); 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); setConfigs(sanitizedConfigs);
const changes: Record<string, string> = {}; const changes: Record<string, string> = {};
@@ -448,16 +453,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setLocalChanges(changes); setLocalChanges(changes);
setVisibleElements(visible); setVisibleElements(visible);
setElementOrder(order); setElementOrder(order);
// Prime style state with saved custom CSS // Prime style state with saved styles + custom CSS
if (Object.keys(cssByElement).length > 0) { if (Object.keys(stylesByElement).length > 0 || Object.keys(cssByElement).length > 0) {
setElementStyles(prev => { setElementStyles(prev => {
const next = { ...prev } as Record<string, any>; const next = { ...prev } as Record<string, any>;
Object.entries(stylesByElement).forEach(([name, st]) => {
next[name] = { ...(next[name] || {}), ...st };
});
Object.entries(cssByElement).forEach(([name, css]) => { Object.entries(cssByElement).forEach(([name, css]) => {
next[name] = { ...(next[name] || {}), customCSS: css }; next[name] = { ...(next[name] || {}), customCSS: css };
}); });
return next; return next;
}); });
// Inject saved CSS for preview (admin only) // Inject saved custom CSS for preview (admin only)
Object.entries(cssByElement).forEach(([name, css]) => { Object.entries(cssByElement).forEach(([name, css]) => {
try { try {
const styleId = `custom-css-${name}`; const styleId = `custom-css-${name}`;
@@ -7,6 +7,7 @@ import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext'; import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url'; import { assetUrl } from '../../utils/url';
import { Eye, Clock } from 'lucide-react'; import { Eye, Clock } from 'lucide-react';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const Card: React.FC<{ a: Article }> = ({ a }) => { const Card: React.FC<{ a: Article }> = ({ a }) => {
const cardBg = useColorModeValue('white', 'gray.800'); const cardBg = useColorModeValue('white', 'gray.800');
@@ -90,6 +91,14 @@ const Card: React.FC<{ a: Article }> = ({ a }) => {
)} )}
</HStack> </HStack>
</VStack> </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> </Box>
); );
}; };
+10
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles'; import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url'; import { assetUrl } from '../../utils/url';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const BlogCard: React.FC<{ article: Article }> = ({ article }) => { const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`; const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
@@ -24,6 +25,7 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
borderColor={border} borderColor={border}
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }} _hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)" transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
> >
<Box position="relative" overflow="hidden"> <Box position="relative" overflow="hidden">
<Image <Image
@@ -70,6 +72,14 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
)} )}
</HStack> </HStack>
</VStack> </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> </VStack>
); );
}; };
+18 -1
View File
@@ -5,6 +5,7 @@ import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url'; import { assetUrl } from '../../utils/url';
import { useClubTheme } from '../../contexts/ClubThemeContext'; import { useClubTheme } from '../../contexts/ClubThemeContext';
import { Eye, Clock } from 'lucide-react'; import { Eye, Clock } from 'lucide-react';
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const FeaturedBlog: React.FC = () => { const FeaturedBlog: React.FC = () => {
const { data, isLoading } = useQuery({ 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> <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> <Heading size="md">{main.title}</Heading>
</VStack> </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> </Box>
)} )}
</GridItem> </GridItem>
<GridItem> <GridItem>
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{[side1, side2].filter(Boolean).map((a) => ( {[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" /> <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}> <VStack align="stretch" spacing={2} flex={1}>
<HStack spacing={2} flexWrap="wrap"> <HStack spacing={2} flexWrap="wrap">
@@ -80,6 +89,14 @@ const FeaturedBlog: React.FC = () => {
</HStack> </HStack>
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading> <Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
</VStack> </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> </HStack>
))} ))}
</VStack> </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> <Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
{p.date_of_birth ? ( {p.date_of_birth ? (
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}> <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> </Text>
) : null} ) : null}
</VStack> </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; export default TeamScroller;
@@ -8,8 +8,8 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
type Props = { type Props = {
// optional manual override
videos?: string[]; videos?: string[];
variant?: 'grid' | 'carousel';
}; };
type RenderItem = { type RenderItem = {
@@ -39,7 +39,7 @@ const toEmbed = (idOrUrl: string): string => {
return `https://www.youtube.com/embed/${idOrUrl}`; 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 cardBg = useColorModeValue('white', 'gray.800');
const theme = useClubTheme(); const theme = useClubTheme();
const { data: settings } = usePublicSettings(); const { data: settings } = usePublicSettings();
@@ -55,7 +55,11 @@ const VideosSection: React.FC<Props> = ({ videos }) => {
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean') const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
? Boolean((settings as any)?.videos_module_enabled) ? Boolean((settings as any)?.videos_module_enabled)
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured)); : (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'; const source = settings?.videos_source || 'auto';
// Default to 6 items on homepage unless overridden by settings (max 12) // Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6)); 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' ? ( {headerVariant === 'sparta_navbar' ? (
<SpartaNavbar /> <SpartaNavbar />
) : ( ) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} /> <Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
)} )}
</Box> </Box>
{children} {children}
@@ -70,7 +70,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{headerVariant === 'sparta_navbar' ? ( {headerVariant === 'sparta_navbar' ? (
<SpartaNavbar /> <SpartaNavbar />
) : ( ) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} /> <Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
)} )}
</Box> </Box>
<Container maxW="container.xl" py={8}> <Container maxW="container.xl" py={8}>
+48 -1
View File
@@ -25,7 +25,8 @@ const MatchesSlider: React.FC<{
onActiveChange: (idx: number) => void; onActiveChange: (idx: number) => void;
onMatchClick?: (m: SliderMatch, compName?: string) => void; onMatchClick?: (m: SliderMatch, compName?: string) => void;
elementProps?: any; 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 trackRef = useRef<HTMLDivElement | null>(null);
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))]; const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
@@ -56,6 +57,52 @@ const MatchesSlider: React.FC<{
} catch {} } catch {}
}, [activeIndex, JSON.stringify(current?.matches)]); }, [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 ( return (
<section className="matches-slider" {...(elementProps || {})}> <section className="matches-slider" {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}> <div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
+98 -4
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 () => { const loadConfigs = async () => {
try { try {
const data = await getPageElementConfigs(pageType); const data = await getPageElementConfigs(pageType);
@@ -123,8 +184,22 @@ export const useAllPageElementConfigs = (pageType: string) => {
setVisibility(visMap); setVisibility(visMap);
if (Object.keys(stylesMap).length > 0) { if (Object.keys(stylesMap).length > 0) {
setStyles(stylesMap); setStyles(stylesMap);
} else {
setStyles({});
} }
setElementOrder(order); 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 // Apply initial order to DOM only in editor/preview mode
const isEditingMode = (() => { const isEditingMode = (() => {
@@ -172,6 +247,16 @@ export const useAllPageElementConfigs = (pageType: string) => {
...prev, ...prev,
[elementName]: visible [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 // Force React to re-render by incrementing refresh key
setRefreshKey(prev => prev + 1); setRefreshKey(prev => prev + 1);
@@ -199,10 +284,15 @@ export const useAllPageElementConfigs = (pageType: string) => {
if (previewMode) { if (previewMode) {
// Only update state - let React apply the styles through component rendering // Only update state - let React apply the styles through component rendering
// This prevents conflicts with React's virtual DOM // This prevents conflicts with React's virtual DOM
setStyles(prev => ({ setStyles(prev => {
...prev, const next = {
[elementName]: newStyles ...prev,
})); [elementName]: newStyles
};
// Also update injected CSS for global preview application
updateInjectedStyleProps(next);
return next;
});
} }
}) as EventListener; }) as EventListener;
@@ -215,6 +305,10 @@ export const useAllPageElementConfigs = (pageType: string) => {
window.removeEventListener('myuibrix-change', handleMyUIbrixChange); window.removeEventListener('myuibrix-change', handleMyUIbrixChange);
window.removeEventListener('myuibrix-reorder', handleMyUIbrixReorder); window.removeEventListener('myuibrix-reorder', handleMyUIbrixReorder);
window.removeEventListener('myuibrix-style-change', handleMyUIbrixStyleChange); window.removeEventListener('myuibrix-style-change', handleMyUIbrixStyleChange);
try {
const s = document.getElementById('myuibrix-style-props');
if (s) s.remove();
} catch {}
}; };
}, [pageType]); }, [pageType]);
+2
View File
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import './styles/global-enhancements.css'; import './styles/global-enhancements.css';
import './styles/admin-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 // Quill editor styles (MUST be imported globally) - CRITICAL for rich text editor
import 'react-quill/dist/quill.snow.css'; import 'react-quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css'; import 'react-image-crop/dist/ReactCrop.css';
+19 -4
View File
@@ -24,7 +24,7 @@ import {
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'; 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 { cs } from 'date-fns/locale';
import { getEvents } from '../services/eventService'; import { getEvents } from '../services/eventService';
@@ -69,7 +69,7 @@ const ActivitiesCalendarPage: React.FC = () => {
const weeks = useMemo(() => { const weeks = useMemo(() => {
const start = startOfWeek(startOfMonth(monthRef), { weekStartsOn: 1 }); const start = startOfWeek(startOfMonth(monthRef), { weekStartsOn: 1 });
const days: Date[] = []; 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; return days;
}, [monthRef]); }, [monthRef]);
@@ -227,7 +227,22 @@ const ActivitiesCalendarPage: React.FC = () => {
const key = format(day, 'yyyy-MM-dd'); const key = format(day, 'yyyy-MM-dd');
const list = byDate.get(key) || []; const list = byDate.get(key) || [];
const faded = !isSameMonth(day, monthRef); 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 ( return (
<Box <Box
key={idx} key={idx}
@@ -290,7 +305,7 @@ const ActivitiesCalendarPage: React.FC = () => {
<Box px={3} py={2} bg={listHeaderBg} borderLeftWidth="4px" borderLeftColor={'brand.primary'}> <Box px={3} py={2} bg={listHeaderBg} borderLeftWidth="4px" borderLeftColor={'brand.primary'}>
<Flex align="center" gap={2}> <Flex align="center" gap={2}>
<Text fontWeight="semibold"> <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> </Text>
<Badge colorScheme="purple" borderRadius="full">{dayEvents.length}</Badge> <Badge colorScheme="purple" borderRadius="full">{dayEvents.length}</Badge>
</Flex> </Flex>
@@ -11,6 +11,7 @@ import { trackEvent as umamiTrackEvent } from '../utils/umami';
import EventLocationMap from '../components/events/EventLocationMap'; import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll'; import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import FilePreview from '../components/common/FilePreview'; import FilePreview from '../components/common/FilePreview';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
const ActivityDetailPage: React.FC = () => { const ActivityDetailPage: React.FC = () => {
const { id } = useParams(); const { id } = useParams();
@@ -116,6 +117,12 @@ const ActivityDetailPage: React.FC = () => {
return ( return (
<MainLayout> <MainLayout>
<Box py={10} bg="transparent"> <Box py={10} bg="transparent">
<InstagramGeneratorButton
activity={data}
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
placement="fixed"
size="md"
/>
<Container maxW="3xl"> <Container maxW="3xl">
{loading && ( {loading && (
<HStack><Spinner size="sm" /><Text>Načítání</Text></HStack> <HStack><Spinner size="sm" /><Text>Načítání</Text></HStack>
+294 -172
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 { 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 { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom'; 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 MainLayout from '../components/layout/MainLayout';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
@@ -18,6 +18,11 @@ import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI'; import { getTeamLogo } from '../utils/sportLogosAPI';
import FilePreview from '../components/common/FilePreview'; import FilePreview from '../components/common/FilePreview';
import { usePublicSettings } from '../hooks/usePublicSettings'; 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) => { const toText = (html?: string) => {
if (!html) return ''; if (!html) return '';
@@ -123,6 +128,38 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000, 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) // Fetch gallery album if article has one (fallback to URL when ID is missing)
const galleryAlbumQuery = useQuery({ const galleryAlbumQuery = useQuery({
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id || (data as any)?.gallery_album_url], 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]); }, [(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 (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>; if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
@@ -256,6 +311,13 @@ const ArticleDetailPage: React.FC = () => {
return ( return (
<MainLayout> <MainLayout>
<Box> <Box>
<InstagramGeneratorButton
article={data as any}
match={matchSnapshot}
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
placement="fixed"
size="md"
/>
<Helmet> <Helmet>
<title>{title}</title> <title>{title}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
@@ -340,183 +402,243 @@ const ArticleDetailPage: React.FC = () => {
</Container> </Container>
</Box> </Box>
<Container maxW="7xl"> <Container maxW="7xl">
<Stack spacing={6}> <SimpleGrid columns={{ base: 1, lg: 12 }} spacing={6}>
{/* Featured Image - smaller with subtle overlay */} <Box gridColumn={{ base: '1 / -1', lg: 'span 8' }}>
{data.image_url && ( <Stack spacing={6}>
<Box position="relative" borderRadius="xl" overflow="hidden"> {/* Featured Image - smaller with subtle overlay */}
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h={{ base: '220px', md: '360px' }} objectFit="cover" /> {data.image_url && (
<Box position="absolute" inset={0} bg="brand.primary" opacity={0.08} pointerEvents="none" /> <Box position="relative" borderRadius="xl" overflow="hidden">
<Box position="absolute" inset={0} bgGradient="linear(to-b, rgba(0,0,0,0.12), rgba(0,0,0,0.02))" pointerEvents="none" /> <Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h={{ base: '220px', md: '360px' }} objectFit="cover" />
</Box> <Box position="absolute" inset={0} bg="brand.primary" opacity={0.08} pointerEvents="none" />
)} <Box position="absolute" inset={0} bgGradient="linear(to-b, rgba(0,0,0,0.12), rgba(0,0,0,0.02))" pointerEvents="none" />
{/* YouTube Video Section - smaller and rounded */} </Box>
{(data as any)?.youtube_video_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
<Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
</Box>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
</Box>
)}
{/* Match Section - Card with logos, score/countdown, venue/date */}
{(matchLinkQuery.data as any)?.external_match_id && (
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
{/* Edge fades */}
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
{opponentColor && (
<Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
)} )}
<Heading as="h3" size="md" mb={3}>Zápas k článku</Heading> {/* YouTube Video Section - smaller and rounded */}
{facrMatchQuery.isLoading ? ( {(data as any)?.youtube_video_id && (
<Text color={textMuted}>Načítám údaje o zápasu</Text> <Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
) : facrMatchQuery.data ? ( <Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
<> <Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
<HStack spacing={2} wrap="wrap" mb={3}> <AspectRatio ratio={16 / 9}>
{facrMatchQuery.data.competitionName && ( <Box
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge> as="iframe"
)} src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge> allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
</HStack> allowFullScreen
<Flex align="center" justify="space-between" gap={4}> title={(data as any).youtube_video_title || 'YouTube video'}
<VStack flex={1} spacing={2} minW="0"> onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).home_team_id || (facrMatchQuery.data as any).home_id || '')} teamName={String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} /> onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text> />
</VStack> </AspectRatio>
<VStack minW={{ base: '100px', md: '140px' }}> </Box>
{(() => { {(data as any).youtube_video_title ? (
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || ''); <Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
const d = new Date(dRaw); ) : null}
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs'); </Box>
if (hasScore) { )}
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
return (<Heading size="2xl">{score}</Heading>); {/* Match Section - Card with logos, score/countdown, venue/date */}
} {(matchLinkQuery.data as any)?.external_match_id && (
const now = Date.now(); <Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
const ms = d.getTime() - now; {/* Edge fades */}
const days = Math.max(0, Math.floor(ms / (1000*60*60*24))); <Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60))); {opponentColor && (
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60))); <Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
})()}
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
return <Text fontSize="sm" color={textMuted}>{d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}</Text>;
})()}
</VStack>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).away_team_id || (facrMatchQuery.data as any).away_id || '')} teamName={String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} />
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')}</Text>
</VStack>
</Flex>
{(facrMatchQuery.data as any).report_url && (
<Box mt={3}>
<Link href={String((facrMatchQuery.data as any).report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
</Box>
)} )}
</> <Heading as="h3" size="md" mb={3}>Zápas k článku</Heading>
) : ( {facrMatchQuery.isLoading ? (
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text> <Text color={textMuted}>Načítám údaje o zápasu</Text>
) : facrMatchQuery.data ? (
<>
<HStack spacing={2} wrap="wrap" mb={3}>
{facrMatchQuery.data.competitionName && (
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
)}
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge>
</HStack>
<Flex align="center" justify="space-between" gap={4}>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).home_team_id || (facrMatchQuery.data as any).home_id || '')} teamName={String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} />
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text>
</VStack>
<VStack minW={{ base: '100px', md: '140px' }}>
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
if (hasScore) {
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
return (<Heading size="2xl">{score}</Heading>);
}
const now = Date.now();
const ms = d.getTime() - now;
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60)));
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60)));
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
})()}
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
return <Text fontSize="sm" color={textMuted}>{d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}</Text>;
})()}
</VStack>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).away_team_id || (facrMatchQuery.data as any).away_id || '')} teamName={String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} />
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')}</Text>
</VStack>
</Flex>
{(facrMatchQuery.data as any).report_url && (
<Box mt={3}>
<Link href={String((facrMatchQuery.data as any).report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
</Box>
)}
</>
) : (
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
)}
</Box>
)} )}
</Box>
)}
{/* Article Content - Main Section with editor-like lists */} {/* Article Content - Main Section with editor-like lists */}
<Box <Box
className="article-content" className="article-content"
bg={useColorModeValue('white','gray.900')} bg={useColorModeValue('white','gray.900')}
borderRadius="lg" borderRadius="lg"
p={{ base: 4, md: 6 }} p={{ base: 4, md: 6 }}
ref={contentRef} ref={contentRef}
sx={{ 'ul, ol': { pl: 6, listStylePosition: 'outside' }, 'ul': { listStyleType: 'disc' }, 'ol': { listStyleType: 'decimal' }, 'li': { mb: 2 } }} sx={{ 'ul, ol': { pl: 6, listStylePosition: 'outside' }, 'ul': { listStyleType: 'disc' }, 'ol': { listStyleType: 'decimal' }, 'li': { mb: 2 } }}
dangerouslySetInnerHTML={{ __html: safeContentHTML }} dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/> />
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */} {/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && ( {((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}> <Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
<Box mb={3}> <Box mb={3}>
<HStack justify="space-between" align="center" mb={2}> <HStack justify="space-between" align="center" mb={2}>
<Heading as="h3" size="md">Fotogalerie k článku</Heading> <Heading as="h3" size="md">Fotogalerie k článku</Heading>
<Button <Button
as={RouterLink} as={RouterLink}
to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'}
size="sm" size="sm"
colorScheme="blue" colorScheme="blue"
variant="outline" variant="outline"
rightIcon={<ArrowRight size={16} />} rightIcon={<ArrowRight size={16} />}
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })} onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
> >
Zobrazit galerii Zobrazit galerii
</Button> </Button>
</HStack> </HStack>
{/* Custom 5-image mosaic */} {/* Custom 5-image mosaic */}
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => { {Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5); const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5);
if (photos.length < 5) { if (photos.length < 5) {
return ( return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}> <SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
{photos.map((p: any) => ( {photos.map((p: any) => (
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} /> <Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} />
))} ))}
</SimpleGrid> </SimpleGrid>
); );
} }
return ( return (
<Box position="relative" sx={{ <Box position="relative" sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1.2fr 1fr', gridTemplateColumns: '1fr 1.2fr 1fr',
gridTemplateRows: 'repeat(2, 140px)', gridTemplateRows: 'repeat(2, 140px)',
gap: '8px' gap: '8px'
}}> }}>
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" /> <Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" /> <Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" /> <Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" /> <Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" /> <Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button> <Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
</Box> </Box>
); );
})()} })()}
{/* Zonerama Attribution */} {/* Zonerama Attribution */}
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700"> <HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
<Text>📸 Fotografie z</Text> <Text>📸 Fotografie z</Text>
<Link <Link
href={(data as any).gallery_album_url || `https://zonerama.com`} href={(data as any).gallery_album_url || `https://zonerama.com`}
isExternal isExternal
fontWeight="600" fontWeight="600"
color="blue.600" color="blue.600"
display="inline-flex" display="inline-flex"
alignItems="center" alignItems="center"
gap={1} gap={1}
> >
Zonerama Zonerama
<ExternalLink size={12} /> <ExternalLink size={12} />
</Link> </Link>
</HStack> </HStack>
</Box> </Box>
</Box> </Box>
)} )}
{/* Embedded Poll - directly under content/gallery */} {/* Embedded Poll - directly under content/gallery */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />} {data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
</Stack> </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> </Container>
</Box> </Box>
+10
View File
@@ -9,6 +9,7 @@ import { getCategories, CategoryItem } from '../services/categories';
import SponsorsSection from '../components/common/SponsorsSection'; import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA'; import NewsletterCTA from '../components/common/NewsletterCTA';
import { Eye, Clock, Search, X } from 'lucide-react'; 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 BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`; 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" borderWidth="0"
_hover={{ boxShadow: 'xl', transform: 'translateY(-3px)' }} _hover={{ boxShadow: 'xl', transform: 'translateY(-3px)' }}
transition="all 0.25s ease" transition="all 0.25s ease"
position="relative"
> >
<Box position="relative"> <Box position="relative">
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={imageH} objectFit="cover" /> <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} {article.title}
</Heading> </Heading>
</Box> </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> </LinkBox>
); );
}; };
+87 -10
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import MainLayout from '../components/layout/MainLayout'; 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 { 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 { 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 { cs } from 'date-fns/locale';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases'; import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@@ -466,7 +466,21 @@ const CalendarPage: React.FC = () => {
const target = e.currentTarget as HTMLElement & { dataset?: any }; const target = e.currentTarget as HTMLElement & { dataset?: any };
const href = (target.getAttribute && target.getAttribute('data-href')) || (target as any).dataset?.href; const href = (target.getAttribute && target.getAttribute('data-href')) || (target as any).dataset?.href;
if (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 // Build 6 weeks x 7 days
const days: Date[] = []; const days: Date[] = [];
for (let i = 0; i < 42; i++) { for (let i = 0; i < 42; i++) {
days.push(new Date(start.getTime() + i * 86400000)); days.push(addDays(start, i));
} }
return days; return days;
}, [monthRef]); }, [monthRef]);
@@ -664,7 +678,19 @@ const CalendarPage: React.FC = () => {
{latestResults.map((m) => { {latestResults.map((m) => {
const href = mkHref(m); const href = mkHref(m);
return ( 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}> <Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text> <Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
<Badge colorScheme="purple">{m.__compName || c.name}</Badge> <Badge colorScheme="purple">{m.__compName || c.name}</Badge>
@@ -758,7 +784,22 @@ const CalendarPage: React.FC = () => {
const key = format(day, 'yyyy-MM-dd'); const key = format(day, 'yyyy-MM-dd');
const list = byDate.get(key) || []; const list = byDate.get(key) || [];
const faded = !isSameMonth(day, monthRef); 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 ( return (
<Box <Box
key={idx} 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 isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const countdown = liveCountdowns[String(m.id)]; const countdown = liveCountdowns[String(m.id)];
return ( 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"> <Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
{!isPast && countdown ? ( {!isPast && countdown ? (
<> <>
@@ -832,7 +885,19 @@ const CalendarPage: React.FC = () => {
<Stack spacing={4}> <Stack spacing={4}>
{(() => { {(() => {
const keys = Array.from(byDate.keys()); 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 pastKeys = keys.filter(k => k < todayStr).sort().reverse();
const futureKeys = keys.filter(k => k >= todayStr).sort(); const futureKeys = keys.filter(k => k >= todayStr).sort();
const renderGroup = (dKey: string, highlight: boolean) => { const renderGroup = (dKey: string, highlight: boolean) => {
@@ -848,7 +913,7 @@ const CalendarPage: React.FC = () => {
> >
<Flex align="center" gap={2}> <Flex align="center" gap={2}>
<Text fontWeight="semibold" color={highlight ? 'brand.primary' : listGroupHeaderText}> <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> </Text>
{highlight && ( {highlight && (
<Badge colorScheme="blue" variant="subtle" borderRadius="full">Dnes</Badge> <Badge colorScheme="blue" variant="subtle" borderRadius="full">Dnes</Badge>
@@ -862,7 +927,19 @@ const CalendarPage: React.FC = () => {
const sentiment = isPast ? getSentiment(m) : null; const sentiment = isPast ? getSentiment(m) : null;
const countdown = liveCountdowns[String(m.id)]; const countdown = liveCountdowns[String(m.id)];
return ( 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 <Flex
align="center" align="center"
justify="space-between" justify="space-between"
@@ -1064,7 +1141,7 @@ const CalendarPage: React.FC = () => {
<Text fontSize="lg" fontWeight="semibold" color="gray.800" mb={1}> <Text fontSize="lg" fontWeight="semibold" color="gray.800" mb={1}>
{(() => { {(() => {
try { 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 { } catch {
return selected.match.date; return selected.match.date;
} }
+18 -4
View File
@@ -1049,7 +1049,7 @@ const HomePage: React.FC = () => {
<div className="name">{p.name}</div> <div className="name">{p.name}</div>
<div className="role">{p.position || 'Hráč'}</div> <div className="role">{p.position || 'Hráč'}</div>
{typeof p.number !== 'undefined' && <div className="number">#{p.number}</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>
))} ))}
</div> </div>
@@ -1338,7 +1338,7 @@ const HomePage: React.FC = () => {
// } // }
return ( return (
<MainLayout headerInsideContainer showSponsorsSection={false}> <MainLayout showSponsorsSection={false}>
<div className="container" data-element="container" style={{ ...getStyles('container') }}> <div className="container" data-element="container" style={{ ...getStyles('container') }}>
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} /> <div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
{/* Above-hero club bar (MyUIbrix managed) */} {/* Above-hero club bar (MyUIbrix managed) */}
@@ -1520,6 +1520,7 @@ const HomePage: React.FC = () => {
setSelectedMatch({ ...m, competition: compName, competitionName: compName }); setSelectedMatch({ ...m, competition: compName, competitionName: compName });
setIsMatchModalOpen(true); 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') } }} 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> <h3>Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a> <a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div> </div>
<NewsList items={news as any} /> {newsVariant === 'scroller' ? (
<BlogCardsScroller />
) : (
<NewsList items={news as any} />
)}
</section> </section>
)} )}
@@ -1642,7 +1647,7 @@ const HomePage: React.FC = () => {
{isVisible('videos', false) && ( {isVisible('videos', false) && (
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}> <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' }}> <div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<VideosSection /> <VideosSection variant={(getVariant('videos', 'grid') as any) as 'grid' | 'carousel'} />
</div> </div>
</section> </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; export default HomePage;
+26 -5
View File
@@ -23,6 +23,8 @@ const PlayerDetailPage: React.FC = () => {
); );
} }
if (isError || !data) { if (isError || !data) {
return ( return (
<MainLayout> <MainLayout>
@@ -73,7 +75,7 @@ const PlayerDetailPage: React.FC = () => {
<Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text> <Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text>
)} )}
{data.date_of_birth && ( {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) && ( {(data.height || data.weight) && (
<Text> <Text>
@@ -81,10 +83,10 @@ const PlayerDetailPage: React.FC = () => {
</Text> </Text>
)} )}
{data.email && ( {data.email && (
<Text><b>Email:</b> {data.email}</Text> <Text><b>Email:</b> <a href={`mailto:${data.email}`}>{data.email}</a></Text>
)} )}
{data.phone && ( {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 && ( {typeof data.team_id === 'number' && data.team_id > 0 && (
<Text><b>Tým ID:</b> {data.team_id}</Text> <Text><b>Tým ID:</b> {data.team_id}</Text>
@@ -95,10 +97,8 @@ const PlayerDetailPage: React.FC = () => {
</VStack> </VStack>
</Container> </Container>
{/* Newsletter CTA */}
<NewsletterCTA /> <NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection /> <SponsorsSection />
</Box> </Box>
</MainLayout> </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; export default PlayerDetailPage;
+74 -2
View File
@@ -53,6 +53,7 @@ const SearchPage: React.FC = () => {
teams: [], teams: [],
contacts: [], contacts: [],
gallery: [], gallery: [],
categories: [],
total: 0, total: 0,
}); });
const [activeTab, setActiveTab] = useState<string>('all'); const [activeTab, setActiveTab] = useState<string>('all');
@@ -87,6 +88,7 @@ const SearchPage: React.FC = () => {
teams: [], teams: [],
contacts: [], contacts: [],
gallery: [], gallery: [],
categories: [],
total: 0, total: 0,
}); });
return; return;
@@ -186,6 +188,33 @@ const SearchPage: React.FC = () => {
<Button type="submit" mt={3} colorScheme="blue" size="lg">Vyhledat</Button> <Button type="submit" mt={3} colorScheme="blue" size="lg">Vyhledat</Button>
</Box> </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 && ( {loading && (
<Flex justify="center" my={12}> <Flex justify="center" my={12}>
@@ -227,7 +256,7 @@ const SearchPage: React.FC = () => {
<Tab>Články ({results.articles.length})</Tab> <Tab>Články ({results.articles.length})</Tab>
<Tab><Icon as={FaUsers} mr={2} />Hráči ({results.players.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><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> </TabList>
<TabPanels> <TabPanels>
@@ -260,6 +289,29 @@ const SearchPage: React.FC = () => {
</Box> </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 */} {/* Players */}
{results.players.length > 0 && ( {results.players.length > 0 && (
<Box> <Box>
@@ -572,6 +624,26 @@ const SearchPage: React.FC = () => {
{/* Other Tab - Teams, Sponsors, Contacts, Gallery */} {/* Other Tab - Teams, Sponsors, Contacts, Gallery */}
<TabPanel px={0}> <TabPanel px={0}>
<VStack align="stretch" spacing={8}> <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 && ( {results.teams.length > 0 && (
<Box> <Box>
<Heading size="sm" mb={3}>Týmy</Heading> <Heading size="sm" mb={3}>Týmy</Heading>
@@ -681,7 +753,7 @@ const SearchPage: React.FC = () => {
</Box> </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> <Text color="gray.500">Žádné další výsledky</Text>
)} )}
</VStack> </VStack>
+96 -1
View File
@@ -118,6 +118,12 @@ const SetupPage: React.FC = () => {
const [youtubeUrl, setYoutubeUrl] = useState(''); const [youtubeUrl, setYoutubeUrl] = useState('');
const [galleryUrl, setGalleryUrl] = 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 toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const bg = useColorModeValue('white', 'gray.800'); const bg = useColorModeValue('white', 'gray.800');
@@ -166,6 +172,26 @@ const SetupPage: React.FC = () => {
return () => { mounted = false; }; 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 // Auto-generate JWT secret when setup is required
useEffect(() => { useEffect(() => {
if (requiresSetup && !jwtSecret) { if (requiresSetup && !jwtSecret) {
@@ -183,6 +209,14 @@ const SetupPage: React.FC = () => {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [clubQuery, searchClubs]); }, [clubQuery, searchClubs]);
useEffect(() => {
if (isDomainHost && !showAdvancedApi) {
if (frontendBaseUrl) {
setApiBaseUrl(frontendBaseUrl.replace(/\/$/, '') + '/api/v1');
}
}
}, [isDomainHost, showAdvancedApi, frontendBaseUrl]);
// Load and apply selected font for preview // Load and apply selected font for preview
useEffect(() => { useEffect(() => {
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont); const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
@@ -279,6 +313,8 @@ const SetupPage: React.FC = () => {
club_name: clubName || undefined, club_name: clubName || undefined,
club_logo_url: clubLogoUrl || undefined, club_logo_url: clubLogoUrl || undefined,
club_url: clubUrl || undefined, club_url: clubUrl || undefined,
frontend_base_url: frontendBaseUrl || undefined,
api_base_url: apiBaseUrl || undefined,
frontpage_style: frontpageStyle || undefined, frontpage_style: frontpageStyle || undefined,
primary_color: primaryColor || undefined, primary_color: primaryColor || undefined,
secondary_color: secondaryColor || undefined, secondary_color: secondaryColor || undefined,
@@ -313,19 +349,49 @@ const SetupPage: React.FC = () => {
use_tls: smtpTLS, use_tls: smtpTLS,
} : null, } : 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); await initializeSetup(payload);
// Set sensible default SEO based on setup data // Set sensible default SEO based on setup data
try { try {
const origin = (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/$/, ''); const origin = (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/$/, '');
const canonical = (frontendBaseUrl || origin || '').replace(/\/$/, '');
await updateSeoSettings({ await updateSeoSettings({
site_title: clubName || 'Fotbal Club', 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.', 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, default_og_image_url: clubLogoUrl || undefined,
canonical_base_url: origin || undefined, canonical_base_url: canonical || undefined,
enable_indexing: true, enable_indexing: true,
}); });
} catch {} } catch {}
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true }); 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 }); navigate('/login', { replace: true });
// Force full reload to ensure app picks up fresh server state and env // Force full reload to ensure app picks up fresh server state and env
setTimeout(() => { setTimeout(() => {
@@ -599,6 +665,35 @@ const SetupPage: React.FC = () => {
{/* removed overall style preview per request */} {/* 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} /> <Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🎨 Barvy a vzhled webu</Heading> <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 [draftKey, setDraftKey] = useState<string>('');
const [aiPrompt, setAiPrompt] = useState<string>(''); const [aiPrompt, setAiPrompt] = useState<string>('');
const [aiLoading, setAiLoading] = useState<boolean>(false); 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); const [aiOverwrite, setAiOverwrite] = useState<boolean>(true);
// Location coordinates for map preview // Location coordinates for map preview
const [locationLat, setLocationLat] = useState<number | undefined>(undefined); const [locationLat, setLocationLat] = useState<number | undefined>(undefined);
@@ -93,6 +93,8 @@ const AdminActivitiesPage: React.FC = () => {
// YouTube videos from club channel // YouTube videos from club channel
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]); const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club'); 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 // Auto-save hook - saves draft automatically
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({ const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
@@ -153,6 +155,66 @@ const AdminActivitiesPage: React.FC = () => {
staleTime: 5 * 60_000, 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 = () => { const openCreate = () => {
// Check for existing draft // Check for existing draft
const key = 'draft-activity-new'; const key = 'draft-activity-new';
@@ -279,14 +341,18 @@ const AdminActivitiesPage: React.FC = () => {
if (e.type) lines.push(`Typ: ${e.type}`); if (e.type) lines.push(`Typ: ${e.type}`);
if (e.description) lines.push(`Poznámky: ${e.description}`); if (e.description) lines.push(`Poznámky: ${e.description}`);
const base = lines.join('\n'); 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 toneText = aiTone === 'informative'
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim(); ? 'neutrálním, věcným a stručným stylem (bez nadsázky)'
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.'; : 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 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', { const { data } = await api.post('/ai/blog/generate', {
prompt, prompt,
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka', 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) // Handle potential JSON string response from AI (defensive parsing)
@@ -831,6 +897,30 @@ const AdminActivitiesPage: React.FC = () => {
<Box mt={4}> <Box mt={4}>
<Heading size="sm" mb={3}>Místo konání</Heading> <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 */} {/* MapLinkImporter */}
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}> <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> <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'; } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout'; 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 { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns'; import { parse, format } from 'date-fns';
import { assetUrl } from '../../utils/url'; import { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI'; import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import { API_URL } from '../../services/api'; import { API_URL } from '../../services/api';
@@ -53,15 +53,10 @@ const MatchesAdminPage = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const toast = useToast(); const toast = useToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null);
const [selected, setSelected] = useState<any | null>(null); const [selected, setSelected] = useState<any | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
home_name_override: '',
away_name_override: '',
venue_override: '', venue_override: '',
date_time_override: '', date_time_edit: '',
home_logo_url: '',
away_logo_url: '',
notes: '', notes: '',
}); });
@@ -70,6 +65,7 @@ const MatchesAdminPage = () => {
queryFn: fetchTeamLogoOverrides, queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const normalizeName = (s: string) => { const normalizeName = (s: string) => {
let out = String(s || ''); let out = String(s || '');
@@ -102,56 +98,58 @@ const MatchesAdminPage = () => {
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k]; for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx; return idx;
}, [byName]); }, [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 [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => { const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as 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)]; if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
// 2) Local/legacy overrides by name
let overrideUrl = byName[teamName]; let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)]; if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) { if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string; if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl; return overrideUrl;
} }
// 3) FACR original if provided
if (facrOriginal) return facrOriginal; if (facrOriginal) return facrOriginal;
// Fallback placeholder
return '/dist/img/logo-club-empty.svg'; return '/dist/img/logo-club-empty.svg';
}; };
// External logo upload helpers/state // Team name/logo editing removed
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);
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({ const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
queryKey: ['admin-matches-list-cache'], queryKey: ['admin-matches-list-cache'],
@@ -170,6 +168,17 @@ const MatchesAdminPage = () => {
// Optional: stable sort by date ascending // Optional: stable sort by date ascending
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm'; 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) => { items.sort((a, b) => {
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime(); 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(); 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 [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
const normalizedTeam = teamFilter.trim().toLowerCase(); const normalizedTeam = teamFilter.trim().toLowerCase();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm'; 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) // Club name (for side filter)
const { data: publicSettings } = useQuery({ const { data: publicSettings } = useQuery({
queryKey: ['public-settings'], queryKey: ['public-settings'],
@@ -410,78 +430,28 @@ const MatchesAdminPage = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
// Datetime validation (RFC3339-ish) // Datetime validation for datetime-local
const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override)); const isDateInvalid = form.date_time_edit.trim() !== '' && isNaN(new Date(form.date_time_edit).getTime());
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const externalMatchId: string = selected?.match_id || selected?.id; const externalMatchId: string = selected?.match_id || selected?.id;
if (!externalMatchId) throw new Error('Chybí match_id'); if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = { ...form }; const payload: any = {
// normalize empty strings to null so backend can clear values venue_override: form.venue_override,
date_time_override: form.date_time_edit,
notes: form.notes,
};
Object.keys(payload).forEach((k) => { Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null; if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
}); });
// First store current overrides
await putMatchOverride(externalMatchId, payload); await putMatchOverride(externalMatchId, payload);
return { ok: true };
// 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 };
}, },
onSuccess: (res: any) => { onSuccess: () => {
const r = res?.results || {}; toast({ title: 'Uloženo', status: 'success' });
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' });
setIsOpen(false); setIsOpen(false);
setSelected(null); setSelected(null);
setHomeUploadedFile(null);
setAwayUploadedFile(null);
// Invalidate the cache-backed list to refresh any merged overrides
queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] }); queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] });
}, },
onError: (e: any) => { onError: (e: any) => {
@@ -489,57 +459,34 @@ const MatchesAdminPage = () => {
}, },
}); });
const openEdit = (m: any, side?: 'home' | 'away') => { const openEdit = (m: any) => {
setSelected(m); 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 || ''; const facrStr: string = m.date_time || m.date || '';
let iso = ''; let localStr = '';
if (facrStr) { if (facrStr) {
try { try {
const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date()); 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 (_) { } catch (_) {
// If it's already ISO or another parseable format, keep as-is if valid
const d2 = new Date(facrStr); 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({ setForm({
home_name_override: m.home || m.home_team || '',
away_name_override: m.away || m.away_team || '',
venue_override: m.venue || '', venue_override: m.venue || '',
date_time_override: iso, date_time_edit: localStr,
home_logo_url: m.home_logo_url || '',
away_logo_url: m.away_logo_url || '',
notes: '', notes: '',
}); });
setIsOpen(true); 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 // Removed autofocus logic for team inputs
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]);
const drawerSize = useBreakpointValue({ base: 'full', md: 'md' }); const drawerSize = useBreakpointValue({ base: 'full', md: 'md' });
// Horizontal scroll affordance // Horizontal scroll affordance
@@ -943,7 +890,7 @@ const MatchesAdminPage = () => {
> >
<Td> <Td>
<HStack spacing={2}> <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="gray" fontSize="xs">Odehráno</Badge>}
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>} {!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
</HStack> </HStack>
@@ -965,7 +912,7 @@ const MatchesAdminPage = () => {
draggable={false} draggable={false}
/> />
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text> <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> </HStack>
</Td> </Td>
<Td textAlign="center"> <Td textAlign="center">
@@ -985,7 +932,7 @@ const MatchesAdminPage = () => {
draggable={false} draggable={false}
/> />
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text> <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> </HStack>
</Td> </Td>
<Td>{m.venue || ''}</Td> <Td>{m.venue || ''}</Td>
@@ -1023,11 +970,11 @@ const MatchesAdminPage = () => {
) : ( ) : (
<Stack spacing={4}> <Stack spacing={4}>
<FormControl> <FormControl>
<FormLabel>Datum a čas (ISO)</FormLabel> <FormLabel>Datum a čas</FormLabel>
<Input <Input
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ" type="datetime-local"
value={form.date_time_override} value={form.date_time_edit}
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, date_time_edit: e.target.value }))}
/> />
{isDateInvalid && ( {isDateInvalid && (
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage> <FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
@@ -1043,129 +990,7 @@ const MatchesAdminPage = () => {
/> />
</FormControl> </FormControl>
{/* Home team */} {/* Team name/logo editing removed */}
<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>
<FormControl> <FormControl>
<FormLabel>Poznámka</FormLabel> <FormLabel>Poznámka</FormLabel>
+14 -1
View File
@@ -438,7 +438,7 @@ const PlayersAdminPage: React.FC = () => {
</Select> </Select>
</HStack> </HStack>
<Box mt={2} fontSize="sm" color="gray.500"> <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> </Box>
</FormControl> </FormControl>
@@ -529,6 +529,9 @@ const PlayersAdminPage: React.FC = () => {
<FormLabel>Telefon (nepovinné)</FormLabel> <FormLabel>Telefon (nepovinné)</FormLabel>
<Input type="tel" value={(editing as any)?.phone || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), phone: e.target.value }))} /> <Input type="tel" value={(editing as any)?.phone || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), phone: e.target.value }))} />
</FormControl> </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> </SimpleGrid>
<FormControl> <FormControl>
@@ -615,6 +618,16 @@ const PlayersAdminPage: React.FC = () => {
return age; 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. // Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
function updateDobPart(part: 'day'|'month'|'year', value: string) { function updateDobPart(part: 'day'|'month'|'year', value: string) {
setDobParts((prev) => { setDobParts((prev) => {
@@ -197,6 +197,8 @@ const SettingsAdminPage: React.FC = () => {
(typeof (settings as any).location_latitude === 'number') && (typeof (settings as any).location_latitude === 'number') &&
(typeof (settings as any).location_longitude === 'number'), (typeof (settings as any).location_longitude === 'number'),
map_style: (settings as any).map_style, 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 // homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any, 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' }); toast({ title: 'Uloženo', description: 'Nastavení bylo úspěšně aktualizováno', status: 'success' });
// Try to refresh prefetch caches // Try to refresh prefetch caches
try { await triggerPrefetch(); } catch {} 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) { } catch (e: any) {
toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' }); toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' });
} finally { } finally {
@@ -271,6 +289,17 @@ const SettingsAdminPage: React.FC = () => {
<Divider /> <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> <Heading size="sm">Zobrazení zápasů</Heading>
<FormControl> <FormControl>
<FormLabel>Počet dní zobrazení dokončených zápasů</FormLabel> <FormLabel>Počet dní zobrazení dokončených zápasů</FormLabel>
@@ -71,6 +71,7 @@ function normalize(s: string): string {
'sportovni klub', 'sportovni klub',
'telovychovna jednota', 'telovychovna jednota',
'skolni sportovni klub', 'skolni sportovni klub',
'spolek',
'fotbal', 'fotbal',
'futsal', 'futsal',
]; ];
@@ -139,6 +140,21 @@ const TeamsAdminPage = () => {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {}; 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 // Fetch logos from logoapi.sportcreative.eu for all teams
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({}); const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
@@ -186,6 +202,15 @@ const TeamsAdminPage = () => {
if (u.startsWith('/')) return assetUrl(u) as string; if (u.startsWith('/')) return assetUrl(u) as string;
return u; 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) // Priority 1: Local admin override (exact + normalized)
let overrideUrl = byName[teamName]; let overrideUrl = byName[teamName];
if (!overrideUrl) { if (!overrideUrl) {
@@ -217,6 +242,15 @@ const TeamsAdminPage = () => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) { if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || ''); 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 || ''); return String(teamName || '');
}; };
+13
View File
@@ -828,6 +828,19 @@ html {
flex-direction: column; flex-direction: column;
gap: 16px; 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) */ /* Variant: compact_split - two columns (slider left, tabs right) */
.matches-slider[data-variant="compact_split"] .matches-grid { .matches-slider[data-variant="compact_split"] .matches-grid {
display: grid; display: grid;
+8 -9
View File
@@ -1,24 +1,23 @@
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { getToken } from '../utils/auth'; import { getToken } from '../utils/auth';
// Resolve API URL. Some code uses REACT_APP_API_URL (full api path including /api/v1), function readStored(key: string): string | null {
// others set REACT_APP_API_BASE_URL (backend origin). Normalize so baseURL always points to API root. try { return localStorage.getItem(key); } catch { return null; }
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL; }
let API_URL = envApiUrl || '/api/v1';
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 { try {
const maybe = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined); const maybe = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
if (!/\/api\//.test(maybe.pathname)) { if (!/\/api\//.test(maybe.pathname)) {
// ensure single trailing slash then append api/v1
maybe.pathname = maybe.pathname.replace(/\/$/, '') + '/api/v1'; maybe.pathname = maybe.pathname.replace(/\/$/, '') + '/api/v1';
API_URL = maybe.toString(); API_URL = maybe.toString();
} else { } else {
API_URL = maybe.toString(); API_URL = maybe.toString();
} }
} catch { } catch {}
// If URL parsing fails, keep API_URL as-is
}
export const api: AxiosInstance = axios.create({ export const api: AxiosInstance = axios.create({
baseURL: API_URL, 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: 'carousel', label: 'Karusel', description: 'Horizontální karusel zápasů' },
{ value: 'scroller', label: 'Posuvník', description: 'Plynulý horizontální posuvník' }, { value: 'scroller', label: 'Posuvník', description: 'Plynulý horizontální posuvník' },
{ value: 'ticker', label: 'Ticker', description: 'Úzký ticker výsledků a zápasů' }, { 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: [ sponsors: [
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' }, { 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 api, { API_URL } from './api';
import { getArticles } from './articles'; import { getArticles } from './articles';
import { getCategories } from './categories';
import { getPlayers } from './public'; import { getPlayers } from './public';
import { getUpcomingEvents } from './eventService'; import { getUpcomingEvents } from './eventService';
import { getSponsors } from './sponsors'; import { getSponsors } from './sponsors';
@@ -7,7 +8,7 @@ import facrApi from './facr/facrApi';
import { getRelatedClubs, RelatedClub } from './relatedClubs'; import { getRelatedClubs, RelatedClub } from './relatedClubs';
export interface SearchResult { 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; id: string | number;
title: string; title: string;
subtitle?: string; subtitle?: string;
@@ -32,6 +33,7 @@ export interface SearchResults {
teams: SearchResult[]; teams: SearchResult[];
contacts: SearchResult[]; contacts: SearchResult[];
gallery: SearchResult[]; gallery: SearchResult[];
categories: SearchResult[];
total: number; total: number;
} }
@@ -128,6 +130,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams: [], teams: [],
contacts: [], contacts: [],
gallery: [], gallery: [],
categories: [],
total: 0, total: 0,
}; };
} }
@@ -149,6 +152,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teamsRes, teamsRes,
contactsRes, contactsRes,
galleryRes, galleryRes,
categoriesRes,
] = await Promise.allSettled([ ] = await Promise.allSettled([
relatedClubsPromise, relatedClubsPromise,
facrApi.searchClubs(query).catch(() => ({ results: [] })), facrApi.searchClubs(query).catch(() => ({ results: [] })),
@@ -243,6 +247,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
return []; return [];
} }
})(), })(),
getCategories(),
]); ]);
const relatedClubs: RelatedClub[] = relatedClubsRes.status === 'fulfilled' ? relatedClubsRes.value : []; 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, home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url, away_logo_url: m.away_logo_url,
venue: m.venue, venue: m.venue,
external_match_id: m.match_id || m.id,
}, },
score: Math.max( score: Math.max(
scoreMatch(m.home || '', q), scoreMatch(m.home || '', q),
@@ -380,6 +386,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
home_logo_url: m.home_logo_url, home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url, away_logo_url: m.away_logo_url,
venue: m.venue, venue: m.venue,
external_match_id: m.match_id || m.id,
}, },
score: Math.max( score: Math.max(
scoreMatch(m.home || '', q), 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)); })).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 articlesData = articlesRes.status === 'fulfilled' ? articlesRes.value?.data || [] : [];
const articles: SearchResult[] = articlesData const baseArticles: SearchResult[] = articlesData
.filter((a: any) => { .filter((a: any) => {
const titleMatch = scoreMatch(a.title || '', q); const titleMatch = scoreMatch(a.title || '', q);
const excerptMatch = scoreMatch(a.excerpt || '', q); const excerptMatch = scoreMatch(a.excerpt || '', q);
const contentMatch = scoreMatch(a.content || '', 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) => ({ .map((a: any) => ({
type: 'article' as const, type: 'article' as const,
@@ -409,11 +417,56 @@ export async function searchAll(query: string): Promise<SearchResults> {
score: Math.max( score: Math.max(
scoreMatch(a.title || '', q), scoreMatch(a.title || '', q),
scoreMatch(a.excerpt || '', q) * 0.7, 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)); .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 // Process players
const playersData = playersRes.status === 'fulfilled' ? playersRes.value : []; const playersData = playersRes.status === 'fulfilled' ? playersRes.value : [];
const players: SearchResult[] = (Array.isArray(playersData) ? playersData : []) 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)); .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 = const total =
clubs.length + clubs.length +
matches.length + matches.length +
@@ -561,7 +628,8 @@ export async function searchAll(query: string): Promise<SearchResults> {
sponsors.length + sponsors.length +
teams.length + teams.length +
contacts.length + contacts.length +
gallery.length; gallery.length +
categories.length;
return { return {
clubs, clubs,
@@ -574,6 +642,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams, teams,
contacts, contacts,
gallery, gallery,
categories,
total, total,
}; };
} catch (error) { } catch (error) {
@@ -589,6 +658,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
teams: [], teams: [],
contacts: [], contacts: [],
gallery: [], gallery: [],
categories: [],
total: 0, total: 0,
}; };
} }
+2 -2
View File
@@ -81,13 +81,13 @@ body.style-pack-modern .section-head h3::after { width: 64px; }
} }
/* News list */ /* News list */
[data-element="news"] .blog-list { [data-element="news"][data-variant="grid_two"] .blog-list {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--pack-gap-md); gap: var(--pack-gap-md);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
[data-element="news"] .blog-list { [data-element="news"][data-variant="grid_two"] .blog-list {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
} }
+71 -4
View File
@@ -95,6 +95,13 @@ func getPrefetchBaseURL() string {
return base 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) // GetMatchesHistory returns cached past matches with overrides applied (public)
// Optional query: q= filters by home/away/venue/competition // Optional query: q= filters by home/away/venue/competition
func (bc *BaseController) GetMatchesHistory(c *gin.Context) { func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
@@ -183,6 +190,7 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
// Optional search filter // Optional search filter
if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" { if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" {
sq := foldAccents(s)
filtered := make([]map[string]interface{}, 0, len(matches)) filtered := make([]map[string]interface{}, 0, len(matches))
for _, m := range matches { for _, m := range matches {
get := func(k string) string { get := func(k string) string {
@@ -199,7 +207,7 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
if f == "" { if f == "" {
continue continue
} }
if strings.Contains(strings.ToLower(f), s) { if strings.Contains(foldAccents(f), sq) {
matched = true matched = true
break break
} }
@@ -211,6 +219,7 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
matches = filtered matches = filtered
} }
// Respond with filtered/processed past matches
c.Header("Cache-Control", "public, max-age=60") c.Header("Cache-Control", "public, max-age=60")
c.JSON(http.StatusOK, matches) 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.Header("Cache-Control", "public, max-age=60")
c.JSON(http.StatusOK, matches) c.JSON(http.StatusOK, matches)
} }
@@ -318,6 +356,7 @@ func (bc *BaseController) GetMatches(c *gin.Context) {
func (bc *BaseController) GetStandings(c *gin.Context) { func (bc *BaseController) GetStandings(c *gin.Context) {
p := filepath.Join("cache", "prefetch", "facr_tables.json") p := filepath.Join("cache", "prefetch", "facr_tables.json")
f, err := os.Open(p) f, err := os.Open(p)
// ... (rest of the code remains the same)
if err != nil { if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached standings"}) c.JSON(http.StatusNoContent, gin.H{"message": "No cached standings"})
return return
@@ -3503,12 +3542,25 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays
} }
// Deployment base URLs
if body.FrontendBaseURL != nil { 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 { 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 { if s.ID == 0 {
@@ -3548,6 +3600,21 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(v) 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) 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-Credentials", "true")
} }
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") 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" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204) c.AbortWithStatus(204)