mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #77
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"items":[],"page":1,"page_size":10,"total":0}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
|
||||||
+1
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:39Z","last_modified":""}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:44Z","last_modified":""}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
null
|
||||||
Vendored
+244
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"lastUpdated":"2025-10-31T17:21:44Z"}
|
||||||
Vendored
+52
@@ -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"
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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":""}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
|
||||||
+1
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
{"etag":"","fetched_at":"2025-10-31T17:21:35Z","last_modified":""}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
@@ -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"}
|
||||||
Vendored
+102
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
null
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"fetched_at": "2025-10-31T15:21:51Z",
|
||||||
|
"link": ""
|
||||||
|
}
|
||||||
Vendored
+1076
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -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í (1–10)',
|
||||||
|
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 }))
|
||||||
|
}))}>1–10</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í (1–10)',
|
|
||||||
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 }))
|
|
||||||
}))}>1–10</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 1–2 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 e‑mail 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, 2–4 roky, 5+ let (11–14 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 || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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í' },
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user