dev day #90 🥳

This commit is contained in:
Tomas Dvorak
2025-11-12 20:31:37 +01:00
parent 8762bde4bf
commit f3db65d350
103 changed files with 4053 additions and 2189 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"items":[{"ID":1,"CreatedAt":"2025-11-09T12:01:17.497052Z","UpdatedAt":"2025-11-09T12:06:11.904927Z","DeletedAt":null,"title":"Historie a úspěchy FC Baník Ostrava","content":"\u003ch2\u003e\u003cspan style=\"color: rgb(230, 0, 0);\"\u003eHistorie a úspěchy FC Baník Ostrava\u003c/span\u003e\u003c/h2\u003e\u003cp\u003eFC Baník Ostrava je jeden z nejznámějších a nejúspěšnějších fotbalových klubů v České republice. Klub byl založen v roce 1922 a od té doby prošel mnoha změnami a úspěchy, které ho postavily na mapu českého fotbalu.\u003c/p\u003e\u003ch3\u003eZaložení a rané roky\u003c/h3\u003e\u003cp\u003eFC Baník Ostrava byl založen v roce 1922 pod názvem SK Slezská Ostrava. V roce 1945 se klub přejmenoval na Sokol Slezská Ostrava a v roce 1952 na Baník Ostrava. Tato změna názvu odrážela původ klubových hráčů, kteří byli zaměstnanci dolů v Ostravské pánvi.\u003c/p\u003e\u003ch3\u003eÚspěchy v československé éře\u003c/h3\u003e\u003cp\u003eV době Československa byl Baník Ostrava jedním z nejúspěšnějších klubů. Klub vyhrál ligový titul v sezóně 1975/76 a 1977/78. Tyto vítězství jsou dodnes považována za vrcholné momenty v historii klubu. Baník Ostrava také dvakrát vyhrál Československý pohár v letech 1973 a 1978.\u003c/p\u003e\u003ch3\u003eModerní éra\u003c/h3\u003e\u003cp\u003ePo rozpadě Československa pokračoval Baník Ostrava ve své úspěšné dráze. V sezóně 1990/91 vyhrál klub československý pohár, což bylo jeho poslední velké vítězství v samostatné éře. V české lize se Baník Ostrava několikrát umístil na pódiu, ale titul zůstal nedosahatelným cílem.\u003c/p\u003e\u003ch3\u003eHráči a trenéři\u003c/h3\u003e\u003cp\u003eMezi nejvýznamnější hráče v historii klubu patří Petr Johana, Libor Kozák a Petr Gabriel. V současnosti je trenérem klubu Radim Kučera, který se snaží vrátit klub zpět do vrcholové ligy.\u003c/p\u003e\u003ch3\u003eBudoucnost klubu\u003c/h3\u003e\u003cp\u003eFC Baník Ostrava se i nadále snaží o návrat do elitní ligy a získání dalších trofejí. Klub má silnou podporu fanoušků a ambiciózní plány na budoucnost. S novými investicemi a talentovanými hráči může být Baník Ostrava opět jedním z nejlepších týmů v České republice.\u003c/p\u003e","author_id":1,"author":{"ID":1,"CreatedAt":"2025-11-09T10:47:29.393135Z","UpdatedAt":"2025-11-09T10:47:48.678204Z","DeletedAt":null,"email":"contact.dvorak@gmail.com","first_name":"Tomas","last_name":"Dvorak","role":"admin","IsActive":true,"last_login":"2025-11-09T10:47:48.677897Z"},"category_id":2,"category":{"ID":2,"CreatedAt":"2025-11-09T12:02:46.113166Z","UpdatedAt":"2025-11-09T12:02:46.113166Z","DeletedAt":null,"name":"PC U1E U-10 Šumperk","description":"","slug":"pc-u1e-u-10-sumperk"},"image_url":"https://eu.zonerama.com/photos/576619562_1500x1000.jpg","published":true,"published_at":"2025-11-09T12:06:11.90135Z","slug":"historie-uspechy-fc-banik-ostrava","excerpt":"","featured":true,"seo_title":"Historie a úspěchy FC Baník Ostrava | Fotbalový klub Krnov","seo_description":"Historie a úspěchy FC Baník Ostrava FC Baník Ostrava je jeden z nejznámějších a nejúspěšnějších fotbalových klubů v České republice. Klub byl založen v roce...","og_image_url":"https://eu.zonerama.com/photos/576619562_1500x1000.jpg","external_link":"","view_count":0,"read_time":2,"unique_views":0,"category_name":"","attachments":"[{\"name\":\"5JjxvmK.jpg\",\"url\":\"/uploads/upload_1762689934_82a47c3d8d60f247.jpg\",\"mime_type\":\"image/jpeg\",\"size\":28203}]","gallery_album_id":"","gallery_album_url":"","gallery_photo_ids":"","youtube_video_id":"nGv61kag-9I","youtube_video_title":"Bizoni UH-Helas Brno\\","youtube_video_url":"https://www.youtube.com/watch?v=nGv61kag-9I","youtube_video_thumbnail":"https://img.youtube.com/vi/nGv61kag-9I/maxresdefault.jpg","match_link":null,"category_slug":"pc-u1e-u-10-sumperk","competition_alias":"PC U1E U-10 Šumperk","normalized_category":"pc u1e u-10 sumperk","url":"/news/historie-uspechy-fc-banik-ostrava"}],"page":1,"page_size":10,"total":1}
{"items":[],"page":1,"page_size":10,"total":0}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"A1A","alias":"Muži A","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":"U19","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":"U17","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}]
[{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0},{"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":0}]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[{"id":1,"created_at":"2025-11-09T12:10:25.543277Z","updated_at":"2025-11-09T12:10:45.039163Z","title":"Přátelský zápas Fotbalového klubu Krnov","description":"\u003cp\u003eFanoušci Fotbalového klubu Krnov mají možnost se zúčastnit přátelského zápasu, který připravil klub pro své příznivce. Tato událost představuje skvělou příležitost, jak podpořit tým a zažít atmosféru fotbalového utkání.Přátelské zápasy jsou důležitou součástí přípravy týmu, během nichž hráči mají možnost procvičit si nové taktiky a zlepšit svou formu. Pro fanoušky je to také příležitost vidět své oblíbené hráče v akci a podpořit je v neformálním prostředí.Přidejte se k nám a užijte si den plný fotbalového nadšení a dobré nálady. Více informací o termínu a místě konání bude zveřejněno v blízké době.\u003c/p\u003e","start_time":"2025-11-10T08:00:00Z","end_time":"2025-11-10T10:00:00Z","location":"Petrovická, Krnov, 794 01","type":"training","category_name":"U17","is_public":true,"created_by_id":1,"created_by":{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"email":"","first_name":"","last_name":"","role":"","IsActive":false},"image_url":"/uploads/upload_1762690234_9b05a55a8e838606.png","file_url":"","attachments":[{"id":2,"created_at":"2025-11-09T12:10:45.048076Z","updated_at":"2025-11-09T12:10:45.048076Z","event_id":1,"name":"club_logo_filename_20221010_083916.png","url":"/uploads/upload_1762690236_dcd845524f468432.png","mime_type":"image/png","size":39233}],"youtube_url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs","latitude":50.0948669,"longitude":17.7001456}]
[]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-09T13:55:44Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:19Z","last_modified":""}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:26Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:23Z","last_modified":""}
-11
View File
@@ -1,15 +1,4 @@
[
{
"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",
-101
View File
@@ -1,101 +0,0 @@
[
{
"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": "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": "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-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-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": "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": "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"
}
]
+1 -1
View File
@@ -1 +1 @@
{"lastUpdated":"2025-11-09T13:55:44Z"}
{"lastUpdated":"2025-11-12T19:22:23Z"}
+12 -12
View File
@@ -1,7 +1,17 @@
{
"baseURL": "http://localhost:8080/api/v1",
"duration_ms": 67,
"duration_ms": 5950,
"endpoints": [
{
"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",
@@ -27,16 +37,6 @@
"file": "articles.json",
"ok": true
},
{
"path": "/sponsors",
"file": "sponsors.json",
"ok": true
},
{
"path": "/events/upcoming",
"file": "events_upcoming.json",
"ok": true
},
{
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
"file": "facr_club_info.json",
@@ -48,5 +48,5 @@
"ok": true
}
],
"lastUpdated": "2025-11-09T13:55:44Z"
"lastUpdated": "2025-11-12T19:22:23Z"
}
+1 -1
View File
@@ -1 +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":""}
{"additional_meta":"","canonical_base_url":"","default_og_image_url":"","enable_indexing":false,"meta_keywords":"","site_description":"","site_title":"","twitter_handle":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +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":"Petrovická","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.0948669,"location_longitude":17.7001456,"map_style":"voyager","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","premium":false,"primary_color":"#ffd500","secondary_color":"#0033ff","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/nGv61kag-9I/maxresdefault.jpg","title":"Bizoni UH-Helas Brno\\","uploaded_at":"2025-11-08","url":"https://www.youtube.com/watch?v=nGv61kag-9I"},{"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-12","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-12","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-09","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-09","url":"https://www.youtube.com/watch?v=ozH8xE7V458"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","youtube_url":"https://www.youtube.com/@FCBizoniUH"}
{"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":"Petrovická","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.0948669,"location_longitude":17.7001456,"map_style":"voyager","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","premium":false,"primary_color":"#ffdd00","secondary_color":"#002aff","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/vklbT4csWQ0/maxresdefault.jpg","title":"Bizoni UH-Jeseník 11:3/5:2/-5.kolo 2. futsal ligy-11.11.25 v UH","uploaded_at":"2025-11-12","url":"https://www.youtube.com/watch?v=vklbT4csWQ0"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/nGv61kag-9I/maxresdefault.jpg","title":"Bizoni UH-Helas Brno\\","uploaded_at":"2025-11-08","url":"https://www.youtube.com/watch?v=nGv61kag-9I"},{"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-12","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-12","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-12","url":"https://www.youtube.com/watch?v=h_-TS6oVvKA"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","videos_title_overrides":{},"youtube_url":"https://www.youtube.com/@FCBizoniUH"}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[{"ID":1,"CreatedAt":"2025-11-09T12:27:21.320276Z","UpdatedAt":"2025-11-09T12:27:21.320276Z","DeletedAt":null,"name":"jskgjsglkesgj","logo_url":"/uploads/upload_1762691233_4b0c3ef4cbb3f521.png","website_url":"jlgjslkgjeslkgjslkeg.conged","description":"","is_active":true,"tier":"standard","display_order":0,"placement":"","width":0,"height":0}]
[]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"by_id":{"0c83e0d2-dafb-48e3-9326-ce1bc44c52a8":{"logo_url":"http://logoapi.sportcreative.eu/logos/0c83e0d2-dafb-48e3-9326-ce1bc44c52a8?format=png","name":"SK Hranice"},"298adc0a-b0c9-4796-9999-5754825d0a28":{"logo_url":"https://is1.fotbal.cz/media/kluby/298adc0a-b0c9-4796-9999-5754825d0a28/298adc0a-b0c9-4796-9999-5754825d0a28_crop.jpg","name":"Školní sportovní klub Bílovec"},"35e4f595-f2a7-4c0c-abd7-73926f33d687":{"logo_url":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","name":"1.BFK Frýdlant nad Ostravicí"},"831702b0-cf90-4d94-9878-b1389b6a72b4":{"logo_url":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","name":"SK Beskyd Frenštát pod Radhoštěm"}},"by_name":{"1.BFK Frýdlant nad Ostravicí":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","SK Beskyd Frenštát pod Radhoštěm":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","SK Hranice":"http://logoapi.sportcreative.eu/logos/0c83e0d2-dafb-48e3-9326-ce1bc44c52a8?format=png","Školní sportovní klub Bílovec":"https://is1.fotbal.cz/media/kluby/298adc0a-b0c9-4796-9999-5754825d0a28/298adc0a-b0c9-4796-9999-5754825d0a28_crop.jpg"}}
{"by_id":{"35e4f595-f2a7-4c0c-abd7-73926f33d687":{"logo_url":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","name":"1.BFK Frýdlant nad Ostravicí"},"831702b0-cf90-4d94-9878-b1389b6a72b4":{"logo_url":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","name":"SK Beskyd Frenštát pod Radhoštěm"},"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":{"1.BFK Frýdlant nad Ostravicí":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","1.bfk frydlant nad ostravici":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","1BFK Frýdlant nad Ostravicí":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","SK Beskyd Frenštát pod Radhoštěm":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","Spolek SK Brušperk":"http://logoapi.sportcreative.eu/logos/eb9e21fd-42a0-4ff5-b253-a028343da896?format=png","sk beskyd frenstat pod radhostem":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","spolek sk brusperk":"http://logoapi.sportcreative.eu/logos/eb9e21fd-42a0-4ff5-b253-a028343da896?format=png"}}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"fetched_at":"2025-11-09T10:47:36Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
{"fetched_at":"2025-11-12T16:22:23Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
-11
View File
@@ -1,11 +0,0 @@
[
{
"id": "576619562",
"album_id": "14130762",
"album_url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130762",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14130762/576619562",
"image_url": "https://eu.zonerama.com/photos/576619562_1500x1000.jpg",
"title": "Kategorie U15 Hranice 5:1 FK Krnov",
"picked_at": "2025-11-09T12:05:12Z"
}
]
+10 -10
View File
@@ -7,7 +7,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -17,7 +17,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -27,7 +27,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -37,7 +37,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -47,7 +47,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -57,7 +57,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -67,7 +67,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -77,7 +77,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -87,7 +87,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -97,6 +97,6 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
{
"fetched_at": "2025-11-09T10:47:48Z",
"fetched_at": "2025-11-12T16:22:38Z",
"link": ""
}
+219 -219
View File
@@ -108,7 +108,7 @@
"photos_count": 108,
"title": "Kategorie U15 Hranice 5:1 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130762",
"views_count": 63
"views_count": 83
},
{
"date": "2. 11. 2025",
@@ -213,7 +213,112 @@
"photos_count": 64,
"title": "Kategorie U14 Hranice 12:1 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130503",
"views_count": 56
"views_count": 93
},
{
"date": "28. 10. 2025",
"id": "14102334",
"photos": [
{
"id": "575240876",
"image_1500": "https://eu.zonerama.com/photos/575240876_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240876"
},
{
"id": "575240870",
"image_1500": "https://eu.zonerama.com/photos/575240870_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240870"
},
{
"id": "575240864",
"image_1500": "https://eu.zonerama.com/photos/575240864_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240864"
},
{
"id": "575240867",
"image_1500": "https://eu.zonerama.com/photos/575240867_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240867"
},
{
"id": "575240866",
"image_1500": "https://eu.zonerama.com/photos/575240866_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240866"
},
{
"id": "575240865",
"image_1500": "https://eu.zonerama.com/photos/575240865_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240865"
},
{
"id": "575240861",
"image_1500": "https://eu.zonerama.com/photos/575240861_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240861"
},
{
"id": "575240860",
"image_1500": "https://eu.zonerama.com/photos/575240860_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240860"
},
{
"id": "575240846",
"image_1500": "https://eu.zonerama.com/photos/575240846_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240846"
},
{
"id": "575240854",
"image_1500": "https://eu.zonerama.com/photos/575240854_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240854"
},
{
"id": "575240845",
"image_1500": "https://eu.zonerama.com/photos/575240845_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240845"
},
{
"id": "575240843",
"image_1500": "https://eu.zonerama.com/photos/575240843_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240843"
},
{
"id": "575240851",
"image_1500": "https://eu.zonerama.com/photos/575240851_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240851"
},
{
"id": "575240842",
"image_1500": "https://eu.zonerama.com/photos/575240842_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240842"
},
{
"id": "575240844",
"image_1500": "https://eu.zonerama.com/photos/575240844_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240844"
},
{
"id": "575240833",
"image_1500": "https://eu.zonerama.com/photos/575240833_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240833"
},
{
"id": "575240829",
"image_1500": "https://eu.zonerama.com/photos/575240829_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240829"
},
{
"id": "575240836",
"image_1500": "https://eu.zonerama.com/photos/575240836_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240836"
},
{
"id": "575240823",
"image_1500": "https://eu.zonerama.com/photos/575240823_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240823"
}
],
"photos_count": 122,
"title": "Kategorie U15 FK Krnov 3:2 Poruba - Petřvald",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102334",
"views_count": 104
},
{
"date": "28. 10. 2025",
@@ -318,7 +423,7 @@
"photos_count": 81,
"title": "Kategorie muži FK Krnov 1:2 Slavia Orlová",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102134",
"views_count": 116
"views_count": 126
},
{
"date": "28. 10. 2025",
@@ -453,112 +558,7 @@
"photos_count": 38,
"title": "Kategorie U14 FK Krnov 1:9 Poruba - Petřvald",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14101976",
"views_count": 100
},
{
"date": "28. 10. 2025",
"id": "14102334",
"photos": [
{
"id": "575240876",
"image_1500": "https://eu.zonerama.com/photos/575240876_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240876"
},
{
"id": "575240870",
"image_1500": "https://eu.zonerama.com/photos/575240870_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240870"
},
{
"id": "575240864",
"image_1500": "https://eu.zonerama.com/photos/575240864_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240864"
},
{
"id": "575240867",
"image_1500": "https://eu.zonerama.com/photos/575240867_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240867"
},
{
"id": "575240866",
"image_1500": "https://eu.zonerama.com/photos/575240866_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240866"
},
{
"id": "575240865",
"image_1500": "https://eu.zonerama.com/photos/575240865_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240865"
},
{
"id": "575240861",
"image_1500": "https://eu.zonerama.com/photos/575240861_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240861"
},
{
"id": "575240860",
"image_1500": "https://eu.zonerama.com/photos/575240860_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240860"
},
{
"id": "575240846",
"image_1500": "https://eu.zonerama.com/photos/575240846_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240846"
},
{
"id": "575240854",
"image_1500": "https://eu.zonerama.com/photos/575240854_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240854"
},
{
"id": "575240845",
"image_1500": "https://eu.zonerama.com/photos/575240845_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240845"
},
{
"id": "575240843",
"image_1500": "https://eu.zonerama.com/photos/575240843_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240843"
},
{
"id": "575240851",
"image_1500": "https://eu.zonerama.com/photos/575240851_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240851"
},
{
"id": "575240842",
"image_1500": "https://eu.zonerama.com/photos/575240842_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240842"
},
{
"id": "575240844",
"image_1500": "https://eu.zonerama.com/photos/575240844_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240844"
},
{
"id": "575240833",
"image_1500": "https://eu.zonerama.com/photos/575240833_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240833"
},
{
"id": "575240829",
"image_1500": "https://eu.zonerama.com/photos/575240829_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240829"
},
{
"id": "575240836",
"image_1500": "https://eu.zonerama.com/photos/575240836_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240836"
},
{
"id": "575240823",
"image_1500": "https://eu.zonerama.com/photos/575240823_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240823"
}
],
"photos_count": 122,
"title": "Kategorie U15 FK Krnov 3:2 Poruba - Petřvald",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102334",
"views_count": 100
"views_count": 108
},
{
"date": "26. 10. 2025",
@@ -653,112 +653,7 @@
"photos_count": 76,
"title": "Kategorie muži FK Krnov 1:3 Frenštát p. Radhoštěm",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087623",
"views_count": 102
},
{
"date": "25. 10. 2025",
"id": "14087896",
"photos": [
{
"id": "574587784",
"image_1500": "https://eu.zonerama.com/photos/574587784_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587784"
},
{
"id": "574587775",
"image_1500": "https://eu.zonerama.com/photos/574587775_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587775"
},
{
"id": "574587774",
"image_1500": "https://eu.zonerama.com/photos/574587774_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587774"
},
{
"id": "574587771",
"image_1500": "https://eu.zonerama.com/photos/574587771_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587771"
},
{
"id": "574587772",
"image_1500": "https://eu.zonerama.com/photos/574587772_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587772"
},
{
"id": "574587761",
"image_1500": "https://eu.zonerama.com/photos/574587761_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587761"
},
{
"id": "574587756",
"image_1500": "https://eu.zonerama.com/photos/574587756_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587756"
},
{
"id": "574587737",
"image_1500": "https://eu.zonerama.com/photos/574587737_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587737"
},
{
"id": "574587732",
"image_1500": "https://eu.zonerama.com/photos/574587732_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587732"
},
{
"id": "574587740",
"image_1500": "https://eu.zonerama.com/photos/574587740_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587740"
},
{
"id": "574587742",
"image_1500": "https://eu.zonerama.com/photos/574587742_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587742"
},
{
"id": "574587731",
"image_1500": "https://eu.zonerama.com/photos/574587731_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587731"
},
{
"id": "574587730",
"image_1500": "https://eu.zonerama.com/photos/574587730_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587730"
},
{
"id": "574587733",
"image_1500": "https://eu.zonerama.com/photos/574587733_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587733"
},
{
"id": "574587717",
"image_1500": "https://eu.zonerama.com/photos/574587717_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587717"
},
{
"id": "574587718",
"image_1500": "https://eu.zonerama.com/photos/574587718_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587718"
},
{
"id": "574587719",
"image_1500": "https://eu.zonerama.com/photos/574587719_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587719"
},
{
"id": "574587700",
"image_1500": "https://eu.zonerama.com/photos/574587700_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587700"
},
{
"id": "574587701",
"image_1500": "https://eu.zonerama.com/photos/574587701_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587701"
}
],
"photos_count": 65,
"title": "Kategorie U15 FK Krnov 1:2 Třinec",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087896",
"views_count": 60
"views_count": 107
},
{
"date": "25. 10. 2025",
@@ -863,7 +758,112 @@
"photos_count": 52,
"title": "Kategorie U14 FK Krnov 0:10 Třinec",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087590",
"views_count": 63
"views_count": 67
},
{
"date": "25. 10. 2025",
"id": "14087896",
"photos": [
{
"id": "574587784",
"image_1500": "https://eu.zonerama.com/photos/574587784_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587784"
},
{
"id": "574587775",
"image_1500": "https://eu.zonerama.com/photos/574587775_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587775"
},
{
"id": "574587774",
"image_1500": "https://eu.zonerama.com/photos/574587774_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587774"
},
{
"id": "574587771",
"image_1500": "https://eu.zonerama.com/photos/574587771_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587771"
},
{
"id": "574587772",
"image_1500": "https://eu.zonerama.com/photos/574587772_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587772"
},
{
"id": "574587761",
"image_1500": "https://eu.zonerama.com/photos/574587761_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587761"
},
{
"id": "574587756",
"image_1500": "https://eu.zonerama.com/photos/574587756_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587756"
},
{
"id": "574587737",
"image_1500": "https://eu.zonerama.com/photos/574587737_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587737"
},
{
"id": "574587732",
"image_1500": "https://eu.zonerama.com/photos/574587732_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587732"
},
{
"id": "574587740",
"image_1500": "https://eu.zonerama.com/photos/574587740_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587740"
},
{
"id": "574587742",
"image_1500": "https://eu.zonerama.com/photos/574587742_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587742"
},
{
"id": "574587731",
"image_1500": "https://eu.zonerama.com/photos/574587731_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587731"
},
{
"id": "574587730",
"image_1500": "https://eu.zonerama.com/photos/574587730_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587730"
},
{
"id": "574587733",
"image_1500": "https://eu.zonerama.com/photos/574587733_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587733"
},
{
"id": "574587717",
"image_1500": "https://eu.zonerama.com/photos/574587717_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587717"
},
{
"id": "574587718",
"image_1500": "https://eu.zonerama.com/photos/574587718_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587718"
},
{
"id": "574587719",
"image_1500": "https://eu.zonerama.com/photos/574587719_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587719"
},
{
"id": "574587700",
"image_1500": "https://eu.zonerama.com/photos/574587700_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587700"
},
{
"id": "574587701",
"image_1500": "https://eu.zonerama.com/photos/574587701_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587701"
}
],
"photos_count": 65,
"title": "Kategorie U15 FK Krnov 1:2 Třinec",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087896",
"views_count": 65
},
{
"date": "18. 10. 2025",
@@ -968,7 +968,7 @@
"photos_count": 75,
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
"views_count": 115
"views_count": 118
},
{
"date": "12. 10. 2025",
@@ -1073,9 +1073,9 @@
"photos_count": 112,
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
"views_count": 214
"views_count": 215
}
],
"fetched_at": "2025-11-09T10:47:48Z",
"fetched_at": "2025-11-12T16:22:38Z",
"input_link": "https://eu.zonerama.com/FKKofolaKrnov/1470757"
}
+29
View File
@@ -0,0 +1,29 @@
# Project Diagrams
This folder contains Mermaid diagrams for the project:
- ER Diagram of the database schema
- System Architecture (frontend ↔ backend ↔ integrations)
- Admin Module Map (grouped by navigation categories)
- Frontpage Data Map (sections → data sources)
## Recommended extensions (VS Code)
- Markdown Preview Mermaid Support (ID: bpruitt-goddard.vscode-mermaid-preview)
- Alternative: Markdown Preview Enhanced (ID: shd101wyy.markdown-preview-enhanced)
## How to preview
1) Install one of the extensions above.
2) Open any .md file here (e.g., er-diagram.md).
3) Press Ctrl+Shift+V (or Right click → Open Preview / Open Preview to the Side).
4) If prompted to allow scripts for Mermaid, accept.
## Files
- er-diagram.md — ER diagram of DB entities and relationships
- system-architecture.md — high-level system flow
- admin-map.md — map of admin sections
- frontpage-data-map.md — frontpage sections → data sources
## Optional: Export as images
- You can install Mermaid CLI to export to PNG/SVG: `npm i -g @mermaid-js/mermaid-cli`
- For Markdown files in this folder, run: `mmdc -i er-diagram.md -o er-diagram.svg --inputType markdown`
(If you extract the mermaid code to a standalone .mmd file, you can omit `--inputType`.)
+61
View File
@@ -0,0 +1,61 @@
# Admin Module Map
```mermaid
graph LR
subgraph Zakladni
ADASH[Nastenka]
AANALYT[Analytika]
end
subgraph Sport
TEAMS[Tymy]
MATCHES[Zapasy]
PLAYERS[Hraci]
ALIASES[Alias_soutezi]
SCORE[Tabule_Scoreboard]
SCORE_R[Scoreboard_Remote]
end
subgraph Obsah
ARTICLES[Clanky]
ACTIVITIES[Aktivity]
CATEGORIES[Kategorie]
COMMENTS[Komentare]
end
subgraph Media
VIDEOS[Videa]
GALLERY[Galerie]
FILES[Soubory]
BANNERS[Bannery]
end
subgraph Komunikace
MSGS[Zpravy]
NEWSLTR[Zpravodaj]
CONTACTS[Kontakty]
end
subgraph Marketing
SPONSORS[Sponzori]
MERCH[Obleceni]
POLLS[Ankety]
SWEEP[Souteze]
ENGAGE[Odmeny_a_Uspechy]
SHORT[Zkracene_odkazy]
end
subgraph Nastroje
PREFETCH[Prefetch_a_Cache]
ERRORS[Chyby]
DOCS[Dokumentace]
end
subgraph Nastaveni
SETTINGS[Nastaveni]
USERS[Uzivatele]
NAV[Navigace]
ABOUT[O_klubu]
end
ADASH --> Sport
ADASH --> Obsah
ADASH --> Media
ADASH --> Komunikace
ADASH --> Marketing
ADASH --> Nastroje
ADASH --> Nastaveni
```
+57
View File
@@ -0,0 +1,57 @@
graph LR
subgraph Zakladni
ADASH[Nastenka]
AANALYT[Analytika]
end
subgraph Sport
TEAMS[Tymy]
MATCHES[Zapasy]
PLAYERS[Hraci]
ALIASES[Alias_soutezi]
SCORE[Tabule_Scoreboard]
SCORE_R[Scoreboard_Remote]
end
subgraph Obsah
ARTICLES[Clanky]
ACTIVITIES[Aktivity]
CATEGORIES[Kategorie]
COMMENTS[Komentare]
end
subgraph Media
VIDEOS[Videa]
GALLERY[Galerie]
FILES[Soubory]
BANNERS[Bannery]
end
subgraph Komunikace
MSGS[Zpravy]
NEWSLTR[Zpravodaj]
CONTACTS[Kontakty]
end
subgraph Marketing
SPONSORS[Sponzori]
MERCH[Obleceni]
POLLS[Ankety]
SWEEP[Souteze]
ENGAGE[Odmeny_a_Uspechy]
SHORT[Zkracene_odkazy]
end
subgraph Nastroje
PREFETCH[Prefetch_a_Cache]
ERRORS[Chyby]
DOCS[Dokumentace]
end
subgraph Nastaveni
SETTINGS[Nastaveni]
USERS[Uzivatele]
NAV[Navigace]
ABOUT[O_klubu]
end
ADASH --> Sport
ADASH --> Obsah
ADASH --> Media
ADASH --> Komunikace
ADASH --> Marketing
ADASH --> Nastroje
ADASH --> Nastaveni
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

+37
View File
@@ -0,0 +1,37 @@
graph LR
subgraph Backend
Router[API Router /api/v1]
Middleware[Middleware JWT RateLimit CORS Gzip Recovery]
Controllers[Controllers]
Services[Services]
Models[Models GORM]
DB[PostgreSQL]
Migrations[Migrations]
Jobs[Background jobs Prefetcher Newsletter]
Uploads[uploads static dist]
end
subgraph Integrations
FACR[FACR API]
YT[YouTube API]
ZON[Zonerama]
SMTP[SMTP Email]
MAPS[Google Maps]
UMAMI[Umami Analytics]
end
Router --> Middleware
Router --> Controllers
Controllers --> Services
Services --> Models
Models --> DB
Migrations --> DB
Jobs --> Services
Jobs --> DB
Controllers --> Uploads
Controllers --> FACR
Controllers --> YT
Controllers --> ZON
Controllers --> SMTP
Controllers --> MAPS
Controllers -. telemetry .-> UMAMI
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

+79
View File
@@ -0,0 +1,79 @@
# ER Diagram
```mermaid
erDiagram
USERS ||--o{ ARTICLES : author_id
CATEGORIES ||--o{ ARTICLES : category_id
ARTICLES ||--o{ ARTICLE_TEAM_LINKS : article_id
ARTICLES ||--o{ ARTICLE_MATCH_LINKS : article_id
TEAMS ||--o{ PLAYERS : team_id
USERS ||--o{ EVENTS : created_by_id
EVENTS ||--o{ EVENT_ATTACHMENTS : event_id
POLLS ||--o{ POLL_OPTIONS : poll_id
POLLS ||--o{ POLL_VOTES : poll_id
POLL_OPTIONS ||--o{ POLL_VOTES : option_id
USERS o{--o| POLL_VOTES : user_id
CATEGORIES o{--o| POLLS : category_id
ARTICLES o{--o| POLLS : related_article_id
EVENTS o{--o| POLLS : related_event_id
PLAYERS o{--o| POLL_OPTIONS : player_id
USERS ||--|| USER_PROFILES : user_id
USERS ||--o{ PASSWORD_RESETS : user_id
USERS ||--o{ COMMENTS : user_id
COMMENTS o{--o| COMMENTS : parent_id
USERS ||--o{ COMMENT_BANS : user_id
USERS ||--o{ UNBAN_REQUESTS : user_id
COMMENTS ||--o{ COMMENT_REACTIONS : comment_id
COMMENTS ||--o{ COMMENT_REPORTS : comment_id
USERS o{--o| UPLOADED_FILES : uploaded_by_id
UPLOADED_FILES ||--o{ FILE_USAGES : file_id
CONTACT_CATEGORIES o{--o| CONTACTS : category_id
SHORT_LINKS o{--o| LINK_CLICKS : short_link_id
USERS o{--o| SHORT_LINKS : created_by_id
EMAIL_LOGS ||--o{ EMAIL_EVENTS : email_log_id
ARTICLES ||--o{ BLOG_NOTIFICATIONS : article_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_PRIZES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_ENTRIES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_WINNERS : sweepstake_id
USERS ||--o{ SWEEPSTAKE_ENTRIES : user_id
USERS ||--o{ SWEEPSTAKE_WINNERS : user_id
SWEEPSTAKE_ENTRIES ||--o{ SWEEPSTAKE_WINNERS : entry_id
SWEEPSTAKE_PRIZES |o--o{ SWEEPSTAKE_WINNERS : prize_id
USERS ||--o{ POINTS_TRANSACTIONS : user_id
USERS ||--o{ USER_ACHIEVEMENTS : user_id
ACHIEVEMENTS ||--o{ USER_ACHIEVEMENTS : achievement_id
REWARD_ITEMS ||--o{ REWARD_REDEMPTIONS : reward_id
USERS ||--o{ REWARD_REDEMPTIONS : user_id
USERS o{--o| AUDIT_LOGS : user_id
USERS o{--o| ERROR_EVENTS : user_id
USERS o{--o| VISITOR_EVENTS : user_id
SETUP_INFO ||--o{ CLUB_INFO : setup_info_id
NAVIGATION_ITEMS o{--o| NAVIGATION_ITEMS : parent_id
%% Standalone/core tables (configured/consumed by services)
SETTINGS
ABOUT_PAGES
SPONSORS
BANNERS
CLOTHING
COMPETITION_ALIASES
MATCH_OVERRIDES
TEAM_LOGO_OVERRIDES
NEWSLETTER_SUBSCRIPTIONS
NEWSLETTER_SENT_LOG
MATCH_NOTIFICATIONS
SCOREBOARD_STATES
```
+75
View File
@@ -0,0 +1,75 @@
erDiagram
USERS ||--o{ ARTICLES : author_id
CATEGORIES ||--o{ ARTICLES : category_id
ARTICLES ||--o{ ARTICLE_TEAM_LINKS : article_id
ARTICLES ||--o{ ARTICLE_MATCH_LINKS : article_id
TEAMS ||--o{ PLAYERS : team_id
USERS ||--o{ EVENTS : created_by_id
EVENTS ||--o{ EVENT_ATTACHMENTS : event_id
POLLS ||--o{ POLL_OPTIONS : poll_id
POLLS ||--o{ POLL_VOTES : poll_id
POLL_OPTIONS ||--o{ POLL_VOTES : option_id
USERS |o--o{ POLL_VOTES : user_id
CATEGORIES |o--o{ POLLS : category_id
ARTICLES |o--o{ POLLS : related_article_id
EVENTS |o--o{ POLLS : related_event_id
PLAYERS |o--o{ POLL_OPTIONS : player_id
USERS ||--|| USER_PROFILES : user_id
USERS ||--o{ PASSWORD_RESETS : user_id
USERS ||--o{ COMMENTS : user_id
COMMENTS |o--o{ COMMENTS : parent_id
USERS ||--o{ COMMENT_BANS : user_id
USERS ||--o{ UNBAN_REQUESTS : user_id
COMMENTS ||--o{ COMMENT_REACTIONS : comment_id
COMMENTS ||--o{ COMMENT_REPORTS : comment_id
USERS |o--o{ UPLOADED_FILES : uploaded_by_id
UPLOADED_FILES ||--o{ FILE_USAGES : file_id
CONTACT_CATEGORIES |o--o{ CONTACTS : category_id
SHORT_LINKS |o--o{ LINK_CLICKS : short_link_id
USERS |o--o{ SHORT_LINKS : created_by_id
EMAIL_LOGS ||--o{ EMAIL_EVENTS : email_log_id
ARTICLES ||--o{ BLOG_NOTIFICATIONS : article_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_PRIZES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_ENTRIES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_WINNERS : sweepstake_id
USERS ||--o{ SWEEPSTAKE_ENTRIES : user_id
USERS ||--o{ SWEEPSTAKE_WINNERS : user_id
SWEEPSTAKE_ENTRIES ||--o{ SWEEPSTAKE_WINNERS : entry_id
SWEEPSTAKE_PRIZES |o--o{ SWEEPSTAKE_WINNERS : prize_id
USERS ||--o{ POINTS_TRANSACTIONS : user_id
USERS ||--o{ USER_ACHIEVEMENTS : user_id
ACHIEVEMENTS ||--o{ USER_ACHIEVEMENTS : achievement_id
REWARD_ITEMS ||--o{ REWARD_REDEMPTIONS : reward_id
USERS ||--o{ REWARD_REDEMPTIONS : user_id
USERS |o--o{ AUDIT_LOGS : user_id
USERS |o--o{ ERROR_EVENTS : user_id
USERS |o--o{ VISITOR_EVENTS : user_id
SETUP_INFO ||--o{ CLUB_INFO : setup_info_id
NAVIGATION_ITEMS |o--o{ NAVIGATION_ITEMS : parent_id
%% Standalone/core tables (configured/consumed by services)
SETTINGS
ABOUT_PAGES
SPONSORS
BANNERS
CLOTHING
COMPETITION_ALIASES
MATCH_OVERRIDES
TEAM_LOGO_OVERRIDES
NEWSLETTER_SUBSCRIPTIONS
NEWSLETTER_SENT_LOG
MATCH_NOTIFICATIONS
SCOREBOARD_STATES
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 91 KiB

+72
View File
@@ -0,0 +1,72 @@
# Frontpage Data Map
```mermaid
graph TB
Home[Homepage sections]
News[News (Articles)]
Matches[Upcoming & recent matches]
TableSec[Standings / Tables]
Activities[Activities (Events)]
GallerySec[Gallery]
VideosSec[Videos]
PlayersSec[Players]
SponsorsSec[Sponsors]
MerchSec[Merch]
PollsSec[Polls]
MapSec[Club Map]
NewsletterSec[Newsletter box]
BannersSec[Banners]
Home --> News
Home --> Matches
Home --> TableSec
Home --> Activities
Home --> GallerySec
Home --> VideosSec
Home --> PlayersSec
Home --> SponsorsSec
Home --> MerchSec
Home --> PollsSec
Home --> MapSec
Home --> NewsletterSec
Home --> BannersSec
ARTICLES["Articles"]
FACR_API["FACR API"]
MATCH_OVERRIDES["Match overrides"]
COMPETITION_ALIASES["Competition aliases"]
EVENTS["Events"]
SETTINGS["Settings"]
ZONERAMA["Zonerama"]
YOUTUBE["YouTube"]
TEAMS["Teams"]
DB_PLAYERS["Players"]
SPONSORS["Sponsors"]
CLOTHING["Clothing"]
POLLS["Polls"]
POLL_OPTIONS["Poll options"]
GOOGLE_MAPS["Google Maps"]
NEWSLETTER_SUBSCRIPTIONS["Newsletter subscriptions"]
BANNERS["Banners"]
News -->|DB| ARTICLES
Matches -->|Source| FACR_API
Matches -->|Overrides| MATCH_OVERRIDES
TableSec -->|Source| FACR_API
TableSec -->|Aliases| COMPETITION_ALIASES
Activities -->|DB| EVENTS
GallerySec -->|Profile URL| SETTINGS
GallerySec --> ZONERAMA
VideosSec -->|Config| SETTINGS
VideosSec --> YOUTUBE
PlayersSec --> TEAMS
PlayersSec --> DB_PLAYERS
SponsorsSec -->|DB| SPONSORS
MerchSec -->|DB| CLOTHING
PollsSec -->|DB| POLLS
PollsSec --> POLL_OPTIONS
MapSec -->|lat/lng + style| SETTINGS
MapSec --> GOOGLE_MAPS
NewsletterSec -->|subscribe| NEWSLETTER_SUBSCRIPTIONS
BannersSec -->|DB| BANNERS
```
+68
View File
@@ -0,0 +1,68 @@
graph TB
Home[Homepage sections]
News[News Articles]
Matches[Upcoming and recent matches]
TableSec[Standings Tables]
Activities[Activities Events]
GallerySec[Gallery]
VideosSec[Videos]
PlayersSec[Players]
SponsorsSec[Sponsors]
MerchSec[Merch]
PollsSec[Polls]
MapSec[Club Map]
NewsletterSec[Newsletter]
BannersSec[Banners]
Home --> News
Home --> Matches
Home --> TableSec
Home --> Activities
Home --> GallerySec
Home --> VideosSec
Home --> PlayersSec
Home --> SponsorsSec
Home --> MerchSec
Home --> PollsSec
Home --> MapSec
Home --> NewsletterSec
Home --> BannersSec
ARTICLES[Articles]
FACR_API[FACR API]
MATCH_OVERRIDES[Match overrides]
COMPETITION_ALIASES[Competition aliases]
EVENTS[Events]
SETTINGS[Settings]
ZONERAMA[Zonerama]
YOUTUBE[YouTube]
TEAMS[Teams]
DB_PLAYERS[Players]
SPONSORS[Sponsors]
CLOTHING[Clothing]
POLLS[Polls]
POLL_OPTIONS[Poll options]
GOOGLE_MAPS[Google Maps]
NEWSLETTER_SUBSCRIPTIONS[Newsletter subscriptions]
BANNERS[Banners]
News -->|DB| ARTICLES
Matches -->|Source| FACR_API
Matches -->|Overrides| MATCH_OVERRIDES
TableSec -->|Source| FACR_API
TableSec -->|Aliases| COMPETITION_ALIASES
Activities -->|DB| EVENTS
GallerySec -->|Profile URL| SETTINGS
GallerySec --> ZONERAMA
VideosSec -->|Config| SETTINGS
VideosSec --> YOUTUBE
PlayersSec --> TEAMS
PlayersSec --> DB_PLAYERS
SponsorsSec -->|DB| SPONSORS
MerchSec -->|DB| CLOTHING
PollsSec -->|DB| POLLS
PollsSec --> POLL_OPTIONS
MapSec -->|lat lng and style| SETTINGS
MapSec --> GOOGLE_MAPS
NewsletterSec -->|subscribe| NEWSLETTER_SUBSCRIPTIONS
BannersSec -->|DB| BANNERS
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

+3
View File
@@ -0,0 +1,3 @@
{
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
}
+56
View File
@@ -0,0 +1,56 @@
# System Architecture
```mermaid
graph LR
subgraph Clients
A["Public site React SPA"]
B["Admin SPA"]
C["Scoreboard Overlay"]
end
subgraph Frontend
FE["React 18 + Chakra UI; Router + Query"]
end
subgraph Backend
BE["Go Gin REST API api v1; GORM services"]
JOBS["Background jobs; Prefetcher; Newsletter automation"]
end
subgraph Data
DB["PostgreSQL"]
UP["uploads static dist"]
end
subgraph Integrations_optional
FACR["FACR API"]
YT["YouTube API"]
ZON["Zonerama"]
SMTP["SMTP email"]
MAPS["Google Maps"]
UMAMI["Umami Analytics"]
end
A --> FE
B --> FE
C --> FE
FE -->|REST JSON| BE
FE -->|uploads static| UP
BE --> DB
BE --> UP
%% External calls
BE --> FACR
BE --> YT
BE --> ZON
BE --> SMTP
BE -. "telemetry" .-> UMAMI
BE --> MAPS
%% Jobs
JOBS --> BE
JOBS --> DB
JOBS --> SMTP
```
+52
View File
@@ -0,0 +1,52 @@
graph LR
subgraph Clients
A["Public site React SPA"]
B["Admin SPA"]
C["Scoreboard Overlay"]
end
subgraph Frontend
FE["React 18 + Chakra UI; Router + Query"]
end
subgraph Backend
BE["Go Gin REST API api v1; GORM services"]
JOBS["Background jobs; Prefetcher; Newsletter automation"]
end
subgraph Data
DB["PostgreSQL"]
UP["uploads static dist"]
end
subgraph Integrations_optional
FACR["FACR API"]
YT["YouTube API"]
ZON["Zonerama"]
SMTP["SMTP email"]
MAPS["Google Maps"]
UMAMI["Umami Analytics"]
end
A --> FE
B --> FE
C --> FE
FE -->|REST JSON| BE
FE -->|uploads static| UP
BE --> DB
BE --> UP
%% External calls
BE --> FACR
BE --> YT
BE --> ZON
BE --> SMTP
BE -. "telemetry" .-> UMAMI
BE --> MAPS
%% Jobs
JOBS --> BE
JOBS --> DB
JOBS --> SMTP
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

+300
View File
@@ -0,0 +1,300 @@
# System Issues Tracker
## Admin Dashboard
- [ ] **Nástěnka**
- Status: Fully working
- Priority: Low
- Notes: No issues detected
- [ ] **Analytika**
- Status: Fully working
- Priority: Low
- Notes: No issues detected
## Týmy
- [x] **Logo Overwrite Issue**
- Status: Fixed
- Priority: High
- Affected Teams:
- Frýdlant n. O.
- SK OLOMOUC SIGMA MŽ, z.s.
- Working Examples:
- Tělovýchovná jednota Sokol Kozmice, z.s.
- Error: No error, just doesn't update
- Environment: Admin interface
## Zápasy
- [x] **Match Editing**
- Status: Fixed
- Priority: High
- Issues:
- Cannot edit match places
- Cannot edit match dates
- Overwrites don't work
- Environment: Admin interface
## Hráči
- [x] **Age Display**
- Status: Fixed
- Priority: Medium
- Current: "33 roky"
- Should be: "33 let"
- Location: Player profile pages
- [x] **Active Player Filter**
- Status: Fixed
- Priority: High
- Issue: Inactive players still visible when "Pouze aktivní" is toggled
- URL: http://localhost:3000/players
- Expected: Should hide inactive players
## Alias soutěží
- [x] **Frontpage Order**
- Status: Fixed
- Priority: Medium
- Issue: Order changes not reflected on homepage
- Works on: Other pages
## Tabule / Scoreboard
- [ ] **Styling Issues**
- Status: Needs update
- Priority: Medium
- Issues:
- Different from myscore board
- Missing sponsor overlay style
## Články
- [x] **File Upload**
- Status: Fixed
- Priority: High
- Error:
```
Chyba při nahrávání Soubor "example.pptx":
Request failed with status code 400
```
- File Size: ~11MB
- Expected: Should handle files up to at least 20MB
## Frontpage - Blog Detail Page
### Layout Structure Issues
- [x] **Two-Column Layout**
- Left Column (wider):
- Main content
- Images
- Gallery
- Comments
- Right Column (narrower):
- Upcoming matches (5 max)
- Polls
- Additional files
### Missing Components
- [x] **Match Section**
- Location: Under main blog picture
- Should include:
- Club logos on each side
- Score or countdown in middle
- Place and date below
- Modal on click
- Club colors for each side
- [x] **Breadcrumbs**
- Location: Below reading time and publish date
- Missing completely
- [x] **Gallery Section**
- Should show:
- Mosaic of 5 pictures
- 2 smaller pictures on each side
- 1 larger picture in middle
- "Zobrazit celou galerii" button
- Connected to Zonerama album
- [x] **Uploaded Files**
- Location: Above comments section
- Currently missing
- [x] **Poll Section**
- Location: Above files section
- Currently missing
- Should show connected poll
## Blog List Page (/blog)
- [x] **Grid Layout**
- Status: Fixed
- Priority: Medium
- Issues:
- Inconsistent spacing
- Poor responsiveness
- [x] **Category Switcher**
- Status: Fixed
- Priority: High
- Expected: Should filter articles by category
- [x] **Grid Item Layout**
- Missing publish date
- Current elements:
- Left: Category
- Right: Read time
- Should add:
- Middle: Publish date
- Hover tooltips for better UX
## Rich Text Editor
- [ ] **Loading Issues**
- Status: Inconsistent
- Success Rate: ~50%
- Affected Pages:
- Articles
- Activities
- [x] **Toolbar Tools**
- Issue: Separate "Zrušit barvu" and "Zrušit pozadí" buttons
- Should be: Integrated into color picker
- [ ] **Console Errors**
- Error: `addRange(): The given range isn't in document`
- Issue: Toolbar disappears after changes
## Galerie
- [x] **Zonerama Sync**
- Status: Fixed
- Error:
```
Access to XMLHttpRequest at 'http://localhost:8080/api/v1/admin/gallery/refresh'
from origin 'http://localhost:3000' has been blocked by CORS policy
```
- Additional Error:
```
POST http://localhost:8080/api/v1/admin/gallery/refresh
415 (Unsupported Media Type)
```
- Expected: Should sync with Zonerama
## Soubory
- [x] **UI Improvements**
- Status: Fixed
- Verified:
- Success messages are in Czech
- "Vymazat vše" buttons present in "Nepoužívané" and "Duplicity" tabs
## Zpravodaj
- [x] **Auto-Enable Feature**
- Status: Fixed
- Expected: Should enable when ≥1 recipient
- Current: Shows "Vypnuto" even with 1 recipient
- [x] **Delivery Status**
- Status: Fixed
- Should show:
- What will be sent
- Exact send time
- More delivery details
- [ ] **Delivery Issues**
- Example:
- Time: 11. 11. 2025 11:53
- Subject: Vítejte v odběru
- Recipient: tdvorak_dev@proton.me
- Status: failed
## Kontakty
- [ ] **Missing Category**
- Status: Competition alias category missing
- Location: Category list
- [ ] **Missing Photos**
- Status: Not showing on frontpage
- Note: Visible in admin
## Bannery
- [ ] **Positioning**
- Status: Incorrect
- Issues:
- Sidebar floating
- "Banner v článcích" not visible
## MyUIbrix
- [ ] **Aktuality Section**
- Status: Showing wrong content
- Current: Shows all blogs
- Should: Show only non-primary blogs (primary should be in hero)
## Ankety
- [ ] **User Tracking**
- Status: Not working
- Issues:
- Logged-in admin shown as non-logged visitor
- Voting tracking incorrect
## Soutěže
- [ ] **Image Upload**
- Status: Not working
- Element: "Titulní obrázek"
- [ ] **Registration**
- Status: Not working
- Issue: No action on "Vstoupit" button
- [ ] **UI/UX**
- Status: Needs improvement
- Suggestions:
- Add tab system for managing winnings
- Improve overall layout
## Odměny a úspěchy
- [ ] **UI/UX**
- Status: Needs improvement
- Issues:
- Overly complex
- Needs date pickers for "Platnost od/do"
- Suggested Changes:
- Remove "Dávkové vytvoření"
- Simplify interface
## Zkrácené odkazy
- [ ] **Link Generation**
- Status: Predictable pattern
- Current: Always uses "ig-share" as first link
- Should: Use random strings
- Example: `http://localhost:8080/s/randomstring123`
## Uživatelé
- [ ] **Role Permissions**
- Status: Too restrictive
- Issue: 403 errors in admin interface
- Needed: Define proper permission levels
## Navigace
- [ ] **Hidden Elements**
- Status: Loses data on refresh
- Issues:
- Shows "#" instead of element name
- Cannot restore hidden elements
- [ ] **Drag and Drop**
- Status: Limited functionality
- Issue: Can't drag subcategories between main categories
- Current: Only works within same category
## Working Correctly
- Aktivity
- Kategorie (marked for removal)
- Videa
- Oblečení
- Prefetch/Fetch
- Nastavení
- Odhlášení
## Testing Notes
- Test Environment: Local development
- Frontend: http://localhost:3000
- Backend: http://localhost:8080
- Browser: [Specify if known]
## Priority Classification
- **High**: Critical functionality issues
- **Medium**: Important but not blocking
- **Low**: Cosmetic or minor issues
+10 -4
View File
@@ -86,7 +86,6 @@ const PrivacyPolicyPage = lazy(() => import('./pages/legal/PrivacyPolicyPage'));
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
const ArticlesAdminPage = lazy(() => import('./pages/admin/ArticlesAdminPage'));
const SponsorsAdminPage = lazy(() => import('./pages/admin/SponsorsAdminPage'));
const CategoriesAdminPage = lazy(() => import('./pages/admin/CategoriesAdminPage'));
const MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
@@ -132,7 +131,7 @@ const FontLoader: React.FC = () => {
// Public route wrapper
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuth();
const { isAuthenticated, isLoading, user } = useAuth();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
@@ -156,7 +155,14 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
}
if (isAuthenticated) {
return <Navigate to="/admin" replace />;
const role = (user as any)?.role;
if (role === 'admin') {
return <Navigate to="/admin" replace />;
}
if (role === 'editor') {
return <Navigate to="/admin/clanky" replace />;
}
return <Navigate to="/" replace />;
}
const currentPath = window.location.pathname;
@@ -261,6 +267,7 @@ const AppLazy: React.FC = () => {
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
</Route>
{/* Admin routes */}
@@ -272,7 +279,6 @@ const AppLazy: React.FC = () => {
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
-2
View File
@@ -32,7 +32,6 @@ import ActivitiesCalendarPage from './pages/ActivitiesCalendarPage';
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
import CategoriesAdminPage from './pages/admin/CategoriesAdminPage';
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
@@ -493,7 +492,6 @@ const App: React.FC = () => {
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
@@ -37,7 +37,6 @@ const adminIndex: AdminSearchItem[] = [
{ label: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads'], icon: FaFolderOpen },
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
{ label: 'Kategorie', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories'], icon: FaAward },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
+165 -148
View File
@@ -263,9 +263,14 @@ const AdminSidebar = ({
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API
// Load dynamic navigation from API (admins only)
useEffect(() => {
let active = true;
// Editors should not call admin-only navigation endpoint; use fallback
if (!isAdmin) {
setNavLoading(false);
return () => { active = false };
}
(async () => {
try {
const items = await getAllNavigationItems();
@@ -470,8 +475,8 @@ const AdminSidebar = ({
);
})}
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
{!hasShortlinks && (
{/* Ensure Shortlinks is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasShortlinks && (
<NavItem
icon={FaLink}
to="/admin/shortlinks"
@@ -481,8 +486,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Engagement page is present even if not configured in dynamic nav */}
{!hasEngagement && (
{/* Ensure Engagement page is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasEngagement && (
<NavItem
icon={FaAward}
to="/admin/engagement"
@@ -492,8 +497,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Comments moderation is present even if not configured in dynamic nav */}
{!hasComments && (
{/* Ensure Comments moderation is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasComments && (
<NavItem
icon={FaComments}
to="/admin/komentare"
@@ -502,8 +507,8 @@ const AdminSidebar = ({
Komentáře
</NavItem>
)}
{/* Ensure Sweepstakes is present even if not configured in dynamic nav */}
{!hasSweepstakes && (
{/* Ensure Sweepstakes is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasSweepstakes && (
<NavItem
icon={FaGift}
to="/admin/sweepstakes"
@@ -512,8 +517,8 @@ const AdminSidebar = ({
Soutěže
</NavItem>
)}
{/* Ensure Competition Aliases is present even if not configured in dynamic nav */}
{!hasCompetitionAliases && (
{/* Ensure Competition Aliases is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasCompetitionAliases && (
<NavItem
icon={FaAward}
to="/admin/aliasy-soutezi"
@@ -523,8 +528,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Clothing is present even if not configured in dynamic nav */}
{!hasClothing && (
{/* Ensure Clothing is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasClothing && (
<NavItem
icon={FaTshirt}
to="/admin/obleceni"
@@ -541,13 +546,15 @@ const AdminSidebar = ({
Hlavní
</Text>
<NavItem
icon={FaTachometerAlt}
to="/admin"
onClick={onClose}
>
Nástěnka
</NavItem>
{isAdmin && (
<NavItem
icon={FaTachometerAlt}
to="/admin"
onClick={onClose}
>
Nástěnka
</NavItem>
)}
{isAdmin && (
<NavItem
@@ -563,26 +570,30 @@ const AdminSidebar = ({
Obsah
</Text>
{/* Core sports entities first */}
<NavItem
icon={FaUsers}
to="/admin/tymy"
onClick={onClose}
>
Týmy
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/zapasy"
onClick={onClose}
>
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
scroller
</Text>
</Text>
</NavItem>
{isAdmin && (
<>
<NavItem
icon={FaUsers}
to="/admin/tymy"
onClick={onClose}
>
Týmy
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/zapasy"
onClick={onClose}
>
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
scroller
</Text>
</Text>
</NavItem>
</>
)}
<NavItem
icon={FaCalendarAlt}
to="/admin/aktivity"
@@ -597,13 +608,15 @@ const AdminSidebar = ({
)}
</Text>
</NavItem>
<NavItem
icon={FaFutbol}
to="/admin/hraci"
onClick={onClose}
>
Hráči
</NavItem>
{isAdmin && (
<NavItem
icon={FaFutbol}
to="/admin/hraci"
onClick={onClose}
>
Hráči
</NavItem>
)}
{/* Other content */}
<NavItem
icon={FaNewspaper}
@@ -613,110 +626,114 @@ const AdminSidebar = ({
Články
</NavItem>
<NavItem
icon={FaFileAlt}
to="/admin/kategorie"
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Kategorie
</NavItem>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
<NavItem
icon={FaImage}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
<NavItem
icon={FaComments}
to="/admin/komentare"
onClick={onClose}
>
Komentáře
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
<NavItem
icon={FaAward}
to="/admin/engagement"
onClick={onClose}
>
Odměny & Úspěchy
Zkrácené odkazy
</NavItem>
{isAdmin && (
<>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
<NavItem
icon={FaImage}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
<NavItem
icon={FaComments}
to="/admin/komentare"
onClick={onClose}
>
Komentáře
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
<NavItem
icon={FaAward}
to="/admin/engagement"
onClick={onClose}
>
Odměny & Úspěchy
</NavItem>
</>
)}
<Divider my={2} />
{isAdmin && (
@@ -86,24 +86,21 @@ const InstagramGeneratorButton: React.FC<Props> = ({
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
// Deterministic shortlink code to keep link stable across generations
const code = article?.id ? `ig-a-${article.id}` : (activity?.id ? `ig-e-${activity.id}` : `ig-share`);
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
code,
} as any;
let sUrl = '';
try {
const res = await createShortLink(payload);
sUrl = res?.short_url || '';
} catch (err) {
// If code already exists or creation fails, fallback to computed short URL path
// Fallback to public shortlink (deterministic per URL) or use long URL
try {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
sUrl = origin ? `${origin}/s/${code}` : fullUrl;
const resPub = await createPublicShortLink({ target_url: fullUrl, title: article?.title || activity?.title || 'Link' });
sUrl = resPub?.short_url || fullUrl;
} catch {
sUrl = fullUrl;
}
@@ -74,18 +74,65 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// Ensure component is mounted before rendering Quill
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Track visibility of the editor container (avoid mounting Quill while hidden)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
let ro: ResizeObserver | null = null;
let io: IntersectionObserver | null = null;
let mo: MutationObserver | null = null;
const check = () => {
try {
const inDoc = document.contains(el);
const rects = el.getClientRects();
const style = window.getComputedStyle(el);
const visible = inDoc && rects.length > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
setIsVisible(visible);
} catch {}
};
// Observe size/visibility changes
try {
ro = new ResizeObserver(() => check());
ro.observe(el);
} catch {}
try {
io = new IntersectionObserver((entries) => {
const entry = entries[0];
setIsVisible(!!entry && (entry.isIntersecting || entry.intersectionRatio > 0));
}, { root: null, threshold: [0, 0.01] });
io.observe(el);
} catch {}
try {
mo = new MutationObserver(() => check());
mo.observe(document.body, { attributes: true, childList: true, subtree: true });
} catch {}
// Initial check
check();
return () => {
try { ro && ro.disconnect(); } catch {}
try { io && io.disconnect(); } catch {}
try { mo && mo.disconnect(); } catch {}
};
}, [containerRef]);
// Keep onChange ref up to date
useEffect(() => {
@@ -143,7 +190,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
full: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
@@ -153,7 +200,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
@@ -302,16 +349,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setTimeout(() => setIsListStyleOpen(true), 0);
}
},
colorreset: () => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('color', false);
},
bgreset: () => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('background', false);
},
},
},
clipboard: {
@@ -391,8 +428,50 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
setTitle('button.ql-colorreset', 'Zrušit barvu');
setTitle('button.ql-bgreset', 'Zrušit pozadí');
// Inject reset option inside color/background pickers
try {
const injectReset = (
pickerSelector: string,
format: 'color' | 'background',
label: string
) => {
const picker = toolbarEl.querySelector(pickerSelector) as HTMLElement | null; // .ql-color or .ql-background
const options = picker?.querySelector('.ql-picker-options') as HTMLElement | null;
if (!options) return;
if (options.querySelector(`button.ql-picker-item[data-reset="${format}"]`)) return;
const btn = document.createElement('button');
btn.setAttribute('type', 'button');
btn.className = 'ql-picker-item';
btn.setAttribute('data-reset', format);
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
btn.style.width = '16px';
btn.style.height = '16px';
btn.style.border = '1px solid #e2e8f0';
btn.style.borderRadius = '2px';
btn.style.position = 'relative';
btn.style.background = '#ffffff';
const slash = document.createElement('span');
slash.style.position = 'absolute';
slash.style.left = '2px';
slash.style.right = '2px';
slash.style.top = '7px';
slash.style.height = '2px';
slash.style.background = '#e53e3e';
slash.style.transform = 'rotate(-45deg)';
btn.appendChild(slash);
btn.addEventListener('click', (e) => {
e.preventDefault();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(format, false);
try { picker?.classList.remove('ql-expanded'); } catch {}
});
options.insertBefore(btn, options.firstChild);
};
injectReset('.ql-color', 'color', 'Zrušit barvu');
injectReset('.ql-background', 'background', 'Zrušit pozadí');
} catch {}
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
@@ -1401,7 +1480,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Defer heavy sanitization to submit time to prevent selection glitches; keep minimal cleanup only
const handleChange = (content: string) => {
onChangeRef.current(cleanEditorHTML(content));
onChangeRef.current(content);
};
@@ -1462,6 +1541,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderRadius="md"
overflow="visible"
bg={bgColor}
ref={containerRef}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
@@ -1622,7 +1702,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Allow user-chosen colors to show. White-on-white is handled during paste/sanitize only.
}}
>
{isMounted && (
{isMounted && isVisible && (
<ReactQuill
theme="snow"
value={value}
@@ -1632,6 +1712,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
ref={quillRef}
modules={quillModules}
formats={quillFormats}
onBlur={(_prev, _source, editor) => {
try {
const html = editor?.getHTML ? editor.getHTML() : (quillRef.current?.getEditor().root.innerHTML || value);
onChangeRef.current(cleanEditorHTML(html));
} catch {}
}}
/>
)}
</Box>
@@ -1,7 +1,7 @@
import React from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
import HorizontalScroller from '../ui/HorizontalScroller';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
@@ -108,8 +108,17 @@ const BlogCardsScroller: React.FC = () => {
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
// Load featured (primary) to exclude from scroller
const { data: featuredData } = useQuery({
queryKey: ['articles', 'featured', { page: 1, page_size: 100 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 100 }),
});
const list: Article[] = data?.data || [];
const listAll: Article[] = data?.data || [];
const featuredKeys = new Set(
(featuredData?.data || []).map((a: Article) => (a.slug ? `s:${a.slug}` : `i:${a.id}`))
);
const list: Article[] = listAll.filter((a) => !featuredKeys.has(a.slug ? `s:${a.slug}` : `i:${a.id}`));
return (
<Box>
@@ -147,7 +147,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor}>
<Heading size="xl" color={headingColor} id="home-gallery-heading">
Fotogalerie
</Heading>
<Text color={textColor} fontSize="sm">
@@ -227,6 +227,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
h="200px"
objectFit="cover"
loading="lazy"
decoding="async"
/>
) : (
<Box
+11 -4
View File
@@ -66,7 +66,6 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
// 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 youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
const titleOverrides: Record<string, string> = (settings as any)?.videos_title_overrides || {};
useEffect(() => {
try {
@@ -171,7 +170,15 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
}}
>
<AspectRatio ratio={16 / 9}>
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
<Box
position="relative"
cursor="pointer"
role="button"
tabIndex={0}
aria-label={`Přehrát video: ${it.title}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePlayClick(it); } }}
onClick={() => handlePlayClick(it)}
>
{/* Thumbnail */}
{thumb ? (
<Box
@@ -257,7 +264,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<Box>
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
<HStack spacing={3}>
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">Videa</Heading>
</HStack>
<Link as={RouterLink} to="/videa">
<Button
@@ -340,7 +347,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
return (
<Box>
<Box className="section-head">
<Heading as="h3" size="md">Videa</Heading>
<Heading as="h3" size="md" id="home-videos-heading">Videa</Heading>
<Link as={RouterLink} to="/videa">
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
</Link>
+20 -5
View File
@@ -62,16 +62,20 @@ const MatchesSlider: React.FC<{
const items = (current?.matches || []);
const looped = [...items, ...items, ...items];
return (
<section className="matches-slider matches-ticker" {...(elementProps || {})}>
<section className="matches-slider matches-ticker" aria-label={`Zápasy ${current?.name || ''}`} {...(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">
<div className="ticker-belt" role="list">
{looped.map((m, idx) => (
<div
key={`${m.id || idx}-ticker`}
className="match-card card"
role="button"
tabIndex={0}
aria-label={`${sanitizeClubName(m.home || '')} proti ${sanitizeClubName(m.away || '')}${m.time ? `, ${m.time}` : ''}${m.score ? `, skóre ${String(m.score)}` : ''}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onMatchClick?.(m, current?.name); } }}
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
@@ -104,17 +108,21 @@ const MatchesSlider: React.FC<{
}
return (
<section className="matches-slider" {...(elementProps || {})}>
<section className="matches-slider" aria-label={`Zápasy ${current?.name || ''}`} {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="matches-grid">
<div className="matches-track" ref={trackRef}>
<div className="matches-track" ref={trackRef} role="list">
{(current?.matches || []).map((m, idx) => (
<div
key={m.id || idx}
className="match-card card"
role="button"
tabIndex={0}
aria-label={`${sanitizeClubName(m.home || '')} proti ${sanitizeClubName(m.away || '')}${m.date ? `, ${new Date(`${m.date}T${(m.time || '00:00')}:00`).toLocaleDateString()}` : ''}${m.time ? `, ${m.time}` : ''}${m.score ? `, skóre ${String(m.score)}` : ''}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onMatchClick?.(m, current?.name); } }}
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
@@ -163,7 +171,14 @@ const MatchesSlider: React.FC<{
</div>
<div className="matches-tabs">
{comps.map((c, i) => (
<button key={`${c.name}-${i}`} className={i === activeIndex ? 'active' : ''} onClick={() => onActiveChange(i)}>{c.name}</button>
<button
key={`${c.name}-${i}`}
className={i === activeIndex ? 'active' : ''}
aria-pressed={i === activeIndex}
onClick={() => onActiveChange(i)}
>
{c.name}
</button>
))}
</div>
</div>
@@ -27,12 +27,17 @@ const NextMatch: React.FC<{
<section
className="next-match"
{...(elementProps as any)}
role={onOpen ? 'button' : 'region'}
aria-label={`Další zápas: ${sanitizeClubName(show?.home || '')} vs ${sanitizeClubName(show?.away || '')}${competitionName ? `, ${competitionName}` : ''}`}
tabIndex={onOpen ? 0 : -1}
onKeyDown={(e) => { if (!onOpen) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); onOpen?.(); } }}
onClick={(e) => { e.stopPropagation(); onOpen?.(); }}
style={{ cursor: onOpen ? 'pointer' : 'default', position: 'relative', ...(elementProps?.style || {}) }}
>
{onPrev && (
<button
aria-label="Předchozí soutěž"
type="button"
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
className="nav prev"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
@@ -78,6 +83,7 @@ const NextMatch: React.FC<{
{onNext && (
<button
aria-label="Další soutěž"
type="button"
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
className="nav next"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
import { useToast } from '@chakra-ui/react';
const fmtDate = (iso?: string | null) => {
if (!iso) return '';
@@ -15,6 +16,7 @@ const SweepstakeWidget: React.FC = () => {
const [joining, setJoining] = useState<boolean>(false);
const [playing, setPlaying] = useState<boolean>(false);
const playedRef = useRef(false);
const toast = useToast();
const load = async () => {
setLoading(true);
@@ -65,9 +67,11 @@ const SweepstakeWidget: React.FC = () => {
setJoining(true);
try {
await enterSweepstake(s.id);
toast({ status: 'success', title: 'Úspěšně jste vstoupili do soutěže' });
await load();
} catch (e) {
// ignore
} catch (e: any) {
const msg = e?.response?.data?.error || 'Nelze vstoupit do soutěže';
toast({ status: 'error', title: msg });
} finally {
setJoining(false);
}
@@ -104,13 +108,11 @@ const SweepstakeWidget: React.FC = () => {
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Začíná: {fmtDate(s.start_at)} Končí: {fmtDate(s.end_at)}</div>
</div>
{!isLogged ? (
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se a zapojit</a>
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se</a>
) : data?.has_entered ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
</button>
<span style={{ fontSize: 14, opacity: 0.85 }}>Soutěž ještě nezačala. Vstup bude možný od {fmtDate(s.start_at)}.</span>
)}
</div>
</div>
@@ -131,6 +133,12 @@ const SweepstakeWidget: React.FC = () => {
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
{s.description && <div style={{ opacity: 0.8 }}>{s.description}</div>}
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Konec: {fmtDate(s.end_at)}</div>
<div style={{ marginTop: 6, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, background: '#eef', color: '#223', padding: '2px 8px', borderRadius: 12 }}>Vstup: {(s as any).entry_cost_points ? `${(s as any).entry_cost_points} bodů` : 'zdarma'}</span>
{(s as any).max_entries_per_user > 1 && (
<span style={{ fontSize: 13, background: '#f3f3f3', color: '#333', padding: '2px 8px', borderRadius: 12 }}>max {(s as any).max_entries_per_user}×/osoba</span>
)}
</div>
</div>
{!isLogged ? (
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
@@ -138,7 +146,7 @@ const SweepstakeWidget: React.FC = () => {
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
{joining ? 'Vstupuji…' : 'Vstoupit'}
</button>
)}
</div>
@@ -175,6 +175,27 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
py={2}
px={1}
cursor={draggable ? 'grab' : 'default'}
role="region"
aria-roledescription="carousel"
aria-label={title ? `Posuvník: ${title}` : 'Posuvník obsahu'}
tabIndex={0}
onKeyDown={(e) => {
const el = containerRef.current;
if (!el) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
scrollBy(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
scrollBy(1);
} else if (e.key === 'Home') {
e.preventDefault();
el.scrollTo({ left: 0, behavior: 'smooth' });
} else if (e.key === 'End') {
e.preventDefault();
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
}
}}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
onMouseDown={(e) => { if (!draggable) return; e.preventDefault(); onPointerDown(e.clientX); }}
@@ -208,7 +229,7 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
{/* navigation buttons - must be above gradient masks */}
<IconButton
aria-label="scroll left"
aria-label="Posunout doleva"
icon={<ChevronLeftIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
@@ -234,7 +255,7 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
pointerEvents="auto"
/>
<IconButton
aria-label="scroll right"
aria-label="Posunout doprava"
icon={<ChevronRightIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
+74 -5
View File
@@ -1,4 +1,4 @@
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, Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
@@ -17,6 +17,7 @@ import TeamLogo from '../components/common/TeamLogo';
import MatchModal from '../components/home/MatchModal';
import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import { getBanners, Banner as UIBanner } from '../services/banners';
import FilePreview from '../components/common/FilePreview';
import { usePublicSettings } from '../hooks/usePublicSettings';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
@@ -42,6 +43,8 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
queryKey: ['competition-aliases-public'],
@@ -336,6 +339,13 @@ const ArticleDetailPage: React.FC = () => {
});
}, [(data as any)?.content, toAbsoluteUploads]);
const articleBannersQ = useQuery<UIBanner[]>({
queryKey: ['banners', { placement: 'article_inline' }],
queryFn: () => getBanners({ active: true, placement: 'article_inline' }),
staleTime: 60 * 1000,
});
const articleBanners = (articleBannersQ.data || []) as UIBanner[];
const relatedArticlesQuery = useQuery({
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
enabled: Boolean((data as any)?.id),
@@ -526,6 +536,22 @@ const ArticleDetailPage: React.FC = () => {
</HStack>
) : null}
</HStack>
<Breadcrumb fontSize="sm" mt={2} color={textMuted} separator="/">
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/">Domů</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/blog">Blog</BreadcrumbLink>
</BreadcrumbItem>
{(data as any)?.category?.id ? (
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to={`/news?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
</BreadcrumbItem>
) : null}
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>{data.title}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</Container>
</Box>
<Container maxW="7xl">
@@ -541,7 +567,8 @@ const ArticleDetailPage: React.FC = () => {
{/* 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">
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden" cursor="pointer"
onClick={() => { if (facrMatchQuery?.data) { setSelectedMatch({ ...(facrMatchQuery.data as any), competition: (facrMatchQuery.data as any).competitionName }); setIsMatchModalOpen(true); } }}>
{/* 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 && (
@@ -639,6 +666,27 @@ const ArticleDetailPage: React.FC = () => {
}}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{articleBanners.length > 0 && (
<Box textAlign="center" mt={{ base: 4, md: 6 }}>
<a
href={articleBanners[0].click_url || '#'}
target={articleBanners[0].click_url ? '_blank' : undefined}
rel={articleBanners[0].click_url ? 'noopener noreferrer' : undefined}
style={{ display: 'inline-block' }}
>
<Image
src={assetUrl((articleBanners[0] as any).image_url) || '/images/sponsors/placeholder.png'}
alt={articleBanners[0].name}
maxW="100%"
w={articleBanners[0].width ? `${articleBanners[0].width}px` : '100%'}
h={articleBanners[0].height ? `${articleBanners[0].height}px` : 'auto'}
borderRadius="md"
loading="lazy"
decoding="async"
/>
</a>
</Box>
)}
{/* YouTube Video Section - simplified with rounded edges */}
{(data as any)?.youtube_video_id && (
<Box borderRadius="xl" overflow="hidden">
@@ -708,6 +756,13 @@ const ArticleDetailPage: React.FC = () => {
</Stack>
</Box>
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
{/* Polls in sidebar */}
{data?.id ? (
<Widget title="Anketa">
<EmbeddedPoll articleId={(data as any).id} maxPolls={1} />
</Widget>
) : null}
{relatedArticlesQuery.isLoading ? null : (() => {
const list = ((relatedArticlesQuery.data as any)?.data || [])
.filter((a: any) => a?.id !== (data as any)?.id)
@@ -768,12 +823,28 @@ const ArticleDetailPage: React.FC = () => {
</Widget>
);
})()}
{/* Attachments in sidebar */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Widget title="Přílohy">
<VStack align="stretch" spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between">
<Text noOfLines={1}>{f.name || f.url}</Text>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
</HStack>
))}
</VStack>
</Widget>
)}
</VStack>
</SimpleGrid>
</Container>
</Box>
{/* Attachments - bottom above CTA */}
{/* Polls (Ankety) above attachments */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Attachments - bottom above comments */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Container maxW="7xl" mt={4}>
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
@@ -789,8 +860,6 @@ const ArticleDetailPage: React.FC = () => {
</Box>
</Container>
)}
{/* Polls (Ankety) above CTA */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Comments at the end */}
{(data as any)?.id ? (
<Container maxW="7xl" mt={4}>
+28 -53
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery } from '@chakra-ui/react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery, Tooltip } from '@chakra-ui/react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
import { getBanners, Banner as UIBanner } from '../services/banners';
@@ -24,6 +24,9 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
? ({ base: '160px', md: '180px' } as const)
: ({ base: '200px', md: '220px' } as const);
const publishedAt = (article as any).published_at || (article as any).created_at;
const publishedDateStr = publishedAt ? (()=>{ try { return new Date(publishedAt).toLocaleDateString('cs-CZ'); } catch { return ''; } })() : '';
return (
<LinkBox
as={RouterLink}
@@ -49,59 +52,31 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
fetchPriority={variant === 'large' ? 'high' as any : 'auto' as any}
/>
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
{categoryName && (
<Badge
position="absolute"
top={2}
left={2}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{categoryName}
</Badge>
)}
{/* Stats badges at top */}
{(readTime || (viewCount && viewCount > 0)) && (
<HStack position="absolute" top={2} right={2} spacing={1}>
{readTime && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{/* Top info row: category (left), date (center), read time (right) */}
<HStack position="absolute" top={2} left={2} right={2} justify="space-between" align="center">
{categoryName ? (
<Tooltip label="Kategorie" hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{categoryName}
</Badge>
</Tooltip>
) : <Box />}
{publishedDateStr ? (
<Tooltip label="Datum publikace" hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{publishedDateStr}
</Badge>
</Tooltip>
) : <Box />}
{readTime ? (
<Tooltip label="Doba čtení" hasArrow>
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
<Clock size={12} />
{readTime} min
</Badge>
)}
{viewCount && viewCount > 0 && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
<Eye size={12} />
{viewCount}
</Badge>
)}
</HStack>
)}
</Tooltip>
) : <Box />}
</HStack>
<Heading
as="h3"
@@ -367,9 +342,9 @@ const BlogPage: React.FC = () => {
</Container>
)}
<Container maxW="5xl">
<Container maxW="7xl">
{/* Responsive grid with consistent card sizing */}
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={6}>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={8}>
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
))}
+4 -3
View File
@@ -37,6 +37,7 @@ import ContactMap from '../components/home/ContactMap';
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
import { facrApi } from '../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
import { getImageUrl } from '../utils/imageUtils';
type ContactFormData = {
name: string;
@@ -276,7 +277,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
@@ -317,7 +318,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
@@ -359,7 +360,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
+279 -125
View File
@@ -39,6 +39,7 @@ const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'
import ActivitiesList from '../components/pack/ActivitiesList';
import { useAuth } from '../contexts/AuthContext';
import SweepstakeWidget from '../components/sweepstakes/SweepstakeWidget';
import { sortCategoriesWithOrder } from '../utils/categorySort';
// Types for real API-driven data
type NewsItem = {
@@ -92,7 +93,7 @@ const HomePage: React.FC = () => {
const [edgeRoleIdx, setEdgeRoleIdx] = useState<number>(0);
const blogAutoRef = useRef<HTMLDivElement | null>(null);
// FACR competitions with matches (for slider)
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string }>>([]);
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string; display_order?: number }>>([]);
const [matchesTab, setMatchesTab] = useState<number>(0);
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -118,10 +119,11 @@ const HomePage: React.FC = () => {
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
const [activitiesLoaded, setActivitiesLoaded] = useState<boolean>(false);
const [defer, setDefer] = useState<boolean>(false);
// Aliases
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
const [settings, setSettings] = useState<any>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
@@ -164,6 +166,33 @@ const HomePage: React.FC = () => {
slug: item.slug,
})), [featured]);
const upcomingCompIndices = useMemo(() => {
const now = Date.now();
try {
return (facrCompetitions || [])
.map((c, i) => {
const items = Array.isArray(c?.matches) ? c.matches : [];
const hasUpcoming = items.some((m: any) => {
const t = new Date(`${m.date || ''}T${(m.time || '00:00')}:00`).getTime();
return !isNaN(t) && t > now;
});
return hasUpcoming ? i : -1;
})
.filter((i) => i !== -1);
} catch {
return [] as number[];
}
}, [facrCompetitions]);
useEffect(() => {
try {
if (!Array.isArray(upcomingCompIndices) || upcomingCompIndices.length === 0) return;
if (!upcomingCompIndices.includes(nextCompIdx)) {
setNextCompIdx(upcomingCompIndices[0]);
}
} catch {}
}, [upcomingCompIndices, nextCompIdx]);
useEffect(() => {
let cancelled = false;
@@ -262,8 +291,8 @@ const HomePage: React.FC = () => {
try {
aliasesList = await getCompetitionAliasesPublic();
} catch {}
const amap: Record<string, { alias: string; original_name?: string }> = {};
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name }; });
const amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
// Try live settings API first
let liveSettings: any = null;
try {
@@ -392,10 +421,12 @@ const HomePage: React.FC = () => {
return {
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
matches_link: c.matches_link,
matches: filtered
matches: filtered,
display_order: (amap?.[c?.code]?.display_order),
};
});
setFacrCompetitions(comps);
const sortedComps = sortCategoriesWithOrder(comps as any);
setFacrCompetitions(sortedComps as any);
// Next match FACR link
const first = filteredMatches?.[0];
@@ -414,7 +445,7 @@ const HomePage: React.FC = () => {
// Load players via API (include inactive to show as non-active instead of hiding)
try {
const apiPlayers: ApiPlayer[] = await apiGetPlayers({ active: false });
const apiPlayers: ApiPlayer[] = await apiGetPlayers();
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
id: p.id,
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
@@ -481,7 +512,7 @@ const HomePage: React.FC = () => {
const top3 = all.slice(0, 3);
setFeatured(top3);
setNews((prev) => {
const featuredKeys = new Set(top3.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
const featuredKeys = new Set(all.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
return (prev || []).filter((n) => !featuredKeys.has(n.slug ? `s:${n.slug}` : `i:${n.id}`));
});
} catch {}
@@ -531,6 +562,8 @@ const HomePage: React.FC = () => {
if (facrTablesJSON?.competitions?.length) {
const comps = (facrTablesJSON.competitions || []).map((c: any) => ({
name: (amap?.[c?.code]?.alias) || c.name || c.code,
display_order: (amap?.[c?.code]?.display_order),
code: c.code,
table: (c.table?.overall || []).map((r: any, idx: number) => ({
position: Number(r.rank || idx + 1),
team: r.team || r.team_name || '-',
@@ -544,7 +577,8 @@ const HomePage: React.FC = () => {
score: r.score || '0:0',
})),
}));
setStandings(comps);
const sortedTables = sortCategoriesWithOrder(comps as any);
setStandings(sortedTables);
}
// Club name/logo from FACR if not provided by settings
@@ -630,6 +664,9 @@ const HomePage: React.FC = () => {
}));
if (active) setUpcomingEvents(mapped);
} catch {}
finally {
if (active) setActivitiesLoaded(true);
}
})();
return () => { active = false; };
}, []);
@@ -1402,13 +1439,17 @@ const HomePage: React.FC = () => {
</div>
</a>
) : (
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
<div className="overlay">
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
</div>
</a>
isLoading ? (
<div className="hero-card big skeleton" style={{ borderRadius: 16 }} />
) : (
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
<div className="overlay">
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
</div>
</a>
)
)}
<div className="small-col">
{featured.slice(1, 3).map((n, idx) => (
@@ -1421,13 +1462,17 @@ const HomePage: React.FC = () => {
</a>
))}
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, featured.length - 1))) }).map((_, idx) => (
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
<div className="overlay">
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
</div>
</a>
isLoading ? (
<div key={`placeholder-${idx}`} className="hero-card small skeleton" style={{ borderRadius: 16 }} />
) : (
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
<div className="overlay">
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
</div>
</a>
)
))}
</div>
</section>
@@ -1438,7 +1483,7 @@ const HomePage: React.FC = () => {
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
@@ -1446,34 +1491,37 @@ const HomePage: React.FC = () => {
{/* Featured articles are now shown in the hero grid above, not here */}
{/* Sidebar banners (homepage_sidebar) - fixed edge rail, left/right via MyUIbrix variant */}
{/* Sidebar banners (homepage_sidebar) - sticky within page container */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
data-element="sidebar"
data-variant={getVariant('sidebar', 'right')}
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
style={{
// Use configured styles but force fixed rail placement
...getStyles('sidebar'),
position: 'fixed',
top: 112,
left: getVariant('sidebar', 'right') === 'left' ? 12 : 'auto',
right: getVariant('sidebar', 'right') === 'left' ? 'auto' : 12,
width: 320,
maxWidth: '100%',
zIndex: 50,
pointerEvents: 'none',
}}
style={{ margin: '24px 0', ...getStyles('sidebar') }}
>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, pointerEvents: 'auto', padding: 4 }}>
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
</a>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div
style={{
position: 'sticky',
top: 112,
width: 320,
maxWidth: '100%',
marginLeft: getVariant('sidebar', 'right') === 'left' ? 0 : 'auto',
marginRight: getVariant('sidebar', 'right') === 'left' ? 'auto' : 0,
zIndex: 1,
}}
>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, padding: 4 }}>
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
</a>
</div>
))}
</div>
))}
</div>
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
@@ -1492,58 +1540,68 @@ const HomePage: React.FC = () => {
)}
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
{facrCompetitions.length > 0 && isVisible('matches', true) ? (
(() => {
const comp = facrCompetitions[Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1))];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || items[0] || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
const handleNextMatchClick = () => {
if (show) {
setSelectedMatch({
...show,
competition: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
{isVisible('matches', true) ? (
facrCompetitions.length > 0 ? (
upcomingCompIndices.length > 0 ? (
(() => {
const safeIndex = Math.max(0, Math.min(nextCompIdx, facrCompetitions.length - 1));
const pos = upcomingCompIndices.indexOf(safeIndex);
const effectiveIndex = pos >= 0 ? upcomingCompIndices[pos] : upcomingCompIndices[0];
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
const handleNextMatchClick = () => {
if (show) {
setSelectedMatch({
...show,
competition: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
return (
return (
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setNextCompIdx(prevIdx)}
onNext={() => setNextCompIdx(nextIdx)}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
'aria-live': 'polite' as any,
style: { ...getStyles('matches') },
}}
/>
);
})()
) : null
) : (
<div className="card">
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
style: { ...getStyles('matches') },
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
/>
);
})()
) : isVisible('matches', true) ? (
<div className="card">
<NextMatch
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
/>
</div>
</div>
)
) : null}
{/* Sweepstakes / Lottery widget (visible around matches section) */}
@@ -1570,6 +1628,20 @@ const HomePage: React.FC = () => {
</Suspense>
) : null
)}
{facrCompetitions.length === 0 && isLoading && (
<section data-element="matches-slider" data-variant={getVariant('matches-slider', 'carousel')} aria-label="Zápasy" style={{ position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '280px', ...getStyles('matches-slider') }}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>Zápasy</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div style={{ display: 'flex', gap: 18, overflow: 'hidden', padding: '8px 2px 16px 2px' }}>
{[1,2,3].map((i) => (
<div key={i} className="card skeleton" style={{ minWidth: 340, height: 160, borderRadius: 12 }} />
))}
</div>
</section>
)}
{/* News + Tables: split into two independent sections */}
{(() => {
@@ -1597,23 +1669,31 @@ const HomePage: React.FC = () => {
style={{ marginTop: 32 }}
>
{showNews && (
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" aria-labelledby="home-news-heading" style={{ ...getStyles('news'), contentVisibility: 'auto' as any, containIntrinsicSize: '600px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
<h3 id="home-news-heading">Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
{newsVariant === 'scroller' ? (
<BlogCardsScroller />
) : (
<NewsList items={news as any} />
isLoading && (!news || (news as any).length === 0) ? (
<div className="blog-list">
{[1,2,3,4].map(i => (
<div key={i} className="card skeleton" style={{ height: 96, borderRadius: 12 }} />
))}
</div>
) : (
<NewsList items={news as any} />
)
)}
</section>
)}
{showTable && (
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} role="region" aria-labelledby="home-table-heading" style={{ ...getStyles('table'), contentVisibility: 'auto' as any, containIntrinsicSize: '520px' }}>
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<h3 id="home-table-heading">Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
{defer ? (
@@ -1639,7 +1719,15 @@ const HomePage: React.FC = () => {
}}
/>
</Suspense>
) : null}
) : (
<div className="table-card">
<div className="standings">
{[1,2,3,4,5,6,7,8].map(i => (
<div key={i} className="standing-row skeleton" style={{ borderRadius: 12 }} />
))}
</div>
</div>
)}
{/* Banners under the table, inside the table column */}
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
defer ? (
@@ -1657,12 +1745,28 @@ const HomePage: React.FC = () => {
{/* (Moved) Banner under tables now renders inside the table column above */}
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
{isVisible('activities', true) && !activitiesLoaded && (
<section data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<h3 id="home-activities-heading">Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
{[1,2,3].map(i => (
<div key={i} className="card skeleton" style={{ height: 120, borderRadius: 12 }} />
))}
</div>
</div>
</section>
)}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3 id="home-activities-heading">Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<ActivitiesList items={upcomingEvents as any} />
@@ -1671,10 +1775,25 @@ const HomePage: React.FC = () => {
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
{isVisible('team', false) && players.length === 0 && isLoading && (
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
<div className="section-head">
<h3>Hráči</h3>
<h3 id="home-players-heading">Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="scroll-x">
{[1,2,3,4,5,6].map(i => (
<div key={i} className="player-card skeleton" style={{ width: 170, height: 210, borderRadius: 14 }} />
))}
</div>
</section>
)}
{players.length > 0 && isVisible('team', false) && (
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
<div className="section-head">
<h3 id="home-players-heading">Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="scroll-x">
@@ -1691,7 +1810,7 @@ const HomePage: React.FC = () => {
{/* Gallery */}
{isVisible('gallery', false) && (
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} aria-labelledby="home-gallery-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1704,7 +1823,7 @@ const HomePage: React.FC = () => {
{/* Videos */}
{isVisible('videos', false) && (
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} aria-labelledby="home-videos-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1713,26 +1832,50 @@ const HomePage: React.FC = () => {
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
/>
</Suspense>
) : null}
) : (
<>
<div className="section-head">
<h3 id="home-videos-heading">Videa</h3>
<a href="/videa" className="see-all">Více videí</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
{[1,2,3].map((i) => (
<div key={i} className="card skeleton" style={{ height: 240, borderRadius: 12 }} />
))}
</div>
</>
)}
</div>
</section>
)}
{isVisible('merch', true) && (
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} aria-labelledby="home-merch-heading" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('merch') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
</Suspense>
) : null}
) : (
<>
<div className="section-head">
<h3 id="home-merch-heading">Oblečení týmu</h3>
<a href="/obleceni" className="see-all">Zobrazit vše</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{[1,2,3,4,5].map((i) => (
<div key={i} className="card skeleton" style={{ height: 180, borderRadius: 12 }} />
))}
</div>
</>
)}
</div>
</section>
)}
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} aria-label="Anketa" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '500px', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1740,7 +1883,9 @@ const HomePage: React.FC = () => {
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</Suspense>
) : null}
) : (
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
)}
</div>
</section>
)}
@@ -1751,7 +1896,7 @@ const HomePage: React.FC = () => {
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
@@ -1759,13 +1904,15 @@ const HomePage: React.FC = () => {
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" aria-label="Přihlášení k newsletteru" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '420px', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
{defer ? (
<Suspense fallback={null}>
<NewsletterSubscribe />
</Suspense>
) : null}
) : (
<div className="skeleton" style={{ height: 280, borderRadius: 12 }} />
)}
</div>
</section>
)}
@@ -1830,6 +1977,7 @@ const HomePage: React.FC = () => {
data-element="sponsors"
data-variant={variant}
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
aria-labelledby="home-sponsors-heading"
style={{
width: '100vw',
position: 'relative',
@@ -1839,19 +1987,28 @@ const HomePage: React.FC = () => {
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
contentVisibility: 'auto' as any,
containIntrinsicSize: '520px',
...getStyles('sponsors')
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
<h3 id="home-sponsors-heading">Sponzoři</h3>
</div>
{isLoading && ordered.length === 0 && (
<div className="sponsors-grid">
{[1,2,3,4,5,6,7,8].map(i => (
<div key={i} className="sponsor-tile skeleton" style={{ minHeight: 90, borderRadius: 12 }} />
))}
</div>
)}
{variant === 'grid' && (
<>
{general.length > 0 && (
<div className="title-sponsor">
{general.map((g) => (
<a key={`g-${g.id}`} className="sponsor-tile" href={g.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
<img loading="lazy" decoding="async" src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
</a>
))}
</div>
@@ -1860,7 +2017,7 @@ const HomePage: React.FC = () => {
<div className="sponsors-grid">
{(standard.length > 0 ? standard : (general.length === 0 ? ordered : [])).map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1872,7 +2029,7 @@ const HomePage: React.FC = () => {
<div className="track">
{[...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1883,7 +2040,7 @@ const HomePage: React.FC = () => {
<div className="belt">
{[...ordered, ...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1943,11 +2100,8 @@ 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';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
+2 -5
View File
@@ -130,11 +130,8 @@ 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';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { useMemo, useState } from 'react';
import { SearchIcon } from '@chakra-ui/icons';
const PlayersPage: React.FC = () => {
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: () => getPlayers() });
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players-all'], queryFn: () => getPlayers({ active: false }) });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
@@ -49,6 +49,7 @@ import { api } from '../../services/api';
// Removed react-datepicker to prevent crash; using native date/time inputs instead
import { getPublicSettings } from '../../services/settings';
import PollLinker from '../../components/admin/PollLinker';
import { useAuth } from '../../contexts/AuthContext';
import FilePreview from '../../components/common/FilePreview';
import { facrApi } from '../../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
@@ -73,6 +74,8 @@ const types: Array<{ value: Event['type']; label: string }> = [
];
const AdminActivitiesPage: React.FC = () => {
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
@@ -1135,7 +1138,7 @@ const AdminActivitiesPage: React.FC = () => {
{/* Poll Section */}
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
<Heading size="sm" mb={3}>Anketa</Heading>
{editing?.id ? (
{isAdmin && editing?.id ? (
<PollLinker eventId={editing.id} />
) : (
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
+9 -9
View File
@@ -99,15 +99,6 @@ const AdminVideosPage: React.FC = () => {
if (mounted) setAutoLoading(false);
}
};
const saveOverrides = async () => {
try {
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
}
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
@@ -159,6 +150,15 @@ const AdminVideosPage: React.FC = () => {
}
};
const saveOverrides = async () => {
try {
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
}
};
const fetchChannelVideos = async () => {
const channel = channelInput?.trim();
if (!channel) {
+24 -15
View File
@@ -11,6 +11,7 @@ import {
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { useAuth } from '../../contexts/AuthContext';
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
import { generateBlogAI } from '../../services/ai';
import { useState, useRef, useCallback, useMemo } from 'react';
@@ -172,6 +173,8 @@ const parseYoutubeVideoId = (raw: string): string => {
};
const ArticlesAdminPage = () => {
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const toast = useToast();
const qc = useQueryClient();
const [page, setPage] = useState(1);
@@ -519,16 +522,20 @@ const ArticlesAdminPage = () => {
try {
// Set cover image immediately
setEditing((prev) => ({ ...(prev as any), image_url: pick.image_url }));
// Persist pick to unified cache (admin)
await putZoneramaPick({
id: pick.id,
album_id: pick.album_id,
album_url: pick.album_url,
page_url: pick.page_url,
image_url: pick.image_url,
title: pick.title,
} as any);
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
// Persist pick to unified cache (admin only)
if (isAdmin) {
await putZoneramaPick({
id: pick.id,
album_id: pick.album_id,
album_url: pick.album_url,
page_url: pick.page_url,
image_url: pick.image_url,
title: pick.title,
} as any);
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
} else {
toast({ title: 'Obrázek nastaven', status: 'success' });
}
} catch (e: any) {
toast({ title: 'Uložení výběru selhalo', description: e?.response?.data?.error || e?.message || 'Chyba', status: 'error' });
}
@@ -537,9 +544,11 @@ const ArticlesAdminPage = () => {
// Handle album photo selection for blog content
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
try {
// Save album to cache
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
await saveAlbumToCache(albumInfo.url, photos.length);
// Save album to cache (admins only)
if (isAdmin) {
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
await saveAlbumToCache(albumInfo.url, photos.length);
}
// Store album info with article and append images to content
setEditing((prev) => {
@@ -573,7 +582,7 @@ const ArticlesAdminPage = () => {
toast({
title: 'Album přidáno',
description: `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.`,
description: isAdmin ? `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.` : `${photos.length} fotografií vloženo do článku.`,
status: 'success',
duration: 4000
});
@@ -2092,7 +2101,7 @@ const ArticlesAdminPage = () => {
</Text>
</Box>
{editing?.id ? (
{isAdmin && editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
+54 -4
View File
@@ -2,7 +2,7 @@ import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban, adminListBans, adminLiftBan } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
import { getArticles } from '../../services/articles';
@@ -37,6 +37,11 @@ const CommentsAdminPage: React.FC = () => {
queryFn: adminListUnbanRequests,
});
const bansQ = useQuery({
queryKey: ['admin-comment-bans'],
queryFn: adminListBans,
});
const updateStatusMut = useMutation({
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
@@ -57,7 +62,16 @@ const CommentsAdminPage: React.FC = () => {
const resolveUnbanMut = useMutation({
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] });
await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] });
toast({ status: 'success', title: 'Vyřízeno' });
},
});
const liftBanMut = useMutation({
mutationFn: (id: number) => adminLiftBan(id),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] }); toast({ status: 'success', title: 'Ban zrušen' }); },
});
React.useEffect(() => {
@@ -167,7 +181,10 @@ const CommentsAdminPage: React.FC = () => {
<Tr key={c.id}>
<Td>#{c.id}</Td>
<Td>#{c.user?.id} {c.user?.first_name} {c.user?.last_name}</Td>
<Td><Badge>{c.target_type}</Badge> <Text as="span">{c.target_id}</Text></Td>
<Td>
<Badge mr={2}>{c.target_type}</Badge>
<Text as="span">{c.target_label || c.target_id}</Text>
</Td>
<Td maxW="420px"><Text noOfLines={2}>{c.content}</Text></Td>
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
<Td>{(c as any).reports ? <Badge colorScheme={(c as any).reports > 2 ? 'red' : 'yellow'}>{(c as any).reports}</Badge> : '-'}</Td>
@@ -213,7 +230,7 @@ const CommentsAdminPage: React.FC = () => {
{(unbanQ.data?.items || []).map((r) => (
<Tr key={r.id}>
<Td>#{r.id}</Td>
<Td>#{r.user_id}</Td>
<Td>#{r.user?.id} {r.user?.first_name} {r.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{r.user?.email}</Text></Td>
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
<Td><Badge>{r.status}</Badge></Td>
<Td>
@@ -228,6 +245,39 @@ const CommentsAdminPage: React.FC = () => {
</Table>
</Box>
<Heading size="sm" mt={6} mb={2}>Zablokovaní uživatelé</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Důvod</Th>
<Th>Zabanován</Th>
<Th>Platné do</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{(bansQ.data?.items || []).map((b) => {
const untilText = !b.until ? 'Trvale' : new Date(b.until).toLocaleString();
return (
<Tr key={b.id}>
<Td>#{b.id}</Td>
<Td>#{b.user?.id} {b.user?.first_name} {b.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{b.user?.email}</Text></Td>
<Td>{b.reason || '-'}</Td>
<Td>{new Date(b.created_at).toLocaleString()}</Td>
<Td>{untilText}</Td>
<Td>
<Button size="xs" variant="outline" onClick={() => liftBanMut.mutate(b.id)}>Zrušit ban</Button>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* Ban modal */}
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
<ModalOverlay />
+24 -1
View File
@@ -101,12 +101,32 @@ const ContactsAdminPage: React.FC = () => {
const [savingSettings, setSavingSettings] = useState(false);
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
// Map of competition code -> alias (public aliases)
const [compAliasMap, setCompAliasMap] = useState<Record<string, string>>({});
useEffect(() => {
loadData();
loadSettings();
}, []);
// Load competition aliases map for filtering categories (so alias-named categories are visible)
useEffect(() => {
(async () => {
try {
const aliases = await getCompetitionAliasesPublic().catch(() => [] as Array<{ code?: string; alias?: string }>);
const map: Record<string, string> = {};
(aliases || []).forEach((a: any) => {
const code = String(a?.code || '').trim();
const alias = String(a?.alias || '').trim();
if (code && alias) map[code] = alias;
});
setCompAliasMap(map);
} catch {
// ignore
}
})();
}, []);
const loadData = async () => {
setLoading(true);
try {
@@ -170,12 +190,15 @@ const ContactsAdminPage: React.FC = () => {
for (const comp of facrCompetitions || []) {
const n = String(comp?.name || '').trim();
if (n) names.add(n);
const code = String(comp?.code || '').trim();
const alias = code && compAliasMap[code] ? String(compAliasMap[code]).trim() : '';
if (alias) names.add(alias);
}
return Array.from(names);
} catch {
return [] as string[];
}
}, [facrCompetitions]);
}, [facrCompetitions, compAliasMap]);
const filteredContactCategories = useMemo(() => {
try {
+137 -94
View File
@@ -88,6 +88,7 @@ const EngagementAdminPage: React.FC = () => {
const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
// Remove raw JSON editing, keep structured metadata only
const batchEnabled = false;
const [batch, setBatch] = React.useState({
base_url: '',
@@ -330,7 +331,9 @@ const EngagementAdminPage: React.FC = () => {
</FormControl>
</WrapItem>
<WrapItem>
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
{batchEnabled && (
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
)}
</WrapItem>
</Wrap>
<HStack align="start" spacing={4}>
@@ -361,22 +364,38 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl>
{(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && (
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput>
</FormControl>
)}
</HStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Pro avatar uveďte URL obrázku.</FormHelperText>
</FormControl>
<HStack>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
</HStack>
</>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
</FormControl>
</HStack>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Pro avatar uveďte URL obrázku. Pro odemknutí uploadu není třeba.</FormHelperText>
</FormControl>
<HStack>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
</HStack>
<FormControl>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} />
</FormControl>
</VStack>
{/* Metadata helpers */}
{form.type === 'merch_coupon' && (
<VStack align="stretch" spacing={2}>
@@ -384,10 +403,6 @@ const EngagementAdminPage: React.FC = () => {
<FormLabel>Kód kuponu</FormLabel>
<Input value={meta.coupon_code || ''} onChange={(e)=>setMetaField('coupon_code', e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Platnost do (ISO nebo datum)</FormLabel>
<Input value={meta.expires_at || ''} onChange={(e)=>setMetaField('expires_at', e.target.value)} placeholder="2025-12-31" />
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
@@ -432,16 +447,18 @@ const EngagementAdminPage: React.FC = () => {
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack>
</VStack>
<Box>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Box borderWidth="1px" borderRadius="md" p={2}>
{form.image_url ? (
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
) : (
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
)}
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<Box>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Box borderWidth="1px" borderRadius="md" p={2}>
{form.image_url ? (
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
) : (
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
)}
</Box>
</Box>
</Box>
)}
</HStack>
</VStack>
</Box>
@@ -468,6 +485,7 @@ const EngagementAdminPage: React.FC = () => {
<Th>Body</Th>
<Th>Sklad</Th>
<Th>Obrázek</Th>
<Th>Platnost</Th>
<Th>Aktivní</Th>
<Th>Akce</Th>
</Tr>
@@ -496,6 +514,20 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
{(() => {
const m = (r.metadata || {}) as any;
const vf = m.valid_from ? new Date(m.valid_from) : null;
const vt = m.valid_to ? new Date(m.valid_to) : null;
if (!vf && !vt) return <Text color="gray.500">-</Text>;
return (
<VStack align="start" spacing={0}>
{vf && <Text fontSize="xs">od {vf.toLocaleString()}</Text>}
{vt && <Text fontSize="xs">do {vt.toLocaleString()}</Text>}
</VStack>
);
})()}
</Td>
<Td>
<Switch
isChecked={!!r.active}
@@ -630,7 +662,6 @@ const EngagementAdminPage: React.FC = () => {
{editForm.type === 'merch_coupon' && (
<>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Platnost do</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
@@ -665,6 +696,16 @@ const EngagementAdminPage: React.FC = () => {
)}
</VStack>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
</FormControl>
</VStack>
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
@@ -699,76 +740,78 @@ const EngagementAdminPage: React.FC = () => {
</ModalContent>
</Modal>
{/* Batch create modal */}
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
<FormHelperText>Příklad: avatar-{`{i}`}.png avatar-1.png, avatar-2.png</FormHelperText>
</FormControl>
<HStack>
{/* Batch create modal (hidden) */}
{batchEnabled && (
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Počáteční index</FormLabel>
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předpona názvu</FormLabel>
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
</FormControl>
<HStack>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="merch_coupon">Merch kupon</option>
<option value="custom">Vlastní</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<HStack>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<NumberInputField />
</NumberInput>
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
<FormHelperText>Příklad: avatar-{`{i}`}.png avatar-1.png, avatar-2.png</FormHelperText>
</FormControl>
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Počáteční index</FormLabel>
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předpona názvu</FormLabel>
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
</FormControl>
<HStack>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="merch_coupon">Merch kupon</option>
<option value="custom">Vlastní</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<HStack>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
</HStack>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={batchModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={batchModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</ModalFooter>
</ModalContent>
</Modal>
)}
</AdminLayout>
);
};
+93 -2
View File
@@ -78,6 +78,8 @@ const FilesAdminPage: React.FC = () => {
const [forceDelete, setForceDelete] = useState(false);
const [scanResult, setScanResult] = useState<any>(null);
const [refreshResult, setRefreshResult] = useState<any>(null);
const [isBulkDeletingUnused, setIsBulkDeletingUnused] = useState(false);
const [isBulkDeletingDuplicates, setIsBulkDeletingDuplicates] = useState(false);
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
@@ -202,6 +204,71 @@ const FilesAdminPage: React.FC = () => {
return full || url;
};
const handleDeleteAllUnused = async () => {
if (unusedFiles.length === 0) return;
const confirmed = window.confirm(`Opravdu chcete smazat ${unusedFiles.length} nepoužívaných souborů? Tuto akci nelze vrátit.`);
if (!confirmed) return;
setIsBulkDeletingUnused(true);
let deleted = 0;
let failed = 0;
for (const f of unusedFiles) {
try {
await deleteFile(f.id, false);
deleted++;
} catch (e) {
failed++;
}
}
setIsBulkDeletingUnused(false);
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
toast({ title: 'Hromadné mazání dokončeno', description: `Smazáno ${deleted} / ${unusedFiles.length}. Chyby: ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
};
const handleDeleteAllDuplicates = async () => {
if (duplicateGroups.length === 0) return;
const confirmed = window.confirm('Smazat všechny duplicitní soubory bez použití? V každé skupině bude ponechán 1 soubor. Používané soubory budou přeskočeny.');
if (!confirmed) return;
setIsBulkDeletingDuplicates(true);
// Build list of files to delete: in each group keep one (oldest by created_at), delete the rest only if usage_count === 0
type FI = typeof duplicateFiles extends Record<string, infer A> ? A extends Array<infer B> ? B : never : never;
const toDelete: FI[] = [] as any;
duplicateGroups.forEach(([, files]) => {
if (files.length <= 1) return;
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
const [, ...rest] = sorted;
rest.forEach(f => {
if ((f.usage_count ?? 0) === 0) toDelete.push(f as any);
});
});
let deleted = 0;
let skipped = 0;
let failed = 0;
for (const f of toDelete) {
try {
await deleteFile((f as any).id, false);
deleted++;
} catch (e) {
failed++;
}
}
// Count duplicates with usage to report as skipped
duplicateGroups.forEach(([, files]) => {
if (files.length <= 1) return;
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
const [, ...rest] = sorted;
rest.forEach(f => { if ((f.usage_count ?? 0) > 0) skipped++; });
});
setIsBulkDeletingDuplicates(false);
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
toast({ title: 'Mazání duplicit dokončeno', description: `Smazáno ${deleted}, přeskočeno (použité) ${skipped}, chyby ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
};
// Mime type options
const mimeTypes = useMemo(() => {
const types = new Set<string>();
@@ -443,7 +510,19 @@ const FilesAdminPage: React.FC = () => {
</AlertDescription>
</Box>
</Alert>
<HStack>
<Spacer />
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
size="sm"
onClick={handleDeleteAllUnused}
isLoading={isBulkDeletingUnused}
isDisabled={unusedFiles.length === 0}
>
Vymazat vše
</Button>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
@@ -483,7 +562,19 @@ const FilesAdminPage: React.FC = () => {
</AlertDescription>
</Box>
</Alert>
<HStack>
<Spacer />
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
size="sm"
onClick={handleDeleteAllDuplicates}
isLoading={isBulkDeletingDuplicates}
isDisabled={duplicateGroups.length === 0}
>
Vymazat vše
</Button>
</HStack>
{duplicateGroups.length === 0 ? (
<Box textAlign="center" py={8}>
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
@@ -131,7 +131,7 @@ const GalleryAdminPage: React.FC = () => {
try {
// Use the api service which automatically includes authentication
await api.post('/admin/gallery/refresh');
await api.post('/admin/gallery/refresh', {});
toast({
title: 'Galerie obnovena',
+16 -46
View File
@@ -34,7 +34,7 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride } from '../../services/adminMatches';
import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -85,51 +85,21 @@ const MatchesAdminPage = () => {
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
queryKey: ['admin-matches-list-cache'],
queryFn: async () => {
// Read cached FACR club info
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
const json = await res.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
const items: any[] = comps.flatMap((c: any) =>
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
);
// Optional: stable sort by date ascending
const items = await fetchAdminMatches();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatDisplayDate = (s: string): string => {
const str = String(s || '').trim();
if (!str) return '';
try {
const dt = parse(str, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
} catch {}
const d2 = new Date(str);
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
return str;
};
items.sort((a, b) => {
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
return da - db;
});
return items.map((m: any) => ({
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
competition_id: m.competition_id,
home: m.home || m.home_team,
home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
away: m.away || m.away_team,
away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
score: m.score,
venue: m.venue,
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
}));
const parseTs = (obj: any): number => {
const s = String(obj?.date_time || obj?.date || '').trim();
if (!s) return Number.MAX_SAFE_INTEGER;
try {
const dt = parse(s, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return dt.getTime();
} catch {}
const d2 = new Date(s);
if (!isNaN(d2.getTime())) return d2.getTime();
return Number.MAX_SAFE_INTEGER;
};
items.sort((a: any, b: any) => parseTs(a) - parseTs(b));
return items;
},
});
@@ -374,7 +344,7 @@ const MatchesAdminPage = () => {
const saveMutation = useMutation({
mutationFn: async () => {
const externalMatchId: string = selected?.match_id || selected?.id;
const externalMatchId: string = String((selected?.match_id ?? selected?.id ?? '')).trim();
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = {
venue_override: form.venue_override,
@@ -132,7 +132,6 @@ const ADMIN_PAGE_PRESETS = [
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
{ value: 'categories', label: 'Kategorie', url: '/admin/kategorie' },
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
@@ -1149,6 +1148,8 @@ const NavigationAdminPage = () => {
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
childrenDroppableId={`admin-children-${item.id}`}
draggableChildPrefix={'admin-child'}
onEditTarget={(it) => openNavModal(it, undefined, true)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
@@ -602,6 +602,25 @@ export default function NewsletterAdminPage() {
<Text>Automatické rozesílky</Text>
</HStack>
</HStack>
{/* Weekly schedule detail */}
<Box mt={3} color={textSecondary} fontSize="sm">
{statusData?.weekly_day ? (
<>
<Text>
<b>Týdenní přehled:</b> {statusData?.weekly_enabled ? 'Zapnuto' : 'Vypnuto'}
{statusData?.weekly_enabled ? (
<> {({sun:'Neděle', mon:'Pondělí', tue:'Úterý', wed:'Středa', thu:'Čtvrtek', fri:'Pátek', sat:'Sobota'} as any)[statusData.weekly_day as any]}
{' '}{String(statusData?.weekly_hour ?? 9).padStart(2,'0')}:00</>
) : null}
</Text>
{statusData?.weekly_next_scheduled ? (
<Text>
<b>Příští týdenní odeslání:</b> {format(new Date(statusData.weekly_next_scheduled), 'd. M. yyyy HH:mm', { locale: cs })}
</Text>
) : null}
</>
) : null}
</Box>
{statusData?.next_approximate ? (
<Text color="gray.600" fontSize="sm" mt={2}>
Další automatický newsletter za {(() => {
@@ -639,11 +639,8 @@ const PlayersAdminPage: React.FC = () => {
// Czech pluralization for years: 1 rok, 24 roky, 5+ let (1114 let)
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
@@ -27,11 +27,14 @@ import {
Badge,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
const ShortlinksAdminPage: React.FC = () => {
const toast = useToast();
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const qc = useQueryClient();
const [targetUrl, setTargetUrl] = React.useState('');
const [title, setTitle] = React.useState('');
@@ -77,7 +80,7 @@ const ShortlinksAdminPage: React.FC = () => {
};
return (
<AdminLayout>
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
@@ -125,7 +128,9 @@ const ShortlinksAdminPage: React.FC = () => {
<HStack>
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
{isAdmin && (
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
)}
</HStack>
</Td>
</Tr>
@@ -138,7 +143,8 @@ const ShortlinksAdminPage: React.FC = () => {
</Table>
</Box>
{/* Stats modal */}
{/* Stats modal (admins only) */}
{isAdmin && (
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
<ModalOverlay />
<ModalContent>
@@ -190,6 +196,7 @@ const ShortlinksAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
</AdminLayout>
);
+225 -209
View File
@@ -35,6 +35,11 @@ import {
Divider,
Image,
FormHelperText,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import AdminLayout from '../../layouts/AdminLayout';
@@ -88,15 +93,15 @@ const SweepstakesAdminPage: React.FC = () => {
const [form, setForm] = useState<any>(defaultForm);
const [editing, setEditing] = useState<Sweepstake | null>(null);
// Prizes modal state
const prizesDisc = useDisclosure();
const [prizeSweep, setPrizeSweep] = useState<Sweepstake | null>(null);
// Prizes state (integrated tab)
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
const [prizeForm, setPrizeForm] = useState<{ name: string; quantity: number; value?: string; image_url?: string; kind?: 'physical'|'points'|'xp'|'points_xp'; points?: number; xp?: number }>(() => ({ name: '', quantity: 1, value: '', image_url: '', kind: 'physical', points: 0, xp: 0 }));
const [savingPrize, setSavingPrize] = useState<boolean>(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const rulesInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<number>(0);
const [coverPreview, setCoverPreview] = useState<string>('');
const onUploadImage = async (file?: File | null) => {
if (!file) return;
@@ -143,24 +148,19 @@ const SweepstakesAdminPage: React.FC = () => {
};
const openPrizes = async (it: Sweepstake) => {
try {
setPrizeSweep(it);
prizesDisc.onOpen();
const list = await adminListPrizes(it.id);
setPrizes(list);
} catch {
setPrizes([]);
}
openEdit(it);
setActiveTab(2);
try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); }
};
const addPrize = async () => {
if (!prizeSweep) return;
if (!editing) { toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' }); return; }
if (!prizeForm.name.trim()) { toast({ status: 'error', title: 'Název výhry je povinný' }); return; }
try {
setSavingPrize(true);
await adminCreatePrize(prizeSweep.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
await adminCreatePrize(editing.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
setPrizeForm({ name: '', quantity: 1, value: '', image_url: '' });
setPrizes(await adminListPrizes(prizeSweep.id));
setPrizes(await adminListPrizes(editing.id));
} catch (e:any) {
toast({ status: 'error', title: 'Nelze uložit výhru' });
} finally {
@@ -169,14 +169,14 @@ const SweepstakesAdminPage: React.FC = () => {
};
const delPrize = async (p: SweepstakePrize) => {
if (!prizeSweep) return;
if (!editing) return;
if (!window.confirm('Smazat výhru?')) return;
await adminDeletePrize(prizeSweep.id, p.id as any);
setPrizes(await adminListPrizes(prizeSweep.id));
await adminDeletePrize(editing.id, p.id as any);
setPrizes(await adminListPrizes(editing.id));
};
const movePrize = async (idx: number, dir: -1 | 1) => {
if (!prizeSweep) return;
if (!editing) return;
const arr = [...prizes];
const ni = idx + dir;
if (ni < 0 || ni >= arr.length) return;
@@ -184,12 +184,12 @@ const SweepstakesAdminPage: React.FC = () => {
arr[idx] = arr[ni];
arr[ni] = tmp;
setPrizes(arr);
await adminReorderPrizes(prizeSweep.id, arr.map(p => p.id as any));
await adminReorderPrizes(editing.id, arr.map(p => p.id as any));
};
useEffect(() => { load(); }, [status]);
const openCreate = () => { setEditing(null); setForm(defaultForm); onOpen(); };
const openCreate = () => { setEditing(null); setForm(defaultForm); setPrizes([]); setActiveTab(0); onOpen(); };
const openEdit = (it: Sweepstake) => {
setEditing(it);
setForm({
@@ -205,7 +205,9 @@ const SweepstakesAdminPage: React.FC = () => {
entry_cost_points: (it as any).entry_cost_points ?? 0,
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
});
setActiveTab(0);
onOpen();
(async ()=>{ try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); } })();
};
const save = async () => {
@@ -229,12 +231,16 @@ const SweepstakesAdminPage: React.FC = () => {
if (editing) {
await adminUpdateSweepstake(editing.id, payload);
toast({ status: 'success', title: 'Uloženo' });
onClose();
await load();
} else {
await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno' });
const created = await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno', description: 'Nyní můžete přidat výhry' });
setEditing(created);
setActiveTab(2);
try { setPrizes(await adminListPrizes(created.id)); } catch { setPrizes([]); }
await load();
}
onClose();
await load();
} catch (e: any) {
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
}
@@ -325,106 +331,206 @@ const SweepstakesAdminPage: React.FC = () => {
</Box>
)}
{/* Create/Edit Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
{/* Create/Edit Modal with tabs */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Popis</FormLabel>
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
</FormControl>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Začátek</FormLabel>
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Konec</FormLabel>
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Styl vizualizace</FormLabel>
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
<option value="wheel">Kolo štěstí</option>
<option value="cycler">Náhodný přepínač</option>
</Select>
</FormControl>
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
<FormLabel>Počet výherců</FormLabel>
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
<NumberInputField />
</NumberInput>
<FormHelperText>Max. 100 výherců</FormHelperText>
</FormControl>
</SimpleGrid>
<HStack>
<Button variant="outline" onClick={()=> editing ? openPrizes(editing) : toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' })}>Upravit výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
}}>5× 500 XP</Button>
</HStack>
<SimpleGrid columns={3} spacing={4}>
<FormControl>
<FormLabel>Vstupné (body)</FormLabel>
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Max. účastí / uživatel</FormLabel>
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Titulní obrázek</FormLabel>
<HStack>
<Image src={form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={(e)=>onUploadImage(e.target.files?.[0])} />
</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Pravidla</FormLabel>
<HStack>
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát PDF/obrázek
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</FormControl>
</SimpleGrid>
</VStack>
<Tabs index={activeTab} onChange={setActiveTab as any} isFitted>
<TabList>
<Tab>Základní</Tab>
<Tab>Termíny a limity</Tab>
<Tab>Výhry</Tab>
</TabList>
<TabPanels>
<TabPanel>
<VStack spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Popis</FormLabel>
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
</FormControl>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Titulní obrázek</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<Image src={coverPreview || form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(!f) return; try { setCoverPreview(URL.createObjectURL(f)); const r=await uploadFile(f); setForm((prev:any)=>({ ...prev, image_url: r.url })); setCoverPreview(''); toast({ status:'success', title:'Obrázek nahrán' }); } catch { toast({ status:'error', title:'Nahrávání selhalo' }); } }} />
</Button>
{form.image_url && (<Button size="sm" variant="ghost" onClick={()=>{ setForm((prev:any)=>({ ...prev, image_url: '' })); setCoverPreview(''); }}>Odebrat</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
</VStack>
</FormControl>
<FormControl>
<FormLabel>Pravidla</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát PDF/obrázek
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
{form.rules_url && (<Button as={RouterLink} to={form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</VStack>
</FormControl>
</SimpleGrid>
</VStack>
</TabPanel>
<TabPanel>
<VStack spacing={4} align="stretch">
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Začátek</FormLabel>
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Konec</FormLabel>
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Styl vizualizace</FormLabel>
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
<option value="wheel">Kolo štěstí</option>
<option value="cycler">Náhodný přepínač</option>
</Select>
</FormControl>
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
<FormLabel>Počet výherců</FormLabel>
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
<NumberInputField />
</NumberInput>
<FormHelperText>Max. 100 výherců</FormHelperText>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={3} spacing={4}>
<FormControl>
<FormLabel>Vstupné (body)</FormLabel>
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Max. účastí / uživatel</FormLabel>
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</SimpleGrid>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={3}>
<HStack>
<Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
}}>5× 500 XP</Button>
</HStack>
<Divider />
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
{prizes.map((p, i) => (
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
<IconButton aria-label="Nahoru" size="xs" icon={<ArrowUpIcon />} onClick={()=>movePrize(i,-1)} />
<IconButton aria-label="Dolů" size="xs" icon={<ArrowDownIcon />} onClick={()=>movePrize(i,1)} />
<Text flex={1} fontWeight="600">{p.name}</Text>
<Text>×{p.quantity}</Text>
{p.kind && (
<Text fontSize="xs" px={2} py={0.5} borderRadius="md" borderWidth="1px" color="gray.600">
{p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
</Text>
)}
<Text color="gray.500">{p.value}</Text>
<IconButton aria-label="Smazat" size="xs" colorScheme="red" icon={<DeleteIcon />} onClick={()=>delPrize(p)} />
</HStack>
))}
<Divider />
<Heading size="sm">Přidat výhru</Heading>
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={2} alignItems="end">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={prizeForm.name} onChange={(e)=>setPrizeForm({ ...prizeForm, name: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={prizeForm.quantity} onChange={(v)=>setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Hodnota</FormLabel>
<Input value={prizeForm.value} onChange={(e)=>setPrizeForm({ ...prizeForm, value: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<HStack>
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
<Button as="label" leftIcon={<FiUpload />} size="sm" variant="outline">
Upload
<Input type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
</Button>
</HStack>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} alignItems="end">
<FormControl>
<FormLabel>Typ výhry</FormLabel>
<Select value={prizeForm.kind || 'physical'} onChange={(e)=>setPrizeForm({ ...prizeForm, kind: e.target.value as any })}>
<option value="physical">Fyzická výhra</option>
<option value="points">Body</option>
<option value="xp">XP</option>
<option value="points_xp">Body + XP</option>
</Select>
</FormControl>
{(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={Number(prizeForm.points)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
{(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>XP</FormLabel>
<NumberInput min={0} value={Number(prizeForm.xp)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
</SimpleGrid>
<HStack justify="flex-end">
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
</HStack>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
<ModalFooter>
<HStack>
@@ -434,96 +540,6 @@ const SweepstakesAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
{/* Prizes Modal */}
<Modal isOpen={prizesDisc.isOpen} onClose={prizesDisc.onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Výhry {prizeSweep?.title}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
{prizes.map((p, i) => (
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
<IconButton aria-label="Nahoru" size="xs" icon={<ArrowUpIcon />} onClick={()=>movePrize(i,-1)} />
<IconButton aria-label="Dolů" size="xs" icon={<ArrowDownIcon />} onClick={()=>movePrize(i,1)} />
<Text flex={1} fontWeight="600">{p.name}</Text>
<Text>×{p.quantity}</Text>
{p.kind && (
<Text fontSize="xs" px={2} py={0.5} borderRadius="md" borderWidth="1px" color="gray.600">
{p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
</Text>
)}
<Text color="gray.500">{p.value}</Text>
<IconButton aria-label="Smazat" size="xs" colorScheme="red" icon={<DeleteIcon />} onClick={()=>delPrize(p)} />
</HStack>
))}
<Divider />
<Heading size="sm">Přidat výhru</Heading>
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={2} alignItems="end">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={prizeForm.name} onChange={(e)=>setPrizeForm({ ...prizeForm, name: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={prizeForm.quantity} onChange={(v)=>setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Hodnota</FormLabel>
<Input value={prizeForm.value} onChange={(e)=>setPrizeForm({ ...prizeForm, value: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<HStack>
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
<Button as="label" leftIcon={<FiUpload />} size="sm" variant="outline">
Upload
<Input type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
</Button>
</HStack>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} alignItems="end">
<FormControl>
<FormLabel>Typ výhry</FormLabel>
<Select value={prizeForm.kind || 'physical'} onChange={(e)=>setPrizeForm({ ...prizeForm, kind: e.target.value as any })}>
<option value="physical">Fyzická výhra</option>
<option value="points">Body</option>
<option value="xp">XP</option>
<option value="points_xp">Body + XP</option>
</Select>
</FormControl>
{(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={Number(prizeForm.points)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
{(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>XP</FormLabel>
<NumberInput min={0} value={Number(prizeForm.xp)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
</SimpleGrid>
<HStack justify="flex-end">
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<Button onClick={prizesDisc.onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Container>
</AdminLayout>
);
+54 -15
View File
@@ -63,6 +63,8 @@ function normalize(s: string): string {
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
out = out.replace(/\bn\.?\b/g, ' nad ');
out = out.replace(/\bp\.?\b/g, ' pod ');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
@@ -140,6 +142,16 @@ const TeamsAdminPage = () => {
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
// Lowercase-key index for robust UUID lookups irrespective of source casing
const overridesByIdLC = useMemo(() => {
const m: Record<string, { name?: string; logo_url?: string }> = {};
try {
for (const [k, v] of Object.entries(overridesById)) {
m[String(k).toLowerCase()] = v as any;
}
} catch {}
return m;
}, [overridesById]);
// 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 }> = {};
@@ -168,7 +180,7 @@ const TeamsAdminPage = () => {
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
if (r.team_id) teamIds.add(r.team_id);
if (r.team_id) teamIds.add(String(r.team_id).toLowerCase());
else {
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
if (derived) teamIds.add(derived);
@@ -200,8 +212,9 @@ const TeamsAdminPage = () => {
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 0: Admin override by team ID
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
const tid = teamId ? String(teamId).toLowerCase() : '';
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.logo_url) {
const u = String(overridesByIdLC[tid].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
@@ -254,8 +267,8 @@ const TeamsAdminPage = () => {
}
// Priority 2: logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
if (tid && sportLogosMap[tid]) {
return sportLogosMap[tid];
}
// Priority 3: FACR original
@@ -268,8 +281,9 @@ const TeamsAdminPage = () => {
};
const getName = (teamName?: string, teamId?: string) => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
const tid = teamId ? String(teamId).toLowerCase() : '';
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.name) {
return String(overridesByIdLC[tid].name || '').trim() || String(teamName || '');
}
// If no ID, but override exists for the normalized name, use canonical override name
try {
@@ -326,6 +340,7 @@ const TeamsAdminPage = () => {
for (const r of rows) {
const rawName = (r.team || '').trim();
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
if (teamId) teamId = String(teamId).toLowerCase();
if (!teamId && mainClubId) {
const rn = normalize(rawName);
if (
@@ -431,7 +446,30 @@ const TeamsAdminPage = () => {
const onSave = useMutation({
mutationFn: async () => {
if (!form.external_team_id) {
let extTeamId = (form.external_team_id || '').trim();
if (!extTeamId) {
let derived: string | undefined = undefined;
try { derived = deriveTeamIdFromLogoUrl(form.logo_url); } catch {}
if (!derived && selected?.teamLogoUrl) {
try { derived = deriveTeamIdFromLogoUrl(selected.teamLogoUrl); } catch {}
}
if (!derived) {
const primaryNameTry = (form.team_name || selected?.teamName || '').trim();
if (primaryNameTry) {
try {
const results = await searchClubs(primaryNameTry);
const norm = (s: string) => normalize(s);
const exact = results.find(r => norm(r.name) === norm(primaryNameTry));
const pick = exact || results[0];
if (pick?.id) derived = String(pick.id);
} catch {}
}
}
if (derived) {
extTeamId = derived;
}
}
if (!extTeamId) {
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
}
let logoUrl = (form.logo_url || '').trim();
@@ -443,8 +481,8 @@ const TeamsAdminPage = () => {
.filter(Boolean);
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
try {
if (!uploadedFile && form.external_team_id) {
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
if (!uploadedFile && extTeamId) {
const apiLogo = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (apiLogo) {
logoUrl = apiLogo;
}
@@ -482,10 +520,10 @@ const TeamsAdminPage = () => {
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
extTeamId,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
filename: `${extTeamId}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
@@ -497,7 +535,7 @@ const TeamsAdminPage = () => {
try {
let confirmedUrl: string | null = null;
for (let i = 0; i < 10; i++) {
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
confirmedUrl = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (confirmedUrl) break;
await new Promise((r) => setTimeout(r, 700));
}
@@ -532,7 +570,7 @@ const TeamsAdminPage = () => {
}
}
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
await putTeamLogoOverride(extTeamId, primaryName, logoUrl);
return true;
},
@@ -706,7 +744,8 @@ const TeamsAdminPage = () => {
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
<Td py={1.5}>
<Button size="xs" fontSize="xs" onClick={() => {
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const tidRaw = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const tid = tidRaw ? String(tidRaw).toLowerCase() : undefined;
const displayName = getName(r.team, tid);
const key = tid ? `id:${tid}` : normalize(displayName);
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
@@ -821,6 +821,15 @@ html {
100% { transform: translateX(-33.333%); }
}
/* Reduce motion preferences: disable continuous marquee-style animations */
@media (prefers-reduced-motion: reduce) {
.sponsors-slider .track,
.sponsors-scroller .belt,
.matches-slider.matches-ticker .ticker-belt {
animation: none !important;
}
}
/* Matches slider */
.matches-slider { margin: 12px 0 20px; }
.matches-slider .matches-grid {
+17
View File
@@ -29,6 +29,15 @@ export type CommentBan = {
reason?: string;
until?: string | null;
created_at: string;
created_by_id?: number;
user?: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
username?: string;
};
};
export async function adminListBans(): Promise<{ items: CommentBan[] }>{
@@ -49,6 +58,14 @@ export type UnbanRequest = {
created_at: string;
resolved_by_id?: number | null;
resolved_at?: string | null;
user?: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
username?: string;
};
};
export async function adminListUnbanRequests(): Promise<{ items: UnbanRequest[] }>{
+10
View File
@@ -84,6 +84,16 @@ export interface NewsletterStatus {
interval_minutes: number;
next_approximate: string;
newsletter_enabled?: boolean;
// Scheduling detail (optional; provided by backend)
weekly_enabled?: boolean;
weekly_day?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';
weekly_hour?: number;
weekly_next_scheduled?: string;
matches_enabled?: boolean;
reminder_lead_hours?: number;
results_enabled?: boolean;
quiet_start?: number;
quiet_end?: number;
}
export const getNewsletterStatus = async (): Promise<NewsletterStatus> => {
+1
View File
@@ -6,6 +6,7 @@ export type CommentItem = {
id: number;
target_type: TargetType;
target_id: string;
target_label?: string;
parent_id?: number | null;
content: string;
status?: 'visible' | 'hidden';
+9 -2
View File
@@ -36,8 +36,15 @@ export async function createPublicShortLink(payload: { target_url: string; title
}
export async function listShortLinks(): Promise<{ items: any[] }> {
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
return res.data;
// Prefer editor-accessible endpoint
try {
const res = await api.get<{ items: any[] }>('/shortlinks');
return res.data;
} catch (e) {
// Fallback to admin endpoint (admins only)
const res2 = await api.get<{ items: any[] }>('/admin/shortlinks');
return res2.data;
}
}
export async function getShortLinkStats(id: number | string): Promise<any> {
+1 -1
View File
@@ -121,7 +121,7 @@ func LoadConfig() {
// File upload settings
UploadDir: getEnv("UPLOAD_DIR", "./uploads"),
MaxUploadSize: int64(getEnvAsInt("MAX_UPLOAD_SIZE", 10)) * 1024 * 1024, // 10MB default
MaxUploadSize: int64(getEnvAsInt("MAX_UPLOAD_SIZE", 20)) * 1024 * 1024, // 20MB default
AllowedMimeTypes: []string{
// Images
"image/jpeg",
+360 -196
View File
@@ -72,7 +72,65 @@ func generateTeamNameAliases(name string) []string {
es := abbreviateAmpersand(e)
if es != "" && es != base && es != t && es != e { add(es) }
variants := []string{t, s, e, es}
// Generate PN-abbreviated variants like "... n. X." / "... p. X." from full forms (nad/pod)
makePNAbbrevs := func(s string) []string {
if strings.TrimSpace(s) == "" { return nil }
// Build variants for "nad <Word>" / "pod <Word>" ->
// n. W., n.W., n. W, n.W (and p. analogs)
mk := func(in string, re *regexp.Regexp, repPrefix string, withFinalDot bool, withSpace bool) string {
return re.ReplaceAllStringFunc(in, func(m string) string {
sub := re.FindStringSubmatch(m)
if len(sub) < 2 { return m }
letter := firstRuneUpper(sub[1])
if letter == "" { return m }
if withFinalDot {
if withSpace { return repPrefix + " " + letter + "." }
return repPrefix + letter + "."
}
if withSpace { return repPrefix + " " + letter }
return repPrefix + letter
})
}
// spaced + with dot
a := mk(s, rePNNadWord, "n.", true, true)
a = mk(a, rePNPodWord, "p.", true, true)
// no space + with dot
b := mk(s, rePNNadWord, "n.", true, false)
b = mk(b, rePNPodWord, "p.", true, false)
// spaced + without final dot
c := mk(s, rePNNadWord, "n.", false, true)
c = mk(c, rePNPodWord, "p.", false, true)
// no space + without final dot
d := mk(s, rePNNadWord, "n.", false, false)
d = mk(d, rePNPodWord, "p.", false, false)
// collect distinct, non-empty, changed variants
seen := map[string]struct{}{}
out := []string{}
addv := func(x string) {
x = strings.TrimSpace(x)
if x == "" || x == s { return }
if _, ok := seen[x]; ok { return }
seen[x] = struct{}{}
out = append(out, x)
}
addv(a); addv(b); addv(c); addv(d)
return out
}
for _, v := range []string{t, e} {
for _, p := range makePNAbbrevs(v) { add(p) }
}
// Also generate and add versions with common club prefixes stripped (SK, FK, MFK, TJ, 1.BFK, ...)
st := stripOrgPrefixes(t)
se := stripOrgPrefixes(e)
if st != "" && st != t { add(st) }
if se != "" && se != e { add(se) }
// PN abbreviations for stripped versions as well
for _, v := range []string{st, se} {
for _, p := range makePNAbbrevs(v) { add(p) }
}
variants := []string{t, s, e, es, st, se}
for _, v := range variants {
if strings.TrimSpace(v) == "" { continue }
nd := strings.ReplaceAll(v, ".", "")
@@ -84,7 +142,7 @@ func generateTeamNameAliases(name string) []string {
return out
}
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$`)
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)[\s]*$`)
func trimLegalSuffixes(s string) string {
return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, ""))
@@ -96,6 +154,25 @@ var (
reMultiSpace = regexp.MustCompile(`\s+`)
)
var (
rePNNadWord = regexp.MustCompile(`(?i)\bnad\s+([\p{L}-]+)`)
rePNPodWord = regexp.MustCompile(`(?i)\bpod\s+([\p{L}-]+)`)
)
// Remove leading organization tokens like "1.BFK", "FK", "SK", "TJ", "MFK", "SFC", ...
var reLeadingOrg = regexp.MustCompile(`(?i)^(?:\d+\.)?\s*(?:sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\.?\s+`)
func stripOrgPrefixes(s string) string {
x := strings.TrimSpace(s)
if x == "" { return x }
for {
nx := reLeadingOrg.ReplaceAllString(x, "")
nx = strings.TrimSpace(nx)
if nx == x || nx == "" { return nx }
x = nx
}
}
func expandPNAbbrev(s string) string {
if s == "" { return s }
x := reAbbrevP.ReplaceAllString(s, "pod ")
@@ -667,14 +744,15 @@ func (bc *BaseController) GetStandings(c *gin.Context) {
if err := bc.DB.Find(&tlovs).Error; err == nil {
tloByID := map[string]models.TeamLogoOverride{}
for _, it := range tlovs {
tloByID[it.ExternalTeamID] = it
if it.ExternalTeamID == "" { continue }
tloByID[strings.ToLower(it.ExternalTeamID)] = it
}
for i := range rows {
id, _ := rows[i]["team_id"].(string)
if id == "" {
continue
}
if tlo, ok := tloByID[id]; ok {
if tlo, ok := tloByID[strings.ToLower(id)]; ok {
if strings.TrimSpace(tlo.TeamName) != "" {
rows[i]["team"] = tlo.TeamName
}
@@ -2059,101 +2137,166 @@ func computeEstimatedReadMinutes(html string) int {
return minutes
}
// GetAdminMatches returns cached matches merged with DB overrides (admin only)
// GetAdminMatches returns cached FACR matches merged with DB overrides (admin only)
func (bc *BaseController) GetAdminMatches(c *gin.Context) {
// Read cached events
p := filepath.Join("cache", "prefetch", "events_upcoming.json")
f, err := os.Open(p)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
return
}
defer f.Close()
var matches []map[string]interface{}
if err := json.NewDecoder(f).Decode(&matches); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"})
return
}
// Read cached FACR club info (contains competitions with matches)
p := filepath.Join("cache", "prefetch", "facr_club_info.json")
f, err := os.Open(p)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached FACR matches"})
return
}
defer f.Close()
// Load overrides
var movs []models.MatchOverride
if err := bc.DB.Find(&movs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
return
}
movByID := map[string]models.MatchOverride{}
for _, m := range movs {
movByID[m.ExternalMatchID] = m
}
var facr struct {
Competitions []struct {
ID string `json:"id"`
Name string `json:"name"`
Matches []struct {
MatchID string `json:"match_id"`
DateTime string `json:"date_time"`
Date string `json:"date"`
Time string `json:"time"`
Home string `json:"home"`
HomeTeam string `json:"home_team"`
HomeID string `json:"home_id"`
HomeTeamID string `json:"home_team_id"`
HomeTeamFACRID string `json:"home_team_facr_id"`
Away string `json:"away"`
AwayTeam string `json:"away_team"`
AwayID string `json:"away_id"`
AwayTeamID string `json:"away_team_id"`
AwayTeamFACRID string `json:"away_team_facr_id"`
Score string `json:"score"`
Venue string `json:"venue"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
} `json:"matches"`
} `json:"competitions"`
}
if err := json.NewDecoder(f).Decode(&facr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist FACR cache"})
return
}
var tlovs []models.TeamLogoOverride
if err := bc.DB.Find(&tlovs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
return
}
tloByTeam := map[string]models.TeamLogoOverride{}
for _, t := range tlovs {
tloByTeam[t.ExternalTeamID] = t
}
// Helper to pick first non-empty string
firstNonEmpty := func(ss ...string) string {
for _, s := range ss {
s = strings.TrimSpace(s)
if s != "" {
return s
}
}
return ""
}
// Apply overrides in-place
for _, m := range matches {
// External match ID
var matchID string
if v, ok := m["match_id"].(string); ok {
matchID = v
} else if v2, ok2 := m["id"].(string); ok2 {
matchID = v2
}
// Flatten and normalize to a simple slice of maps
items := make([]map[string]any, 0, 256)
for _, comp := range facr.Competitions {
for _, m := range comp.Matches {
id := strings.TrimSpace(m.MatchID)
if id == "" {
continue
}
home := firstNonEmpty(m.Home, m.HomeTeam)
away := firstNonEmpty(m.Away, m.AwayTeam)
homeID := firstNonEmpty(m.HomeID, m.HomeTeamID, m.HomeTeamFACRID)
awayID := firstNonEmpty(m.AwayID, m.AwayTeamID, m.AwayTeamFACRID)
item := map[string]any{
"id": id,
"match_id": id,
"date_time": strings.TrimSpace(m.DateTime),
"date": strings.TrimSpace(m.Date),
"time": strings.TrimSpace(m.Time),
"competitionName": strings.TrimSpace(comp.Name),
"competition_id": strings.TrimSpace(comp.ID),
"home": home,
"home_team": home,
"home_id": homeID,
"away": away,
"away_team": away,
"away_id": awayID,
"score": strings.TrimSpace(m.Score),
"venue": strings.TrimSpace(m.Venue),
"home_logo_url": strings.TrimSpace(m.HomeLogoURL),
"away_logo_url": strings.TrimSpace(m.AwayLogoURL),
}
items = append(items, item)
}
}
if ov, ok := movByID[matchID]; ok {
if ov.HomeNameOverride != nil {
m["home"] = *ov.HomeNameOverride
m["home_team"] = *ov.HomeNameOverride
}
if ov.AwayNameOverride != nil {
m["away"] = *ov.AwayNameOverride
m["away_team"] = *ov.AwayNameOverride
}
if ov.VenueOverride != nil {
m["venue"] = *ov.VenueOverride
}
if ov.DateTimeOverride != nil {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
if ov.AwayLogoURL != nil {
m["away_logo_url"] = *ov.AwayLogoURL
}
}
// Load overrides and apply
var movs []models.MatchOverride
if err := bc.DB.Find(&movs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
return
}
movByID := map[string]models.MatchOverride{}
for _, m := range movs {
movByID[m.ExternalMatchID] = m
}
// Team-logo overrides by team id
if homeID, ok := m["home_id"].(string); ok {
if tlo, ok := tloByTeam[homeID]; ok {
if tlo.LogoURL != "" {
m["home_logo_url"] = tlo.LogoURL
}
if tlo.TeamName != "" {
m["home"] = tlo.TeamName
}
}
}
if awayID, ok := m["away_id"].(string); ok {
if tlo, ok := tloByTeam[awayID]; ok {
if tlo.LogoURL != "" {
m["away_logo_url"] = tlo.LogoURL
}
if tlo.TeamName != "" {
m["away"] = tlo.TeamName
}
}
}
}
var tlovs []models.TeamLogoOverride
if err := bc.DB.Find(&tlovs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
return
}
tloByTeam := map[string]models.TeamLogoOverride{}
for _, t := range tlovs {
tloByTeam[t.ExternalTeamID] = t
}
c.JSON(http.StatusOK, matches)
for _, m := range items {
matchID, _ := m["match_id"].(string)
if ov, ok := movByID[matchID]; ok {
if ov.HomeNameOverride != nil {
m["home"] = *ov.HomeNameOverride
m["home_team"] = *ov.HomeNameOverride
}
if ov.AwayNameOverride != nil {
m["away"] = *ov.AwayNameOverride
m["away_team"] = *ov.AwayNameOverride
}
if ov.VenueOverride != nil {
m["venue"] = *ov.VenueOverride
}
if ov.DateTimeOverride != nil {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
m["time"] = ov.DateTimeOverride.Format("15:04")
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
if ov.AwayLogoURL != nil {
m["away_logo_url"] = *ov.AwayLogoURL
}
}
if homeID, _ := m["home_id"].(string); homeID != "" {
if tlo, ok := tloByTeam[homeID]; ok {
if strings.TrimSpace(tlo.LogoURL) != "" {
m["home_logo_url"] = tlo.LogoURL
}
if strings.TrimSpace(tlo.TeamName) != "" {
m["home"] = tlo.TeamName
m["home_team"] = tlo.TeamName
}
}
}
if awayID, _ := m["away_id"].(string); awayID != "" {
if tlo, ok := tloByTeam[awayID]; ok {
if strings.TrimSpace(tlo.LogoURL) != "" {
m["away_logo_url"] = tlo.LogoURL
}
if strings.TrimSpace(tlo.TeamName) != "" {
m["away"] = tlo.TeamName
m["away_team"] = tlo.TeamName
}
}
}
}
c.JSON(http.StatusOK, items)
}
// --- Admin: Match & Team Logo Overrides ---
@@ -2411,6 +2554,8 @@ func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
return
}
// Best-effort: update public snapshot cache so frontend fallback sees latest aliases
go bc.writeTeamLogoOverridesCache()
c.JSON(http.StatusOK, item)
}
@@ -4841,115 +4986,134 @@ func (bc *BaseController) DeleteCategory(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
return
}
// Successful deletion
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}
// UploadImage handles generic file uploads (images, documents, archives)
func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
// Enforce maximum upload size (bytes)
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
return
}
name := strings.TrimSpace(f.Filename)
ext := strings.ToLower(filepath.Ext(name))
allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".pdf": true}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
return
}
// Light content sniffing to ensure the uploaded payload matches the declared extension
// and basic SVG sanitization (reject obvious script/event patterns).
if src, err := f.Open(); err == nil {
buf := make([]byte, 2048)
n, _ := src.Read(buf)
_ = src.Close()
detected := http.DetectContentType(buf[:n])
validCT := false
switch ext {
case ".pdf":
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
case ".svg":
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
default:
validCT = strings.HasPrefix(detected, "image/")
}
if !validCT {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
return
}
}
// Additional SVG content check against common script vectors
if ext == ".svg" {
if src2, err := f.Open(); err == nil {
defer src2.Close()
check := make([]byte, 65536)
n, _ := io.ReadFull(src2, check)
lower := strings.ToLower(string(check[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
}
}
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
}
_ = os.MkdirAll(dir, 0o755)
b := make([]byte, 8)
_, _ = rand.Read(b)
randHex := hex.EncodeToString(b)
outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext)
outPath := filepath.Join(dir, outName)
if err := c.SaveUploadedFile(f, outPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
urlPath := "/uploads/" + outName
ft := services.NewFileTracker(bc.DB)
mimeType := f.Header.Get("Content-Type")
_ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil)
f, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
// Enforce maximum upload size (bytes)
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
return
}
name := strings.TrimSpace(f.Filename)
ext := strings.ToLower(filepath.Ext(name))
// Allow images, PDFs, Office docs, text, archives, and common media
allowed := map[string]bool{
// Images
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true,
// Documents
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".csv": true,
// Archives
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true,
// Media
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true,
}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
return
}
// Light content sniffing to ensure payload matches extension and sanitize SVGs
if src, err := f.Open(); err == nil {
defer src.Close()
buf := make([]byte, 2048)
n, _ := io.ReadFull(src, buf)
if n < 0 { n = 0 }
dl := strings.ToLower(http.DetectContentType(buf[:n]))
// Build absolute URL from request (supports proxies)
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
// Take the first value if comma-separated
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" {
host = h
}
}
}
// Append forwarded port when host has no explicit port and it's non-default
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
absolute := scheme + "://" + host + urlPath
c.JSON(http.StatusOK, gin.H{
// Always return a backend-relative path for storage
"url": urlPath,
// Convenience absolute URL for immediate usage in UIs
"absolute_url": absolute,
// Basic metadata (best-effort)
"name": outName,
"type": mimeType,
"size": f.Size,
})
validCT := false
switch ext {
case ".pdf":
validCT = strings.Contains(dl, "pdf") || dl == "application/octet-stream"
case ".svg":
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
lower := strings.ToLower(string(buf[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
case ".docx", ".xlsx", ".pptx":
validCT = strings.Contains(dl, "officedocument") || strings.Contains(dl, "application/zip") || dl == "application/octet-stream"
case ".doc", ".xls", ".ppt":
validCT = strings.Contains(dl, "msword") || strings.Contains(dl, "vnd.ms-") || dl == "application/octet-stream"
case ".zip":
validCT = strings.Contains(dl, "zip") || dl == "application/octet-stream"
case ".rar":
validCT = strings.Contains(dl, "rar") || dl == "application/octet-stream"
case ".7z":
validCT = strings.Contains(dl, "7z") || dl == "application/octet-stream"
case ".tar":
validCT = strings.Contains(dl, "tar") || dl == "application/octet-stream"
case ".gz":
validCT = strings.Contains(dl, "gzip") || dl == "application/octet-stream"
case ".txt", ".csv":
validCT = strings.HasPrefix(dl, "text/") || dl == "application/octet-stream"
case ".mp4", ".avi", ".mov":
validCT = strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
case ".mp3", ".wav":
validCT = strings.HasPrefix(dl, "audio/") || dl == "application/octet-stream"
default:
validCT = strings.HasPrefix(dl, "image/")
}
if !validCT {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
return
}
}
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
}
_ = os.MkdirAll(dir, 0o755)
b := make([]byte, 8)
_, _ = rand.Read(b)
randHex := hex.EncodeToString(b)
outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext)
outPath := filepath.Join(dir, outName)
if err := c.SaveUploadedFile(f, outPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
urlPath := "/uploads/" + outName
ft := services.NewFileTracker(bc.DB)
mimeType := f.Header.Get("Content-Type")
_ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil)
// Build absolute URL from request (supports proxies)
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" { host = h }
}
}
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
absolute := scheme + "://" + host + urlPath
c.JSON(http.StatusOK, gin.H{
"url": urlPath,
"absolute_url": absolute,
"name": outName,
"type": mimeType,
"size": f.Size,
})
}
// Global newsletter automation instance (set from main)
+174 -8
View File
@@ -5,9 +5,12 @@ import (
"strings"
"time"
"encoding/json"
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -24,7 +27,54 @@ func (cc *CommentController) AdminListBans(c *gin.Context) {
if err := cc.DB.Where("until IS NULL OR until > ?", now).Order("created_at DESC").Find(&bans).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load bans"}); return
}
c.JSON(http.StatusOK, gin.H{"items": bans})
// Load users
uids := make([]uint, 0, len(bans))
seen := map[uint]bool{}
for _, b := range bans { if !seen[b.UserID] { uids = append(uids, b.UserID); seen[b.UserID] = true } }
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
users := map[uint]userRow{}
if len(uids) > 0 {
var rows []userRow
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
for _, r := range rows { users[r.ID] = r }
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
var profs []prof
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
}
type banOut struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Reason string `json:"reason"`
Until *time.Time `json:"until"`
CreatedAt time.Time `json:"created_at"`
CreatedByID uint `json:"created_by_id"`
User struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
Username string `json:"username,omitempty"`
} `json:"user"`
}
out := make([]banOut, 0, len(bans))
for _, b := range bans {
o := banOut{ ID: b.ID, UserID: b.UserID, Reason: b.Reason, Until: b.Until, CreatedAt: b.CreatedAt, CreatedByID: b.CreatedByID }
if u, ok := users[b.UserID]; ok {
o.User.ID = u.ID
o.User.FirstName = u.FirstName
o.User.LastName = u.LastName
o.User.Email = u.Email
o.User.Role = u.Role
}
if v, ok := usernameByID[b.UserID]; ok { o.User.Username = v }
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// Admin: lift a ban early by setting until = now
@@ -74,11 +124,12 @@ func (cc *CommentController) React(c *gin.Context) {
return
}
uid, _ := c.Get("userID")
// delete previous reaction for this user
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
// create new
// Upsert reaction to ensure exactly one reaction per (comment_id,user_id)
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
if err := cc.DB.Create(&r).Error; err != nil {
if err := cc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{"type": r.Type, "updated_at": time.Now()}),
}).Create(&r).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
@@ -140,8 +191,71 @@ func (cc *CommentController) AdminList(c *gin.Context) {
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
}
// Prepare target labels (titles) for admin visibility: articles and events
articleIDs := make([]uint, 0)
eventIDs := make([]uint, 0)
for _, it := range items {
switch it.TargetType {
case "article":
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
articleIDs = append(articleIDs, uint(v))
}
case "event":
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
eventIDs = append(eventIDs, uint(v))
}
}
}
articleTitleByID := map[uint]string{}
if len(articleIDs) > 0 {
type row struct{ ID uint; Title string }
var rows []row
_ = cc.DB.Table("articles").Select("id, title").Where("id IN ?", articleIDs).Scan(&rows).Error
for _, r := range rows { articleTitleByID[r.ID] = r.Title }
}
eventTitleByID := map[uint]string{}
if len(eventIDs) > 0 {
type row struct{ ID uint; Title string }
var rows []row
_ = cc.DB.Table("events").Select("id, title").Where("id IN ?", eventIDs).Scan(&rows).Error
for _, r := range rows { eventTitleByID[r.ID] = r.Title }
}
out := make([]commentOutput, 0, len(items))
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; if adminLiked[r.ID] { co.AdminLiked = true }; out = append(out, co) }
for _, r := range items {
co := toOutput(r)
if v, ok := repCounts[r.ID]; ok { co.Reports = v }
if adminLiked[r.ID] { co.AdminLiked = true }
// Compose human label for target
switch r.TargetType {
case "article":
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
if t, ok := articleTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
co.TargetLabel = fmt.Sprintf("Článek: %s (#%s)", t, r.TargetID)
} else {
co.TargetLabel = fmt.Sprintf("Článek #%s", r.TargetID)
}
} else {
co.TargetLabel = "Článek"
}
case "event":
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
if t, ok := eventTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
co.TargetLabel = fmt.Sprintf("Aktivita: %s (#%s)", t, r.TargetID)
} else {
co.TargetLabel = fmt.Sprintf("Aktivita #%s", r.TargetID)
}
} else {
co.TargetLabel = "Aktivita"
}
case "gallery_album":
co.TargetLabel = fmt.Sprintf("Galerie album #%s", r.TargetID)
case "youtube_video":
co.TargetLabel = fmt.Sprintf("YouTube video %s", r.TargetID)
default:
co.TargetLabel = fmt.Sprintf("%s #%s", r.TargetType, r.TargetID)
}
out = append(out, co)
}
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
@@ -181,9 +295,60 @@ func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
// Admin: list unban requests
func (cc *CommentController) AdminListUnban(c *gin.Context) {
// Only pending requests
var items []models.UnbanRequest
_ = cc.DB.Order("created_at DESC").Find(&items).Error
c.JSON(http.StatusOK, gin.H{"items": items})
_ = cc.DB.Where("status = ?", "pending").Order("created_at DESC").Find(&items).Error
// Load users and usernames
uids := make([]uint, 0, len(items))
seen := map[uint]bool{}
for _, it := range items { if !seen[it.UserID] { uids = append(uids, it.UserID); seen[it.UserID] = true } }
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
users := map[uint]userRow{}
if len(uids) > 0 {
var rows []userRow
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
for _, r := range rows { users[r.ID] = r }
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
var profs []prof
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
}
type unbanOut struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Message string `json:"message"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ResolvedByID *uint `json:"resolved_by_id,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
User struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
Username string `json:"username,omitempty"`
} `json:"user"`
}
out := make([]unbanOut, 0, len(items))
for _, it := range items {
var u userRow
if r, ok := users[it.UserID]; ok { u = r }
o := unbanOut{
ID: it.ID, UserID: it.UserID, Message: it.Message, Status: it.Status, CreatedAt: it.CreatedAt, ResolvedByID: it.ResolvedByID, ResolvedAt: it.ResolvedAt,
}
o.User.ID = u.ID
o.User.FirstName = u.FirstName
o.User.LastName = u.LastName
o.User.Email = u.Email
o.User.Role = u.Role
if v, ok := usernameByID[it.UserID]; ok { o.User.Username = v }
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// Admin: resolve unban request
@@ -218,6 +383,7 @@ type commentOutput struct {
ID uint `json:"id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetLabel string `json:"target_label,omitempty"`
ParentID *uint `json:"parent_id,omitempty"`
Content string `json:"content"`
Status string `json:"status"`
+141 -10
View File
@@ -33,16 +33,93 @@ func (cc *ContactController) GetContactMessages(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Pagination
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
if page <= 0 { page = 1 }
if limit <= 0 || limit > 200 { limit = 50 }
items, total, err := models.GetContactMessages(cc.DB, page, limit)
if err != nil {
if page <= 0 {
page = 1
}
if limit <= 0 || limit > 200 {
limit = 50
}
// Filters
search := strings.TrimSpace(c.DefaultQuery("search", ""))
isReadParam := strings.TrimSpace(c.DefaultQuery("isRead", ""))
var isRead *bool
if isReadParam != "" {
v := strings.ToLower(isReadParam)
if v == "true" || v == "1" {
t := true
isRead = &t
} else if v == "false" || v == "0" {
f := false
isRead = &f
}
}
// Sorting (map UI fields to DB columns)
sortBy := strings.TrimSpace(c.DefaultQuery("sortBy", "createdAt"))
sortOrder := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sortOrder", "desc")))
if sortOrder != "asc" {
sortOrder = "desc"
}
sortField := "created_at"
switch sortBy {
case "name":
sortField = "name"
case "email":
sortField = "email"
case "subject":
sortField = "subject"
case "createdAt", "created_at":
sortField = "created_at"
}
// Build query
q := cc.DB.Model(&models.ContactMessage{})
if search != "" {
s := "%" + strings.ToLower(search) + "%"
q = q.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(subject) LIKE ? OR LOWER(message) LIKE ?", s, s, s, s)
}
if isRead != nil {
q = q.Where("is_read = ?", *isRead)
}
// Count total
var total int64
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": limit})
// Fetch page
var items []models.ContactMessage
offset := (page - 1) * limit
if err := q.Order(sortField+" "+sortOrder).Offset(offset).Limit(limit).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
// Compute pages (ceil)
pages := 1
if limit > 0 {
pages = (int(total) + limit - 1) / limit
if pages == 0 {
pages = 1
}
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"pagination": gin.H{
"total": total,
"page": page,
"limit": limit,
"pages": pages,
},
})
}
// MarkMessageAsRead marks a message as read (admin)
@@ -75,19 +152,36 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active).Error
enabled := active > 0
// Persist flag
// Persist flag and, when enabling for the first time, ensure weekly digest is active with sane defaults
var s models.Settings
_ = cc.DB.First(&s).Error
if s.ID == 0 {
s = models.Settings{}
}
changed := false
if s.NewsletterEnabled != enabled {
s.NewsletterEnabled = enabled
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
} else {
_ = cc.DB.Save(&s).Error
changed = true
}
if enabled {
// Auto-activate weekly digest and preset schedule if not configured
if !s.EnableWeekly {
s.EnableWeekly = true
changed = true
}
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
s.NewsletterWeeklyDay = "sun"
changed = true
}
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
s.NewsletterWeeklyHour = 9
changed = true
}
}
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
} else if changed {
_ = cc.DB.Save(&s).Error
}
// Update runtime
@@ -736,6 +830,8 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
var total, active int64
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
var s models.Settings
_ = cc.DB.First(&s).Error
var subs []models.NewsletterSubscription
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
sample := make([]string, 0, len(subs))
@@ -751,6 +847,31 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
}
}
next := time.Now().Add(interval)
// Compute next scheduled weekly time (exact), using settings (default Sun 09:00)
weeklyDay := strings.ToLower(strings.TrimSpace(s.NewsletterWeeklyDay))
if weeklyDay == "" { weeklyDay = "sun" }
weeklyHour := s.NewsletterWeeklyHour
if weeklyHour < 0 || weeklyHour > 23 { weeklyHour = 9 }
// find next occurrence
now := time.Now()
target := time.Date(now.Year(), now.Month(), now.Day(), weeklyHour, 0, 0, 0, now.Location())
toWD := func(d string) time.Weekday {
switch d {
case "mon": return time.Monday
case "tue": return time.Tuesday
case "wed": return time.Wednesday
case "thu": return time.Thursday
case "fri": return time.Friday
case "sat": return time.Saturday
default: return time.Sunday
}
}
for i := 0; i < 8; i++ {
if target.Weekday() == toWD(weeklyDay) && target.After(now) {
break
}
target = target.Add(24 * time.Hour)
}
c.JSON(http.StatusOK, gin.H{
"total_subscribers": total,
"active_subscribers": active,
@@ -758,6 +879,16 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
"interval_minutes": int(interval.Minutes()),
"next_approximate": next,
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
// Scheduling detail
"weekly_enabled": s.EnableWeekly,
"weekly_day": weeklyDay,
"weekly_hour": weeklyHour,
"weekly_next_scheduled": target,
"matches_enabled": s.EnableMatchReminders,
"reminder_lead_hours": s.NewsletterReminderLeadHours,
"results_enabled": s.EnableResults,
"quiet_start": s.NewsletterQuietStart,
"quiet_end": s.NewsletterQuietEnd,
})
}
+69 -1
View File
@@ -21,6 +21,29 @@ type EngagementController struct {
Email email.EmailService
}
// parseMetaTime tries to parse time from metadata value which can be string (RFC3339 or YYYY-MM-DD) or numeric unix seconds.
func parseMetaTime(v interface{}) time.Time {
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
if s == "" { return time.Time{} }
if ts, err := time.Parse(time.RFC3339, s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02T15:04", s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02", s); err == nil { return ts }
case float64:
// JSON numbers decode to float64
if t <= 0 { return time.Time{} }
return time.Unix(int64(t), 0)
case int64:
if t <= 0 { return time.Time{} }
return time.Unix(t, 0)
case int:
if t <= 0 { return time.Time{} }
return time.Unix(int64(t), 0)
}
return time.Time{}
}
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
return &EngagementController{DB: db, Email: es}
}
@@ -224,7 +247,31 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
c.JSON(http.StatusOK, items)
// Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to.
now := time.Now()
filtered := make([]models.RewardItem, 0, len(items))
for _, it := range items {
// Mandatory unlock reward is always available
if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") {
filtered = append(filtered, it)
continue
}
var startPtr, endPtr *time.Time
if it.Metadata != nil {
if v, ok := it.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
}
if v, ok := it.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
} else if v2, ok2 := it.Metadata["expires_at"]; ok2 { // alias
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
}
}
if startPtr != nil && now.Before(*startPtr) { continue }
if endPtr != nil && now.After(*endPtr) { continue }
filtered = append(filtered, it)
}
c.JSON(http.StatusOK, filtered)
}
// POST /api/v1/engagement/redeem (auth)
@@ -252,6 +299,27 @@ func (ec *EngagementController) Redeem(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not active"})
return
}
// Check validity window (metadata.valid_from/valid_to or expires_at)
if item.Metadata != nil {
var startPtr, endPtr *time.Time
if v, ok := item.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
}
if v, ok := item.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
} else if v2, ok2 := item.Metadata["expires_at"]; ok2 {
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
}
now := time.Now()
if startPtr != nil && now.Before(*startPtr) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not currently available"})
return
}
if endPtr != nil && now.After(*endPtr) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward validity has ended"})
return
}
}
if item.Stock == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"})
return
+2 -2
View File
@@ -228,7 +228,7 @@ func (ec *ErrorController) AdminListExternal(c *gin.Context) {
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
@@ -263,7 +263,7 @@ func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
+1 -1
View File
@@ -620,7 +620,7 @@ func (fc *FilesController) RefreshFileTracking(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": "File tracking refreshed successfully",
"message": "Sledování souborů bylo úspěšně aktualizováno",
"stats": stats,
})
}
+93 -22
View File
@@ -162,35 +162,106 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
return
}
var updates models.NavigationItem
if err := c.ShouldBindJSON(&updates); err != nil {
// Bind into a generic map to know which fields are present (partial update)
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields
item.Label = updates.Label
item.URL = updates.URL
item.Icon = updates.Icon
item.Type = updates.Type
item.PageType = updates.PageType
item.PageID = updates.PageID
item.Visible = updates.Visible
item.DisplayOrder = updates.DisplayOrder
item.ParentID = updates.ParentID
item.Target = updates.Target
item.CSSClass = updates.CSSClass
item.RequiresAuth = updates.RequiresAuth
item.RequiresAdmin = updates.RequiresAdmin
if err := nc.DB.Save(&item).Error; err != nil {
// Allow-list of updatable fields and basic type normalization
updates := map[string]interface{}{}
if v, ok := raw["label"]; ok {
if s, ok2 := v.(string); ok2 { updates["label"] = s }
}
if v, ok := raw["url"]; ok {
if s, ok2 := v.(string); ok2 { updates["url"] = s }
}
if v, ok := raw["icon"]; ok {
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
}
if v, ok := raw["type"]; ok {
if s, ok2 := v.(string); ok2 { updates["type"] = s }
}
if v, ok := raw["page_type"]; ok {
if s, ok2 := v.(string); ok2 { updates["page_type"] = s }
}
if v, ok := raw["page_id"]; ok {
switch t := v.(type) {
case float64:
updates["page_id"] = int(t)
case int:
updates["page_id"] = t
case int32:
updates["page_id"] = int(t)
case int64:
updates["page_id"] = int(t)
case nil:
updates["page_id"] = nil
}
}
if v, ok := raw["visible"]; ok {
if b, ok2 := v.(bool); ok2 { updates["visible"] = b }
}
if v, ok := raw["display_order"]; ok {
switch t := v.(type) {
case float64:
updates["display_order"] = int(t)
case int:
updates["display_order"] = t
case int32:
updates["display_order"] = int(t)
case int64:
updates["display_order"] = int(t)
}
}
if v, ok := raw["parent_id"]; ok {
switch t := v.(type) {
case float64:
updates["parent_id"] = int(t)
case int:
updates["parent_id"] = t
case int32:
updates["parent_id"] = int(t)
case int64:
updates["parent_id"] = int(t)
case nil:
updates["parent_id"] = nil
}
}
if v, ok := raw["target"]; ok {
if s, ok2 := v.(string); ok2 { updates["target"] = s }
}
if v, ok := raw["css_class"]; ok {
if s, ok2 := v.(string); ok2 { updates["css_class"] = s }
}
if v, ok := raw["requires_auth"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_auth"] = b }
}
if v, ok := raw["requires_admin"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_admin"] = b }
}
if len(updates) == 0 {
// Nothing to update
c.JSON(http.StatusOK, item)
return
}
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update navigation item",
"details": err.Error(),
})
return
}
// Reload to return consistent, fresh data
if err := nc.DB.First(&item, id).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Updated", "id": id})
return
}
c.JSON(http.StatusOK, item)
}
@@ -524,8 +595,8 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
if err := createChild(obsah, "Kategorie", "categories", 2); err != nil { return err }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
// Kategorie admin page removed (categories derived from competition aliases)
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
media, err := createCategory("Média")
if err != nil { return err }
+10 -9
View File
@@ -301,10 +301,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
admin.DELETE("/competition-aliases/:code", baseController.DeleteCompetitionAlias)
admin.POST("/competition-aliases/reorder", baseController.ReorderCompetitionAliases)
// Categories (admin)
admin.POST("/categories", baseController.CreateCategory)
admin.PUT("/categories/:id", baseController.UpdateCategory)
admin.DELETE("/categories/:id", baseController.DeleteCategory)
// Categories admin removed: categories are derived from competition aliases
// Settings (singleton)
admin.GET("/settings", baseController.GetSettings)
@@ -621,7 +618,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
api.GET("/matches", baseController.GetMatches)
api.GET("/matches/history", baseController.GetMatchesHistory)
api.GET("/standings", baseController.GetStandings)
api.GET("/gallery/albums", galleryController.GetGalleryAlbums)
api.GET("/gallery/albums/:id", galleryController.GetGalleryAlbum)
api.GET("/gallery/proxy-image", galleryController.ProxyImage)
@@ -634,10 +631,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
api.GET("/sweepstakes/current", sweepstakesController.GetCurrent)
api.GET("/sweepstakes/:id/visual", sweepstakesController.PublicVisualData)
api.GET("/polls", pollController.GetPolls)
api.GET("/polls/:id", pollController.GetPoll)
api.POST("/polls/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
api.GET("/polls/:id/results", pollController.GetPollResults)
pollsPub := api.Group("/polls")
pollsPub.Use(middleware.JWTOptional(db))
{
pollsPub.GET("", pollController.GetPolls)
pollsPub.GET("/:id", pollController.GetPoll)
pollsPub.POST("/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
pollsPub.GET("/:id/results", pollController.GetPollResults)
}
api.POST("/contact", middleware.RateLimit(10, time.Minute), contactController.SubmitContactForm)
api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter)

Some files were not shown because too many files have changed in this diff Show More