dev day #90 🥳
@@ -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 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
|
||||||
@@ -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 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
|
||||||
@@ -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 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
|
||||||
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-09T13:55:44Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:19Z","last_modified":""}
|
||||||
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:26Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:23Z","last_modified":""}
|
||||||
@@ -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": "FK Kofola Krnov",
|
||||||
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
|
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"lastUpdated":"2025-11-09T13:55:44Z"}
|
{"lastUpdated":"2025-11-12T19:22:23Z"}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
{
|
{
|
||||||
"baseURL": "http://localhost:8080/api/v1",
|
"baseURL": "http://localhost:8080/api/v1",
|
||||||
"duration_ms": 67,
|
"duration_ms": 5950,
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"path": "/sponsors",
|
||||||
|
"file": "sponsors.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/events/upcoming",
|
||||||
|
"file": "events_upcoming.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "/public/team-logo-overrides",
|
"path": "/public/team-logo-overrides",
|
||||||
"file": "team_logo_overrides.json",
|
"file": "team_logo_overrides.json",
|
||||||
@@ -27,16 +37,6 @@
|
|||||||
"file": "articles.json",
|
"file": "articles.json",
|
||||||
"ok": true
|
"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",
|
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
|
||||||
"file": "facr_club_info.json",
|
"file": "facr_club_info.json",
|
||||||
@@ -48,5 +48,5 @@
|
|||||||
"ok": true
|
"ok": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2025-11-09T13:55:44Z"
|
"lastUpdated": "2025-11-12T19:22:23Z"
|
||||||
}
|
}
|
||||||
@@ -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 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
|
||||||
@@ -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 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
|
||||||
@@ -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 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
|
||||||
@@ -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 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
|
||||||
@@ -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"}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -97,6 +97,6 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-11-09T10:47:48Z"
|
"fetched_at": "2025-11-12T16:22:38Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"fetched_at": "2025-11-09T10:47:48Z",
|
"fetched_at": "2025-11-12T16:22:38Z",
|
||||||
"link": ""
|
"link": ""
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"photos_count": 108,
|
"photos_count": 108,
|
||||||
"title": "Kategorie U15 Hranice 5:1 FK Krnov",
|
"title": "Kategorie U15 Hranice 5:1 FK Krnov",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130762",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130762",
|
||||||
"views_count": 63
|
"views_count": 83
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2. 11. 2025",
|
"date": "2. 11. 2025",
|
||||||
@@ -213,7 +213,112 @@
|
|||||||
"photos_count": 64,
|
"photos_count": 64,
|
||||||
"title": "Kategorie U14 Hranice 12:1 FK Krnov",
|
"title": "Kategorie U14 Hranice 12:1 FK Krnov",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130503",
|
"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",
|
"date": "28. 10. 2025",
|
||||||
@@ -318,7 +423,7 @@
|
|||||||
"photos_count": 81,
|
"photos_count": 81,
|
||||||
"title": "Kategorie muži FK Krnov 1:2 Slavia Orlová",
|
"title": "Kategorie muži FK Krnov 1:2 Slavia Orlová",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102134",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102134",
|
||||||
"views_count": 116
|
"views_count": 126
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "28. 10. 2025",
|
"date": "28. 10. 2025",
|
||||||
@@ -453,112 +558,7 @@
|
|||||||
"photos_count": 38,
|
"photos_count": 38,
|
||||||
"title": "Kategorie U14 FK Krnov 1:9 Poruba - Petřvald",
|
"title": "Kategorie U14 FK Krnov 1:9 Poruba - Petřvald",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14101976",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14101976",
|
||||||
"views_count": 100
|
"views_count": 108
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "26. 10. 2025",
|
"date": "26. 10. 2025",
|
||||||
@@ -653,112 +653,7 @@
|
|||||||
"photos_count": 76,
|
"photos_count": 76,
|
||||||
"title": "Kategorie muži FK Krnov 1:3 Frenštát p. Radhoštěm",
|
"title": "Kategorie muži FK Krnov 1:3 Frenštát p. Radhoštěm",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087623",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087623",
|
||||||
"views_count": 102
|
"views_count": 107
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "25. 10. 2025",
|
"date": "25. 10. 2025",
|
||||||
@@ -863,7 +758,112 @@
|
|||||||
"photos_count": 52,
|
"photos_count": 52,
|
||||||
"title": "Kategorie U14 FK Krnov 0:10 Třinec",
|
"title": "Kategorie U14 FK Krnov 0:10 Třinec",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087590",
|
"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",
|
"date": "18. 10. 2025",
|
||||||
@@ -968,7 +968,7 @@
|
|||||||
"photos_count": 75,
|
"photos_count": 75,
|
||||||
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
|
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
|
||||||
"views_count": 115
|
"views_count": 118
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "12. 10. 2025",
|
"date": "12. 10. 2025",
|
||||||
@@ -1073,9 +1073,9 @@
|
|||||||
"photos_count": 112,
|
"photos_count": 112,
|
||||||
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
|
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
|
"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"
|
"input_link": "https://eu.zonerama.com/FKKofolaKrnov/1470757"
|
||||||
}
|
}
|
||||||
@@ -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`.)
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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
|
||||||
@@ -86,7 +86,6 @@ const PrivacyPolicyPage = lazy(() => import('./pages/legal/PrivacyPolicyPage'));
|
|||||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
||||||
const ArticlesAdminPage = lazy(() => import('./pages/admin/ArticlesAdminPage'));
|
const ArticlesAdminPage = lazy(() => import('./pages/admin/ArticlesAdminPage'));
|
||||||
const SponsorsAdminPage = lazy(() => import('./pages/admin/SponsorsAdminPage'));
|
const SponsorsAdminPage = lazy(() => import('./pages/admin/SponsorsAdminPage'));
|
||||||
const CategoriesAdminPage = lazy(() => import('./pages/admin/CategoriesAdminPage'));
|
|
||||||
const MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
|
const MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
|
||||||
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
|
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
|
||||||
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
|
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
|
||||||
@@ -132,7 +131,7 @@ const FontLoader: React.FC = () => {
|
|||||||
|
|
||||||
// Public route wrapper
|
// Public route wrapper
|
||||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading, user } = useAuth();
|
||||||
const [checkingSetup, setCheckingSetup] = useState(true);
|
const [checkingSetup, setCheckingSetup] = useState(true);
|
||||||
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
|
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -156,7 +155,14 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
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;
|
const currentPath = window.location.pathname;
|
||||||
@@ -261,6 +267,7 @@ const AppLazy: React.FC = () => {
|
|||||||
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
|
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
|
||||||
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
|
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
|
||||||
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
|
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
|
||||||
|
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
@@ -272,7 +279,6 @@ const AppLazy: React.FC = () => {
|
|||||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||||
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
||||||
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
||||||
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
|
|
||||||
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
||||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
||||||
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import ActivitiesCalendarPage from './pages/ActivitiesCalendarPage';
|
|||||||
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
|
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
|
||||||
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
|
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
|
||||||
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
|
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
|
||||||
import CategoriesAdminPage from './pages/admin/CategoriesAdminPage';
|
|
||||||
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
|
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
|
||||||
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
|
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
|
||||||
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
|
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
|
||||||
@@ -493,7 +492,6 @@ const App: React.FC = () => {
|
|||||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||||
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
||||||
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
||||||
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
|
|
||||||
{/* moved to editor-accessible routes below */}
|
{/* moved to editor-accessible routes below */}
|
||||||
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
||||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
<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: '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: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
|
||||||
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
|
{ 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: '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: '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 },
|
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
|
||||||
|
|||||||
@@ -263,9 +263,14 @@ const AdminSidebar = ({
|
|||||||
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
|
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load dynamic navigation from API
|
// Load dynamic navigation from API (admins only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
// Editors should not call admin-only navigation endpoint; use fallback
|
||||||
|
if (!isAdmin) {
|
||||||
|
setNavLoading(false);
|
||||||
|
return () => { active = false };
|
||||||
|
}
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const items = await getAllNavigationItems();
|
const items = await getAllNavigationItems();
|
||||||
@@ -470,8 +475,8 @@ const AdminSidebar = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
|
{/* Ensure Shortlinks is present even if not configured in dynamic nav (admins only) */}
|
||||||
{!hasShortlinks && (
|
{isAdmin && !hasShortlinks && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaLink}
|
icon={FaLink}
|
||||||
to="/admin/shortlinks"
|
to="/admin/shortlinks"
|
||||||
@@ -481,8 +486,8 @@ const AdminSidebar = ({
|
|||||||
</NavItem>
|
</NavItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ensure Engagement page is present even if not configured in dynamic nav */}
|
{/* Ensure Engagement page is present even if not configured in dynamic nav (admins only) */}
|
||||||
{!hasEngagement && (
|
{isAdmin && !hasEngagement && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaAward}
|
icon={FaAward}
|
||||||
to="/admin/engagement"
|
to="/admin/engagement"
|
||||||
@@ -492,8 +497,8 @@ const AdminSidebar = ({
|
|||||||
</NavItem>
|
</NavItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ensure Comments moderation is present even if not configured in dynamic nav */}
|
{/* Ensure Comments moderation is present even if not configured in dynamic nav (admins only) */}
|
||||||
{!hasComments && (
|
{isAdmin && !hasComments && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaComments}
|
icon={FaComments}
|
||||||
to="/admin/komentare"
|
to="/admin/komentare"
|
||||||
@@ -502,8 +507,8 @@ const AdminSidebar = ({
|
|||||||
Komentáře
|
Komentáře
|
||||||
</NavItem>
|
</NavItem>
|
||||||
)}
|
)}
|
||||||
{/* Ensure Sweepstakes is present even if not configured in dynamic nav */}
|
{/* Ensure Sweepstakes is present even if not configured in dynamic nav (admins only) */}
|
||||||
{!hasSweepstakes && (
|
{isAdmin && !hasSweepstakes && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaGift}
|
icon={FaGift}
|
||||||
to="/admin/sweepstakes"
|
to="/admin/sweepstakes"
|
||||||
@@ -512,8 +517,8 @@ const AdminSidebar = ({
|
|||||||
Soutěže
|
Soutěže
|
||||||
</NavItem>
|
</NavItem>
|
||||||
)}
|
)}
|
||||||
{/* Ensure Competition Aliases is present even if not configured in dynamic nav */}
|
{/* Ensure Competition Aliases is present even if not configured in dynamic nav (admins only) */}
|
||||||
{!hasCompetitionAliases && (
|
{isAdmin && !hasCompetitionAliases && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaAward}
|
icon={FaAward}
|
||||||
to="/admin/aliasy-soutezi"
|
to="/admin/aliasy-soutezi"
|
||||||
@@ -523,8 +528,8 @@ const AdminSidebar = ({
|
|||||||
</NavItem>
|
</NavItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ensure Clothing is present even if not configured in dynamic nav */}
|
{/* Ensure Clothing is present even if not configured in dynamic nav (admins only) */}
|
||||||
{!hasClothing && (
|
{isAdmin && !hasClothing && (
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaTshirt}
|
icon={FaTshirt}
|
||||||
to="/admin/obleceni"
|
to="/admin/obleceni"
|
||||||
@@ -541,13 +546,15 @@ const AdminSidebar = ({
|
|||||||
Hlavní
|
Hlavní
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<NavItem
|
{isAdmin && (
|
||||||
icon={FaTachometerAlt}
|
<NavItem
|
||||||
to="/admin"
|
icon={FaTachometerAlt}
|
||||||
onClick={onClose}
|
to="/admin"
|
||||||
>
|
onClick={onClose}
|
||||||
Nástěnka
|
>
|
||||||
</NavItem>
|
Nástěnka
|
||||||
|
</NavItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<NavItem
|
<NavItem
|
||||||
@@ -563,26 +570,30 @@ const AdminSidebar = ({
|
|||||||
Obsah
|
Obsah
|
||||||
</Text>
|
</Text>
|
||||||
{/* Core sports entities first */}
|
{/* Core sports entities first */}
|
||||||
<NavItem
|
{isAdmin && (
|
||||||
icon={FaUsers}
|
<>
|
||||||
to="/admin/tymy"
|
<NavItem
|
||||||
onClick={onClose}
|
icon={FaUsers}
|
||||||
>
|
to="/admin/tymy"
|
||||||
Týmy
|
onClick={onClose}
|
||||||
</NavItem>
|
>
|
||||||
<NavItem
|
Týmy
|
||||||
icon={FaCalendarAlt}
|
</NavItem>
|
||||||
to="/admin/zapasy"
|
<NavItem
|
||||||
onClick={onClose}
|
icon={FaCalendarAlt}
|
||||||
>
|
to="/admin/zapasy"
|
||||||
{/* Add subtle scroller hint */}
|
onClick={onClose}
|
||||||
<Text as="span">
|
>
|
||||||
Zápasy
|
{/* Add subtle scroller hint */}
|
||||||
<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')}>
|
<Text as="span">
|
||||||
scroller
|
Zápasy
|
||||||
</Text>
|
<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')}>
|
||||||
</Text>
|
scroller
|
||||||
</NavItem>
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</NavItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaCalendarAlt}
|
icon={FaCalendarAlt}
|
||||||
to="/admin/aktivity"
|
to="/admin/aktivity"
|
||||||
@@ -597,13 +608,15 @@ const AdminSidebar = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem
|
{isAdmin && (
|
||||||
icon={FaFutbol}
|
<NavItem
|
||||||
to="/admin/hraci"
|
icon={FaFutbol}
|
||||||
onClick={onClose}
|
to="/admin/hraci"
|
||||||
>
|
onClick={onClose}
|
||||||
Hráči
|
>
|
||||||
</NavItem>
|
Hráči
|
||||||
|
</NavItem>
|
||||||
|
)}
|
||||||
{/* Other content */}
|
{/* Other content */}
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaNewspaper}
|
icon={FaNewspaper}
|
||||||
@@ -613,110 +626,114 @@ const AdminSidebar = ({
|
|||||||
Články
|
Články
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaFileAlt}
|
icon={FaLink}
|
||||||
to="/admin/kategorie"
|
to="/admin/shortlinks"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Kategorie
|
Zkrácené odkazy
|
||||||
</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
|
|
||||||
</NavItem>
|
</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} />
|
<Divider my={2} />
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|||||||
@@ -86,24 +86,21 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
|||||||
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
|
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
|
||||||
|
|
||||||
// Deterministic shortlink code to keep link stable across generations
|
// 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 = {
|
const payload = {
|
||||||
target_url: fullUrl,
|
target_url: fullUrl,
|
||||||
title: article?.title || activity?.title || 'Link',
|
title: article?.title || activity?.title || 'Link',
|
||||||
source_type: article ? 'article' : (activity ? 'event' : 'other'),
|
source_type: article ? 'article' : (activity ? 'event' : 'other'),
|
||||||
source_id: article?.id || activity?.id,
|
source_id: article?.id || activity?.id,
|
||||||
code,
|
|
||||||
} as any;
|
} as any;
|
||||||
let sUrl = '';
|
let sUrl = '';
|
||||||
try {
|
try {
|
||||||
const res = await createShortLink(payload);
|
const res = await createShortLink(payload);
|
||||||
sUrl = res?.short_url || '';
|
sUrl = res?.short_url || '';
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
const resPub = await createPublicShortLink({ target_url: fullUrl, title: article?.title || activity?.title || 'Link' });
|
||||||
sUrl = origin ? `${origin}/s/${code}` : fullUrl;
|
sUrl = resPub?.short_url || fullUrl;
|
||||||
} catch {
|
} catch {
|
||||||
sUrl = fullUrl;
|
sUrl = fullUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,18 +74,65 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const quillRef = useRef<ReactQuill | null>(null);
|
const quillRef = useRef<ReactQuill | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
const selectedImageIdRef = useRef<string | null>(null);
|
const selectedImageIdRef = useRef<string | null>(null);
|
||||||
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
|
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 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 [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
// Ensure component is mounted before rendering Quill
|
// Ensure component is mounted before rendering Quill
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
return () => setIsMounted(false);
|
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
|
// Keep onChange ref up to date
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,7 +190,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
full: [
|
full: [
|
||||||
[{ header: [1, 2, 3, false] }],
|
[{ header: [1, 2, 3, false] }],
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
|
[{ color: [] }, { background: [] }],
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
|
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
|
||||||
[{ align: [] }],
|
[{ align: [] }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
@@ -153,7 +200,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
basic: [
|
basic: [
|
||||||
[{ header: [1, 2, 3, false] }],
|
[{ header: [1, 2, 3, false] }],
|
||||||
['bold', 'italic', 'underline'],
|
['bold', 'italic', 'underline'],
|
||||||
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
|
[{ color: [] }, { background: [] }],
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
|
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
|
||||||
[{ align: [] }],
|
[{ align: [] }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
@@ -302,16 +349,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
setTimeout(() => setIsListStyleOpen(true), 0);
|
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: {
|
clipboard: {
|
||||||
@@ -391,8 +428,50 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
// Colors and background
|
// Colors and background
|
||||||
setTitle('.ql-color .ql-picker-label', 'Barva textu');
|
setTitle('.ql-color .ql-picker-label', 'Barva textu');
|
||||||
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
|
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
|
||||||
setTitle('button.ql-colorreset', 'Zrušit barvu');
|
// Inject reset option inside color/background pickers
|
||||||
setTitle('button.ql-bgreset', 'Zrušit pozadí');
|
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
|
// Headers
|
||||||
setTitle('.ql-header .ql-picker-label', 'Nadpis');
|
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
|
// Defer heavy sanitization to submit time to prevent selection glitches; keep minimal cleanup only
|
||||||
const handleChange = (content: string) => {
|
const handleChange = (content: string) => {
|
||||||
onChangeRef.current(cleanEditorHTML(content));
|
onChangeRef.current(content);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -1462,6 +1541,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
overflow="visible"
|
overflow="visible"
|
||||||
bg={bgColor}
|
bg={bgColor}
|
||||||
|
ref={containerRef}
|
||||||
sx={{
|
sx={{
|
||||||
'.ql-toolbar': {
|
'.ql-toolbar': {
|
||||||
borderBottom: '1px solid',
|
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.
|
// Allow user-chosen colors to show. White-on-white is handled during paste/sanitize only.
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isMounted && (
|
{isMounted && isVisible && (
|
||||||
<ReactQuill
|
<ReactQuill
|
||||||
theme="snow"
|
theme="snow"
|
||||||
value={value}
|
value={value}
|
||||||
@@ -1632,6 +1712,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
ref={quillRef}
|
ref={quillRef}
|
||||||
modules={quillModules}
|
modules={quillModules}
|
||||||
formats={quillFormats}
|
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>
|
</Box>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
|
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 HorizontalScroller from '../ui/HorizontalScroller';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||||
@@ -108,8 +108,17 @@ const BlogCardsScroller: React.FC = () => {
|
|||||||
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
|
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
|
||||||
queryFn: () => getArticles({ 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<HStack justify="space-between" align="center" flexWrap="wrap">
|
<HStack justify="space-between" align="center" flexWrap="wrap">
|
||||||
<VStack align="start" spacing={1}>
|
<VStack align="start" spacing={1}>
|
||||||
<Heading size="xl" color={headingColor}>
|
<Heading size="xl" color={headingColor} id="home-gallery-heading">
|
||||||
Fotogalerie
|
Fotogalerie
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text color={textColor} fontSize="sm">
|
<Text color={textColor} fontSize="sm">
|
||||||
@@ -227,6 +227,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
|||||||
h="200px"
|
h="200px"
|
||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
|||||||
// Default to 6 items on homepage unless overridden by settings (max 12)
|
// Default to 6 items on homepage unless overridden by settings (max 12)
|
||||||
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
|
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
|
||||||
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -171,7 +170,15 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AspectRatio ratio={16 / 9}>
|
<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 */}
|
{/* Thumbnail */}
|
||||||
{thumb ? (
|
{thumb ? (
|
||||||
<Box
|
<Box
|
||||||
@@ -257,7 +264,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
|||||||
<Box>
|
<Box>
|
||||||
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
|
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
|
||||||
<HStack spacing={3}>
|
<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>
|
</HStack>
|
||||||
<Link as={RouterLink} to="/videa">
|
<Link as={RouterLink} to="/videa">
|
||||||
<Button
|
<Button
|
||||||
@@ -340,7 +347,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
|||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box className="section-head">
|
<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">
|
<Link as={RouterLink} to="/videa">
|
||||||
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
|
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -62,16 +62,20 @@ const MatchesSlider: React.FC<{
|
|||||||
const items = (current?.matches || []);
|
const items = (current?.matches || []);
|
||||||
const looped = [...items, ...items, ...items];
|
const looped = [...items, ...items, ...items];
|
||||||
return (
|
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 }}>
|
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="ticker-belt">
|
<div className="ticker-belt" role="list">
|
||||||
{looped.map((m, idx) => (
|
{looped.map((m, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${m.id || idx}-ticker`}
|
key={`${m.id || idx}-ticker`}
|
||||||
className="match-card card"
|
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); }}
|
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
|
||||||
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
|
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
|
||||||
>
|
>
|
||||||
@@ -104,17 +108,21 @@ const MatchesSlider: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 }}>
|
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="matches-grid">
|
<div className="matches-grid">
|
||||||
<div className="matches-track" ref={trackRef}>
|
<div className="matches-track" ref={trackRef} role="list">
|
||||||
{(current?.matches || []).map((m, idx) => (
|
{(current?.matches || []).map((m, idx) => (
|
||||||
<div
|
<div
|
||||||
key={m.id || idx}
|
key={m.id || idx}
|
||||||
className="match-card card"
|
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); }}
|
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
|
||||||
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
|
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
|
||||||
>
|
>
|
||||||
@@ -163,7 +171,14 @@ const MatchesSlider: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<div className="matches-tabs">
|
<div className="matches-tabs">
|
||||||
{comps.map((c, i) => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,12 +27,17 @@ const NextMatch: React.FC<{
|
|||||||
<section
|
<section
|
||||||
className="next-match"
|
className="next-match"
|
||||||
{...(elementProps as any)}
|
{...(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?.(); }}
|
onClick={(e) => { e.stopPropagation(); onOpen?.(); }}
|
||||||
style={{ cursor: onOpen ? 'pointer' : 'default', position: 'relative', ...(elementProps?.style || {}) }}
|
style={{ cursor: onOpen ? 'pointer' : 'default', position: 'relative', ...(elementProps?.style || {}) }}
|
||||||
>
|
>
|
||||||
{onPrev && (
|
{onPrev && (
|
||||||
<button
|
<button
|
||||||
aria-label="Předchozí soutěž"
|
aria-label="Předchozí soutěž"
|
||||||
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
|
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
|
||||||
className="nav prev"
|
className="nav prev"
|
||||||
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
|
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
|
||||||
@@ -78,6 +83,7 @@ const NextMatch: React.FC<{
|
|||||||
{onNext && (
|
{onNext && (
|
||||||
<button
|
<button
|
||||||
aria-label="Další soutěž"
|
aria-label="Další soutěž"
|
||||||
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
|
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
|
||||||
className="nav next"
|
className="nav next"
|
||||||
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
|
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
|
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
|
||||||
const fmtDate = (iso?: string | null) => {
|
const fmtDate = (iso?: string | null) => {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -15,6 +16,7 @@ const SweepstakeWidget: React.FC = () => {
|
|||||||
const [joining, setJoining] = useState<boolean>(false);
|
const [joining, setJoining] = useState<boolean>(false);
|
||||||
const [playing, setPlaying] = useState<boolean>(false);
|
const [playing, setPlaying] = useState<boolean>(false);
|
||||||
const playedRef = useRef(false);
|
const playedRef = useRef(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -65,9 +67,11 @@ const SweepstakeWidget: React.FC = () => {
|
|||||||
setJoining(true);
|
setJoining(true);
|
||||||
try {
|
try {
|
||||||
await enterSweepstake(s.id);
|
await enterSweepstake(s.id);
|
||||||
|
toast({ status: 'success', title: 'Úspěšně jste vstoupili do soutěže' });
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
// ignore
|
const msg = e?.response?.data?.error || 'Nelze vstoupit do soutěže';
|
||||||
|
toast({ status: 'error', title: msg });
|
||||||
} finally {
|
} finally {
|
||||||
setJoining(false);
|
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 style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Začíná: {fmtDate(s.start_at)} • Končí: {fmtDate(s.end_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
{!isLogged ? (
|
{!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 ? (
|
) : data?.has_entered ? (
|
||||||
<span style={{ fontWeight: 600 }}>Jste zapojeni ✓</span>
|
<span style={{ fontWeight: 600 }}>Jste zapojeni ✓</span>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn" onClick={onJoin} disabled={joining}>
|
<span style={{ fontSize: 14, opacity: 0.85 }}>Soutěž ještě nezačala. Vstup bude možný od {fmtDate(s.start_at)}.</span>
|
||||||
{joining ? 'Přihlašuji…' : 'Zapojit se'}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,6 +133,12 @@ const SweepstakeWidget: React.FC = () => {
|
|||||||
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
|
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
|
||||||
{s.description && <div style={{ opacity: 0.8 }}>{s.description}</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: 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>
|
</div>
|
||||||
{!isLogged ? (
|
{!isLogged ? (
|
||||||
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
|
<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>
|
<span style={{ fontWeight: 600 }}>Jste zapojeni ✓</span>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn" onClick={onJoin} disabled={joining}>
|
<button className="btn" onClick={onJoin} disabled={joining}>
|
||||||
{joining ? 'Přihlašuji…' : 'Zapojit se'}
|
{joining ? 'Vstupuji…' : 'Vstoupit'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -175,6 +175,27 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
|
|||||||
py={2}
|
py={2}
|
||||||
px={1}
|
px={1}
|
||||||
cursor={draggable ? 'grab' : 'default'}
|
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)}
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
|
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
|
||||||
onMouseDown={(e) => { if (!draggable) return; e.preventDefault(); onPointerDown(e.clientX); }}
|
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 */}
|
{/* navigation buttons - must be above gradient masks */}
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="scroll left"
|
aria-label="Posunout doleva"
|
||||||
icon={<ChevronLeftIcon boxSize={6} />}
|
icon={<ChevronLeftIcon boxSize={6} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -234,7 +255,7 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
|
|||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="scroll right"
|
aria-label="Posunout doprava"
|
||||||
icon={<ChevronRightIcon boxSize={6} />}
|
icon={<ChevronRightIcon boxSize={6} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||||
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
|
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 MatchModal from '../components/home/MatchModal';
|
||||||
import { extractPalette } from '../utils/colors';
|
import { extractPalette } from '../utils/colors';
|
||||||
import { getTeamLogo } from '../utils/sportLogosAPI';
|
import { getTeamLogo } from '../utils/sportLogosAPI';
|
||||||
|
import { getBanners, Banner as UIBanner } from '../services/banners';
|
||||||
import FilePreview from '../components/common/FilePreview';
|
import FilePreview from '../components/common/FilePreview';
|
||||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
||||||
@@ -42,6 +43,8 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
enabled: Boolean(slug || id),
|
enabled: Boolean(slug || id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
|
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
|
||||||
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
|
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
|
||||||
queryKey: ['competition-aliases-public'],
|
queryKey: ['competition-aliases-public'],
|
||||||
@@ -336,6 +339,13 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [(data as any)?.content, toAbsoluteUploads]);
|
}, [(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({
|
const relatedArticlesQuery = useQuery({
|
||||||
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
|
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
|
||||||
enabled: Boolean((data as any)?.id),
|
enabled: Boolean((data as any)?.id),
|
||||||
@@ -526,6 +536,22 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
) : null}
|
) : null}
|
||||||
</HStack>
|
</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>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
<Container maxW="7xl">
|
<Container maxW="7xl">
|
||||||
@@ -541,7 +567,8 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Match Section - Card with logos, score/countdown, venue/date */}
|
{/* Match Section - Card with logos, score/countdown, venue/date */}
|
||||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
{(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 */}
|
{/* 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" />
|
<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 && (
|
{opponentColor && (
|
||||||
@@ -639,6 +666,27 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
|
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 */}
|
{/* YouTube Video Section - simplified with rounded edges */}
|
||||||
{(data as any)?.youtube_video_id && (
|
{(data as any)?.youtube_video_id && (
|
||||||
<Box borderRadius="xl" overflow="hidden">
|
<Box borderRadius="xl" overflow="hidden">
|
||||||
@@ -708,6 +756,13 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
|
<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 : (() => {
|
{relatedArticlesQuery.isLoading ? null : (() => {
|
||||||
const list = ((relatedArticlesQuery.data as any)?.data || [])
|
const list = ((relatedArticlesQuery.data as any)?.data || [])
|
||||||
.filter((a: any) => a?.id !== (data as any)?.id)
|
.filter((a: any) => a?.id !== (data as any)?.id)
|
||||||
@@ -768,12 +823,28 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
</Widget>
|
</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>
|
</VStack>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</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 && (
|
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
|
||||||
<Container maxW="7xl" mt={4}>
|
<Container maxW="7xl" mt={4}>
|
||||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
|
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
|
||||||
@@ -789,8 +860,6 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
{/* Polls (Ankety) above CTA */}
|
|
||||||
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
|
||||||
{/* Comments at the end */}
|
{/* Comments at the end */}
|
||||||
{(data as any)?.id ? (
|
{(data as any)?.id ? (
|
||||||
<Container maxW="7xl" mt={4}>
|
<Container maxW="7xl" mt={4}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
|
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
|
||||||
import { getBanners, Banner as UIBanner } from '../services/banners';
|
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: '160px', md: '180px' } as const)
|
||||||
: ({ base: '200px', md: '220px' } 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 (
|
return (
|
||||||
<LinkBox
|
<LinkBox
|
||||||
as={RouterLink}
|
as={RouterLink}
|
||||||
@@ -49,59 +52,31 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
|||||||
fetchPriority={variant === 'large' ? 'high' as any : 'auto' as any}
|
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))" />
|
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
|
||||||
{categoryName && (
|
{/* Top info row: category (left), date (center), read time (right) */}
|
||||||
<Badge
|
<HStack position="absolute" top={2} left={2} right={2} justify="space-between" align="center">
|
||||||
position="absolute"
|
{categoryName ? (
|
||||||
top={2}
|
<Tooltip label="Kategorie" hasArrow>
|
||||||
left={2}
|
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||||
bg="rgba(0,0,0,0.7)"
|
{categoryName}
|
||||||
color="white"
|
</Badge>
|
||||||
fontSize="xs"
|
</Tooltip>
|
||||||
px={2}
|
) : <Box />}
|
||||||
py={1}
|
{publishedDateStr ? (
|
||||||
borderRadius="md"
|
<Tooltip label="Datum publikace" hasArrow>
|
||||||
>
|
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||||
{categoryName}
|
{publishedDateStr}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
</Tooltip>
|
||||||
|
) : <Box />}
|
||||||
{/* Stats badges at top */}
|
{readTime ? (
|
||||||
{(readTime || (viewCount && viewCount > 0)) && (
|
<Tooltip label="Doba čtení" hasArrow>
|
||||||
<HStack position="absolute" top={2} right={2} spacing={1}>
|
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||||
{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"
|
|
||||||
>
|
|
||||||
<Clock size={12} />
|
<Clock size={12} />
|
||||||
{readTime} min
|
{readTime} min
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
</Tooltip>
|
||||||
{viewCount && viewCount > 0 && (
|
) : <Box />}
|
||||||
<Badge
|
</HStack>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Heading
|
<Heading
|
||||||
as="h3"
|
as="h3"
|
||||||
@@ -367,9 +342,9 @@ const BlogPage: React.FC = () => {
|
|||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Container maxW="5xl">
|
<Container maxW="7xl">
|
||||||
{/* Responsive grid with consistent card sizing */}
|
{/* 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) => (
|
{isLoading && Array.from({ length: 9 }).map((_, i) => (
|
||||||
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
|
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import ContactMap from '../components/home/ContactMap';
|
|||||||
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
|
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
|
||||||
import { facrApi } from '../services/facr/facrApi';
|
import { facrApi } from '../services/facr/facrApi';
|
||||||
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
||||||
|
import { getImageUrl } from '../utils/imageUtils';
|
||||||
|
|
||||||
type ContactFormData = {
|
type ContactFormData = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -276,7 +277,7 @@ const ContactPage: React.FC = () => {
|
|||||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||||
<VStack align="start" spacing={3}>
|
<VStack align="start" spacing={3}>
|
||||||
{contact.image_url && (
|
{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>
|
<Box>
|
||||||
<Heading size="sm">{contact.name}</Heading>
|
<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}>
|
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||||
<VStack align="start" spacing={3}>
|
<VStack align="start" spacing={3}>
|
||||||
{contact.image_url && (
|
{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>
|
<Box>
|
||||||
<Heading size="sm">{contact.name}</Heading>
|
<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}>
|
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||||
<VStack align="start" spacing={3}>
|
<VStack align="start" spacing={3}>
|
||||||
{contact.image_url && (
|
{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>
|
<Box>
|
||||||
<Heading size="sm">{contact.name}</Heading>
|
<Heading size="sm">{contact.name}</Heading>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'
|
|||||||
import ActivitiesList from '../components/pack/ActivitiesList';
|
import ActivitiesList from '../components/pack/ActivitiesList';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import SweepstakeWidget from '../components/sweepstakes/SweepstakeWidget';
|
import SweepstakeWidget from '../components/sweepstakes/SweepstakeWidget';
|
||||||
|
import { sortCategoriesWithOrder } from '../utils/categorySort';
|
||||||
|
|
||||||
// Types for real API-driven data
|
// Types for real API-driven data
|
||||||
type NewsItem = {
|
type NewsItem = {
|
||||||
@@ -92,7 +93,7 @@ const HomePage: React.FC = () => {
|
|||||||
const [edgeRoleIdx, setEdgeRoleIdx] = useState<number>(0);
|
const [edgeRoleIdx, setEdgeRoleIdx] = useState<number>(0);
|
||||||
const blogAutoRef = useRef<HTMLDivElement | null>(null);
|
const blogAutoRef = useRef<HTMLDivElement | null>(null);
|
||||||
// FACR competitions with matches (for slider)
|
// 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 [matchesTab, setMatchesTab] = useState<number>(0);
|
||||||
const [selectedClub, setSelectedClub] = useState<any>(null);
|
const [selectedClub, setSelectedClub] = useState<any>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -118,10 +119,11 @@ const HomePage: React.FC = () => {
|
|||||||
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
|
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
|
||||||
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
|
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
|
||||||
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
|
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
|
||||||
|
const [activitiesLoaded, setActivitiesLoaded] = useState<boolean>(false);
|
||||||
const [defer, setDefer] = useState<boolean>(false);
|
const [defer, setDefer] = useState<boolean>(false);
|
||||||
// Aliases
|
// Aliases
|
||||||
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
|
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 [settings, setSettings] = useState<any>(null);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
|
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
|
||||||
@@ -164,6 +166,33 @@ const HomePage: React.FC = () => {
|
|||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
})), [featured]);
|
})), [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -262,8 +291,8 @@ const HomePage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
aliasesList = await getCompetitionAliasesPublic();
|
aliasesList = await getCompetitionAliasesPublic();
|
||||||
} catch {}
|
} catch {}
|
||||||
const amap: Record<string, { alias: string; original_name?: string }> = {};
|
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 }; });
|
(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
|
// Try live settings API first
|
||||||
let liveSettings: any = null;
|
let liveSettings: any = null;
|
||||||
try {
|
try {
|
||||||
@@ -392,10 +421,12 @@ const HomePage: React.FC = () => {
|
|||||||
return {
|
return {
|
||||||
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
|
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
|
||||||
matches_link: c.matches_link,
|
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
|
// Next match FACR link
|
||||||
const first = filteredMatches?.[0];
|
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)
|
// Load players via API (include inactive to show as non-active instead of hiding)
|
||||||
try {
|
try {
|
||||||
const apiPlayers: ApiPlayer[] = await apiGetPlayers({ active: false });
|
const apiPlayers: ApiPlayer[] = await apiGetPlayers();
|
||||||
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
|
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
|
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
|
||||||
@@ -481,7 +512,7 @@ const HomePage: React.FC = () => {
|
|||||||
const top3 = all.slice(0, 3);
|
const top3 = all.slice(0, 3);
|
||||||
setFeatured(top3);
|
setFeatured(top3);
|
||||||
setNews((prev) => {
|
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}`));
|
return (prev || []).filter((n) => !featuredKeys.has(n.slug ? `s:${n.slug}` : `i:${n.id}`));
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -531,6 +562,8 @@ const HomePage: React.FC = () => {
|
|||||||
if (facrTablesJSON?.competitions?.length) {
|
if (facrTablesJSON?.competitions?.length) {
|
||||||
const comps = (facrTablesJSON.competitions || []).map((c: any) => ({
|
const comps = (facrTablesJSON.competitions || []).map((c: any) => ({
|
||||||
name: (amap?.[c?.code]?.alias) || c.name || c.code,
|
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) => ({
|
table: (c.table?.overall || []).map((r: any, idx: number) => ({
|
||||||
position: Number(r.rank || idx + 1),
|
position: Number(r.rank || idx + 1),
|
||||||
team: r.team || r.team_name || '-',
|
team: r.team || r.team_name || '-',
|
||||||
@@ -544,7 +577,8 @@ const HomePage: React.FC = () => {
|
|||||||
score: r.score || '0:0',
|
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
|
// Club name/logo from FACR if not provided by settings
|
||||||
@@ -630,6 +664,9 @@ const HomePage: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
if (active) setUpcomingEvents(mapped);
|
if (active) setUpcomingEvents(mapped);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
finally {
|
||||||
|
if (active) setActivitiesLoaded(true);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1402,13 +1439,17 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
|
isLoading ? (
|
||||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
|
<div className="hero-card big skeleton" style={{ borderRadius: 16 }} />
|
||||||
<div className="overlay">
|
) : (
|
||||||
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
|
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||||
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
|
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
|
||||||
</div>
|
<div className="overlay">
|
||||||
</a>
|
<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">
|
<div className="small-col">
|
||||||
{featured.slice(1, 3).map((n, idx) => (
|
{featured.slice(1, 3).map((n, idx) => (
|
||||||
@@ -1421,13 +1462,17 @@ const HomePage: React.FC = () => {
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, featured.length - 1))) }).map((_, idx) => (
|
{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' }}>
|
isLoading ? (
|
||||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
|
<div key={`placeholder-${idx}`} className="hero-card small skeleton" style={{ borderRadius: 16 }} />
|
||||||
<div className="overlay">
|
) : (
|
||||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
|
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
|
||||||
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
|
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
|
||||||
</div>
|
<div className="overlay">
|
||||||
</a>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1438,7 +1483,7 @@ const HomePage: React.FC = () => {
|
|||||||
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
|
{(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 }}>
|
<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 */}
|
{/* 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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
@@ -1446,34 +1491,37 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Featured articles are now shown in the hero grid above, not here */}
|
{/* 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') && (
|
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
||||||
<section
|
<section
|
||||||
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
|
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
|
||||||
data-element="sidebar"
|
data-element="sidebar"
|
||||||
data-variant={getVariant('sidebar', 'right')}
|
data-variant={getVariant('sidebar', 'right')}
|
||||||
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
|
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
|
||||||
style={{
|
style={{ margin: '24px 0', ...getStyles('sidebar') }}
|
||||||
// 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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, pointerEvents: 'auto', padding: 4 }}>
|
<div
|
||||||
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
|
style={{
|
||||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
position: 'sticky',
|
||||||
<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%' }} />
|
top: 112,
|
||||||
</a>
|
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>
|
||||||
))}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
{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 */}
|
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
|
||||||
{facrCompetitions.length > 0 && isVisible('matches', true) ? (
|
{isVisible('matches', true) ? (
|
||||||
(() => {
|
facrCompetitions.length > 0 ? (
|
||||||
const comp = facrCompetitions[Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1))];
|
upcomingCompIndices.length > 0 ? (
|
||||||
const items = Array.isArray(comp?.matches) ? comp.matches : [];
|
(() => {
|
||||||
const upcoming = items
|
const safeIndex = Math.max(0, Math.min(nextCompIdx, facrCompetitions.length - 1));
|
||||||
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
|
const pos = upcomingCompIndices.indexOf(safeIndex);
|
||||||
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
|
const effectiveIndex = pos >= 0 ? upcomingCompIndices[pos] : upcomingCompIndices[0];
|
||||||
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
|
const comp = facrCompetitions[effectiveIndex];
|
||||||
const show = upcoming || items[0] || null;
|
const items = Array.isArray(comp?.matches) ? comp.matches : [];
|
||||||
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
|
const upcoming = items
|
||||||
const handleNextMatchClick = () => {
|
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
|
||||||
if (show) {
|
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
|
||||||
setSelectedMatch({
|
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
|
||||||
...show,
|
const show = upcoming || null;
|
||||||
competition: comp?.name,
|
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];
|
||||||
setIsMatchModalOpen(true);
|
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
|
||||||
} else if (link) {
|
const handleNextMatchClick = () => {
|
||||||
window.open(link, '_blank', 'noopener,noreferrer');
|
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
|
<NextMatch
|
||||||
data={show}
|
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
|
||||||
competitionName={comp?.name}
|
data={{
|
||||||
countdown={countdown}
|
home: matches[0]?.homeTeam || clubName,
|
||||||
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
|
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||||
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
|
away: matches[0]?.awayTeam || 'Soupeř',
|
||||||
onOpen={handleNextMatchClick}
|
away_logo_url: matches[0]?.awayLogoURL,
|
||||||
elementProps={{
|
|
||||||
'data-element': 'matches' as any,
|
|
||||||
'data-variant': getVariant('matches', 'compact') as any,
|
|
||||||
style: { ...getStyles('matches') },
|
|
||||||
}}
|
}}
|
||||||
|
countdown={countdown}
|
||||||
|
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
|
||||||
/>
|
/>
|
||||||
);
|
</div>
|
||||||
})()
|
)
|
||||||
) : 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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Sweepstakes / Lottery widget (visible around matches section) */}
|
{/* Sweepstakes / Lottery widget (visible around matches section) */}
|
||||||
@@ -1570,6 +1628,20 @@ const HomePage: React.FC = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null
|
) : 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 */}
|
{/* News + Tables: split into two independent sections */}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1597,23 +1669,31 @@ const HomePage: React.FC = () => {
|
|||||||
style={{ marginTop: 32 }}
|
style={{ marginTop: 32 }}
|
||||||
>
|
>
|
||||||
{showNews && (
|
{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 }}>
|
<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>
|
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||||
</div>
|
</div>
|
||||||
{newsVariant === 'scroller' ? (
|
{newsVariant === 'scroller' ? (
|
||||||
<BlogCardsScroller />
|
<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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTable && (
|
{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 }}>
|
<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>
|
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||||
</div>
|
</div>
|
||||||
{defer ? (
|
{defer ? (
|
||||||
@@ -1639,7 +1719,15 @@ const HomePage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</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 under the table, inside the table column */}
|
||||||
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
|
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
|
||||||
defer ? (
|
defer ? (
|
||||||
@@ -1657,12 +1745,28 @@ const HomePage: React.FC = () => {
|
|||||||
{/* (Moved) Banner under tables now renders inside the table column above */}
|
{/* (Moved) Banner under tables now renders inside the table column above */}
|
||||||
|
|
||||||
{/* Competition tables moved into right column below */}
|
{/* Competition tables moved into right column below */}
|
||||||
|
|
||||||
{upcomingEvents.length > 0 && isVisible('activities', true) && (
|
{isVisible('activities', true) && !activitiesLoaded && (
|
||||||
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
<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 style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<div className="section-head" style={{ marginTop: 0 }}>
|
<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>
|
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||||
</div>
|
</div>
|
||||||
<ActivitiesList items={upcomingEvents as any} />
|
<ActivitiesList items={upcomingEvents as any} />
|
||||||
@@ -1671,10 +1775,25 @@ const HomePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Players scroller */}
|
{/* 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">
|
<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>
|
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||||
</div>
|
</div>
|
||||||
<div className="scroll-x">
|
<div className="scroll-x">
|
||||||
@@ -1691,7 +1810,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Gallery */}
|
{/* Gallery */}
|
||||||
{isVisible('gallery', false) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
{defer ? (
|
{defer ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -1704,7 +1823,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Videos */}
|
{/* Videos */}
|
||||||
{isVisible('videos', false) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
{defer ? (
|
{defer ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -1713,26 +1832,50 @@ const HomePage: React.FC = () => {
|
|||||||
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
|
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isVisible('merch', true) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
{defer ? (
|
{defer ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
|
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
|
||||||
</Suspense>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Polls / Voting */}
|
{/* Polls / Voting */}
|
||||||
{isVisible('poll', false) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
{defer ? (
|
{defer ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -1740,7 +1883,9 @@ const HomePage: React.FC = () => {
|
|||||||
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -1751,7 +1896,7 @@ const HomePage: React.FC = () => {
|
|||||||
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
|
{(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 }}>
|
<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 */}
|
{/* 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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
@@ -1759,13 +1904,15 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* CTA (Newsletter) moved up */}
|
{/* CTA (Newsletter) moved up */}
|
||||||
{isVisible('newsletter', false) && (
|
{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' }}>
|
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||||
{defer ? (
|
{defer ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<NewsletterSubscribe />
|
<NewsletterSubscribe />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="skeleton" style={{ height: 280, borderRadius: 12 }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -1830,6 +1977,7 @@ const HomePage: React.FC = () => {
|
|||||||
data-element="sponsors"
|
data-element="sponsors"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
|
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
|
||||||
|
aria-labelledby="home-sponsors-heading"
|
||||||
style={{
|
style={{
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -1839,19 +1987,28 @@ const HomePage: React.FC = () => {
|
|||||||
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
|
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||||
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
|
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
contentVisibility: 'auto' as any,
|
||||||
|
containIntrinsicSize: '520px',
|
||||||
...getStyles('sponsors')
|
...getStyles('sponsors')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h3>Sponzoři</h3>
|
<h3 id="home-sponsors-heading">Sponzoři</h3>
|
||||||
</div>
|
</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' && (
|
{variant === 'grid' && (
|
||||||
<>
|
<>
|
||||||
{general.length > 0 && (
|
{general.length > 0 && (
|
||||||
<div className="title-sponsor">
|
<div className="title-sponsor">
|
||||||
{general.map((g) => (
|
{general.map((g) => (
|
||||||
<a key={`g-${g.id}`} className="sponsor-tile" href={g.url || '#'} target="_blank" rel="noreferrer noopener">
|
<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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1860,7 +2017,7 @@ const HomePage: React.FC = () => {
|
|||||||
<div className="sponsors-grid">
|
<div className="sponsors-grid">
|
||||||
{(standard.length > 0 ? standard : (general.length === 0 ? ordered : [])).map((s) => (
|
{(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">
|
<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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1872,7 +2029,7 @@ const HomePage: React.FC = () => {
|
|||||||
<div className="track">
|
<div className="track">
|
||||||
{[...ordered, ...ordered].map((s, idx) => (
|
{[...ordered, ...ordered].map((s, idx) => (
|
||||||
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
|
<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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1883,7 +2040,7 @@ const HomePage: React.FC = () => {
|
|||||||
<div className="belt">
|
<div className="belt">
|
||||||
{[...ordered, ...ordered, ...ordered].map((s, idx) => (
|
{[...ordered, ...ordered, ...ordered].map((s, idx) => (
|
||||||
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
|
<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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1943,11 +2100,8 @@ const HomePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function czYears(n: number): string {
|
function czYears(n: number): string {
|
||||||
const mod100 = n % 100;
|
if (n === 1) return 'rok';
|
||||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
if (n >= 2 && n <= 4) return 'roky';
|
||||||
const mod10 = n % 10;
|
|
||||||
if (mod10 === 1) return 'rok';
|
|
||||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
|
||||||
return 'let';
|
return 'let';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,11 +130,8 @@ function calculateAge(iso: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function czYears(n: number): string {
|
function czYears(n: number): string {
|
||||||
const mod100 = n % 100;
|
if (n === 1) return 'rok';
|
||||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
if (n >= 2 && n <= 4) return 'roky';
|
||||||
const mod10 = n % 10;
|
|
||||||
if (mod10 === 1) return 'rok';
|
|
||||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
|
||||||
return 'let';
|
return 'let';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { SearchIcon } from '@chakra-ui/icons';
|
import { SearchIcon } from '@chakra-ui/icons';
|
||||||
|
|
||||||
const PlayersPage: React.FC = () => {
|
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 cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
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
|
// Removed react-datepicker to prevent crash; using native date/time inputs instead
|
||||||
import { getPublicSettings } from '../../services/settings';
|
import { getPublicSettings } from '../../services/settings';
|
||||||
import PollLinker from '../../components/admin/PollLinker';
|
import PollLinker from '../../components/admin/PollLinker';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import FilePreview from '../../components/common/FilePreview';
|
import FilePreview from '../../components/common/FilePreview';
|
||||||
import { facrApi } from '../../services/facr/facrApi';
|
import { facrApi } from '../../services/facr/facrApi';
|
||||||
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
|
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
|
||||||
@@ -73,6 +74,8 @@ const types: Array<{ value: Event['type']; label: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const AdminActivitiesPage: React.FC = () => {
|
const AdminActivitiesPage: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = (user as any)?.role === 'admin';
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
const inputBg = useColorModeValue('white', 'gray.700');
|
const inputBg = useColorModeValue('white', 'gray.700');
|
||||||
@@ -1135,7 +1138,7 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
{/* Poll Section */}
|
{/* Poll Section */}
|
||||||
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
|
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
|
||||||
<Heading size="sm" mb={3}>Anketa</Heading>
|
<Heading size="sm" mb={3}>Anketa</Heading>
|
||||||
{editing?.id ? (
|
{isAdmin && editing?.id ? (
|
||||||
<PollLinker eventId={editing.id} />
|
<PollLinker eventId={editing.id} />
|
||||||
) : (
|
) : (
|
||||||
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
|
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
|
||||||
|
|||||||
@@ -99,15 +99,6 @@ const AdminVideosPage: React.FC = () => {
|
|||||||
if (mounted) setAutoLoading(false);
|
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();
|
run();
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [loading, videosSource]);
|
}, [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 fetchChannelVideos = async () => {
|
||||||
const channel = channelInput?.trim();
|
const channel = channelInput?.trim();
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
|
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import AdminLayout from '../../layouts/AdminLayout';
|
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 { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
|
||||||
import { generateBlogAI } from '../../services/ai';
|
import { generateBlogAI } from '../../services/ai';
|
||||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
@@ -172,6 +173,8 @@ const parseYoutubeVideoId = (raw: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ArticlesAdminPage = () => {
|
const ArticlesAdminPage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = (user as any)?.role === 'admin';
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -519,16 +522,20 @@ const ArticlesAdminPage = () => {
|
|||||||
try {
|
try {
|
||||||
// Set cover image immediately
|
// Set cover image immediately
|
||||||
setEditing((prev) => ({ ...(prev as any), image_url: pick.image_url }));
|
setEditing((prev) => ({ ...(prev as any), image_url: pick.image_url }));
|
||||||
// Persist pick to unified cache (admin)
|
// Persist pick to unified cache (admin only)
|
||||||
await putZoneramaPick({
|
if (isAdmin) {
|
||||||
id: pick.id,
|
await putZoneramaPick({
|
||||||
album_id: pick.album_id,
|
id: pick.id,
|
||||||
album_url: pick.album_url,
|
album_id: pick.album_id,
|
||||||
page_url: pick.page_url,
|
album_url: pick.album_url,
|
||||||
image_url: pick.image_url,
|
page_url: pick.page_url,
|
||||||
title: pick.title,
|
image_url: pick.image_url,
|
||||||
} as any);
|
title: pick.title,
|
||||||
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
|
} as any);
|
||||||
|
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Obrázek nastaven', status: 'success' });
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast({ title: 'Uložení výběru selhalo', description: e?.response?.data?.error || e?.message || 'Chyba', status: 'error' });
|
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
|
// Handle album photo selection for blog content
|
||||||
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
|
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
|
||||||
try {
|
try {
|
||||||
// Save album to cache
|
// Save album to cache (admins only)
|
||||||
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
|
if (isAdmin) {
|
||||||
await saveAlbumToCache(albumInfo.url, photos.length);
|
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
|
// Store album info with article and append images to content
|
||||||
setEditing((prev) => {
|
setEditing((prev) => {
|
||||||
@@ -573,7 +582,7 @@ const ArticlesAdminPage = () => {
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Album přidáno',
|
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',
|
status: 'success',
|
||||||
duration: 4000
|
duration: 4000
|
||||||
});
|
});
|
||||||
@@ -2092,7 +2101,7 @@ const ArticlesAdminPage = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{editing?.id ? (
|
{isAdmin && editing?.id ? (
|
||||||
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
||||||
// Invalidate queries to refresh polls
|
// Invalidate queries to refresh polls
|
||||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import AdminLayout from '../../layouts/AdminLayout';
|
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 { 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 { 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 { deleteComment } from '../../services/comments';
|
||||||
import { FiTrash2 } from 'react-icons/fi';
|
import { FiTrash2 } from 'react-icons/fi';
|
||||||
import { getArticles } from '../../services/articles';
|
import { getArticles } from '../../services/articles';
|
||||||
@@ -37,6 +37,11 @@ const CommentsAdminPage: React.FC = () => {
|
|||||||
queryFn: adminListUnbanRequests,
|
queryFn: adminListUnbanRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bansQ = useQuery({
|
||||||
|
queryKey: ['admin-comment-bans'],
|
||||||
|
queryFn: adminListBans,
|
||||||
|
});
|
||||||
|
|
||||||
const updateStatusMut = useMutation({
|
const updateStatusMut = useMutation({
|
||||||
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
|
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
|
||||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
|
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
|
||||||
@@ -57,7 +62,16 @@ const CommentsAdminPage: React.FC = () => {
|
|||||||
|
|
||||||
const resolveUnbanMut = useMutation({
|
const resolveUnbanMut = useMutation({
|
||||||
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
|
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(() => {
|
React.useEffect(() => {
|
||||||
@@ -167,7 +181,10 @@ const CommentsAdminPage: React.FC = () => {
|
|||||||
<Tr key={c.id}>
|
<Tr key={c.id}>
|
||||||
<Td>#{c.id}</Td>
|
<Td>#{c.id}</Td>
|
||||||
<Td>#{c.user?.id} {c.user?.first_name} {c.user?.last_name}</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 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).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>
|
<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) => (
|
{(unbanQ.data?.items || []).map((r) => (
|
||||||
<Tr key={r.id}>
|
<Tr key={r.id}>
|
||||||
<Td>#{r.id}</Td>
|
<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 maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
|
||||||
<Td><Badge>{r.status}</Badge></Td>
|
<Td><Badge>{r.status}</Badge></Td>
|
||||||
<Td>
|
<Td>
|
||||||
@@ -228,6 +245,39 @@ const CommentsAdminPage: React.FC = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</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 */}
|
{/* Ban modal */}
|
||||||
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
|
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
|
|||||||
@@ -101,12 +101,32 @@ const ContactsAdminPage: React.FC = () => {
|
|||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
|
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
// Map of competition code -> alias (public aliases)
|
||||||
|
const [compAliasMap, setCompAliasMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
loadSettings();
|
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 () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -170,12 +190,15 @@ const ContactsAdminPage: React.FC = () => {
|
|||||||
for (const comp of facrCompetitions || []) {
|
for (const comp of facrCompetitions || []) {
|
||||||
const n = String(comp?.name || '').trim();
|
const n = String(comp?.name || '').trim();
|
||||||
if (n) names.add(n);
|
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);
|
return Array.from(names);
|
||||||
} catch {
|
} catch {
|
||||||
return [] as string[];
|
return [] as string[];
|
||||||
}
|
}
|
||||||
}, [facrCompetitions]);
|
}, [facrCompetitions, compAliasMap]);
|
||||||
|
|
||||||
const filteredContactCategories = useMemo(() => {
|
const filteredContactCategories = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
const editModal = useDisclosure();
|
const editModal = useDisclosure();
|
||||||
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
|
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
|
||||||
// Remove raw JSON editing, keep structured metadata only
|
// Remove raw JSON editing, keep structured metadata only
|
||||||
|
const batchEnabled = false;
|
||||||
|
|
||||||
const [batch, setBatch] = React.useState({
|
const [batch, setBatch] = React.useState({
|
||||||
base_url: '',
|
base_url: '',
|
||||||
@@ -330,7 +331,9 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</WrapItem>
|
</WrapItem>
|
||||||
<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>
|
</WrapItem>
|
||||||
</Wrap>
|
</Wrap>
|
||||||
<HStack align="start" spacing={4}>
|
<HStack align="start" spacing={4}>
|
||||||
@@ -361,22 +364,38 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
</NumberInput>
|
</NumberInput>
|
||||||
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč</FormHelperText>
|
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč</FormHelperText>
|
||||||
</FormControl>
|
</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>
|
<FormControl>
|
||||||
<FormLabel>Sklad</FormLabel>
|
<FormLabel>Platnost od</FormLabel>
|
||||||
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
|
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
|
||||||
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
|
|
||||||
</NumberInput>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</HStack>
|
<FormControl>
|
||||||
<FormControl>
|
<FormLabel>Platnost do</FormLabel>
|
||||||
<FormLabel>Obrázek URL</FormLabel>
|
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} />
|
||||||
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
|
</FormControl>
|
||||||
<FormHelperText>Pro avatar uveďte URL obrázku. Pro odemknutí uploadu není třeba.</FormHelperText>
|
</VStack>
|
||||||
</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>
|
|
||||||
{/* Metadata helpers */}
|
{/* Metadata helpers */}
|
||||||
{form.type === 'merch_coupon' && (
|
{form.type === 'merch_coupon' && (
|
||||||
<VStack align="stretch" spacing={2}>
|
<VStack align="stretch" spacing={2}>
|
||||||
@@ -384,10 +403,6 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
<FormLabel>Kód kuponu</FormLabel>
|
<FormLabel>Kód kuponu</FormLabel>
|
||||||
<Input value={meta.coupon_code || ''} onChange={(e)=>setMetaField('coupon_code', e.target.value)} />
|
<Input value={meta.coupon_code || ''} onChange={(e)=>setMetaField('coupon_code', e.target.value)} />
|
||||||
</FormControl>
|
</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>
|
<FormControl>
|
||||||
<FormLabel>Poznámka</FormLabel>
|
<FormLabel>Poznámka</FormLabel>
|
||||||
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
|
<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>
|
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
<Box>
|
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
|
||||||
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
|
<Box>
|
||||||
<Box borderWidth="1px" borderRadius="md" p={2}>
|
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
|
||||||
{form.image_url ? (
|
<Box borderWidth="1px" borderRadius="md" p={2}>
|
||||||
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
|
{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 boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -468,6 +485,7 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
<Th>Body</Th>
|
<Th>Body</Th>
|
||||||
<Th>Sklad</Th>
|
<Th>Sklad</Th>
|
||||||
<Th>Obrázek</Th>
|
<Th>Obrázek</Th>
|
||||||
|
<Th>Platnost</Th>
|
||||||
<Th>Aktivní</Th>
|
<Th>Aktivní</Th>
|
||||||
<Th>Akce</Th>
|
<Th>Akce</Th>
|
||||||
</Tr>
|
</Tr>
|
||||||
@@ -496,6 +514,20 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
</NumberInput>
|
</NumberInput>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</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>
|
<Td>
|
||||||
<Switch
|
<Switch
|
||||||
isChecked={!!r.active}
|
isChecked={!!r.active}
|
||||||
@@ -630,7 +662,6 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
{editForm.type === 'merch_coupon' && (
|
{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>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>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
<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. */}
|
{/* Odstraněno: ruční JSON metadata v editoru. */}
|
||||||
<HStack>
|
<HStack>
|
||||||
<Text>Aktivní</Text>
|
<Text>Aktivní</Text>
|
||||||
@@ -699,76 +740,78 @@ const EngagementAdminPage: React.FC = () => {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Batch create modal */}
|
{/* Batch create modal (hidden) */}
|
||||||
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
|
{batchEnabled && (
|
||||||
<ModalOverlay />
|
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
|
||||||
<ModalContent>
|
<ModalOverlay />
|
||||||
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
|
<ModalContent>
|
||||||
<ModalCloseButton />
|
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
|
||||||
<ModalBody>
|
<ModalCloseButton />
|
||||||
<VStack align="stretch" spacing={3}>
|
<ModalBody>
|
||||||
<FormControl>
|
<VStack align="stretch" spacing={3}>
|
||||||
<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>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Počet</FormLabel>
|
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
|
||||||
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
|
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
|
||||||
<NumberInputField />
|
<FormHelperText>Příklad: avatar-{`{i}`}.png → avatar-1.png, avatar-2.png…</FormHelperText>
|
||||||
</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>
|
</FormControl>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Text>Aktivní</Text>
|
<FormControl>
|
||||||
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
|
<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>
|
</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>
|
</HStack>
|
||||||
</VStack>
|
</ModalFooter>
|
||||||
</ModalBody>
|
</ModalContent>
|
||||||
<ModalFooter>
|
</Modal>
|
||||||
<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>
|
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
const [forceDelete, setForceDelete] = useState(false);
|
const [forceDelete, setForceDelete] = useState(false);
|
||||||
const [scanResult, setScanResult] = useState<any>(null);
|
const [scanResult, setScanResult] = useState<any>(null);
|
||||||
const [refreshResult, setRefreshResult] = 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: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
|
||||||
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
|
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
|
||||||
@@ -202,6 +204,71 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
return full || url;
|
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
|
// Mime type options
|
||||||
const mimeTypes = useMemo(() => {
|
const mimeTypes = useMemo(() => {
|
||||||
const types = new Set<string>();
|
const types = new Set<string>();
|
||||||
@@ -443,7 +510,19 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Box>
|
</Box>
|
||||||
</Alert>
|
</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}>
|
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||||
<Table size="sm">
|
<Table size="sm">
|
||||||
<Thead>
|
<Thead>
|
||||||
@@ -483,7 +562,19 @@ const FilesAdminPage: React.FC = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Box>
|
</Box>
|
||||||
</Alert>
|
</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 ? (
|
{duplicateGroups.length === 0 ? (
|
||||||
<Box textAlign="center" py={8}>
|
<Box textAlign="center" py={8}>
|
||||||
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
|
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ const GalleryAdminPage: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the api service which automatically includes authentication
|
// Use the api service which automatically includes authentication
|
||||||
await api.post('/admin/gallery/refresh');
|
await api.post('/admin/gallery/refresh', {});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Galerie obnovena',
|
title: 'Galerie obnovena',
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import AdminLayout from '../../layouts/AdminLayout';
|
import AdminLayout from '../../layouts/AdminLayout';
|
||||||
import { putMatchOverride } from '../../services/adminMatches';
|
import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
|
||||||
import { getPublicSettings } from '../../services/settings';
|
import { getPublicSettings } from '../../services/settings';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -85,51 +85,21 @@ const MatchesAdminPage = () => {
|
|||||||
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
|
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
|
||||||
queryKey: ['admin-matches-list-cache'],
|
queryKey: ['admin-matches-list-cache'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// Read cached FACR club info
|
const items = await fetchAdminMatches();
|
||||||
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 FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
||||||
const formatDisplayDate = (s: string): string => {
|
const parseTs = (obj: any): number => {
|
||||||
const str = String(s || '').trim();
|
const s = String(obj?.date_time || obj?.date || '').trim();
|
||||||
if (!str) return '';
|
if (!s) return Number.MAX_SAFE_INTEGER;
|
||||||
try {
|
try {
|
||||||
const dt = parse(str, FACR_DATE_FMT, new Date());
|
const dt = parse(s, FACR_DATE_FMT, new Date());
|
||||||
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
|
if (!isNaN(dt.getTime())) return dt.getTime();
|
||||||
} catch {}
|
} catch {}
|
||||||
const d2 = new Date(str);
|
const d2 = new Date(s);
|
||||||
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
|
if (!isNaN(d2.getTime())) return d2.getTime();
|
||||||
return str;
|
return Number.MAX_SAFE_INTEGER;
|
||||||
};
|
};
|
||||||
items.sort((a, b) => {
|
items.sort((a: any, b: any) => parseTs(a) - parseTs(b));
|
||||||
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
|
return items;
|
||||||
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,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,7 +344,7 @@ const MatchesAdminPage = () => {
|
|||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
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');
|
if (!externalMatchId) throw new Error('Chybí match_id');
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
venue_override: form.venue_override,
|
venue_override: form.venue_override,
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ const ADMIN_PAGE_PRESETS = [
|
|||||||
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
|
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
|
||||||
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
|
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
|
||||||
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
|
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
|
||||||
{ value: 'categories', label: 'Kategorie', url: '/admin/kategorie' },
|
|
||||||
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
|
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
|
||||||
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
|
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
|
||||||
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
|
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
|
||||||
@@ -1149,6 +1148,8 @@ const NavigationAdminPage = () => {
|
|||||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||||
onToggleVisible={toggleVisible}
|
onToggleVisible={toggleVisible}
|
||||||
|
childrenDroppableId={`admin-children-${item.id}`}
|
||||||
|
draggableChildPrefix={'admin-child'}
|
||||||
onEditTarget={(it) => openNavModal(it, undefined, true)}
|
onEditTarget={(it) => openNavModal(it, undefined, true)}
|
||||||
onDeleteTarget={(it) => deleteNav(it.id!)}
|
onDeleteTarget={(it) => deleteNav(it.id!)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -602,6 +602,25 @@ export default function NewsletterAdminPage() {
|
|||||||
<Text>Automatické rozesílky</Text>
|
<Text>Automatické rozesílky</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</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 ? (
|
{statusData?.next_approximate ? (
|
||||||
<Text color="gray.600" fontSize="sm" mt={2}>
|
<Text color="gray.600" fontSize="sm" mt={2}>
|
||||||
Další automatický newsletter za {(() => {
|
Další automatický newsletter za {(() => {
|
||||||
|
|||||||
@@ -639,11 +639,8 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
|
|
||||||
// Czech pluralization for years: 1 rok, 2–4 roky, 5+ let (11–14 let)
|
// Czech pluralization for years: 1 rok, 2–4 roky, 5+ let (11–14 let)
|
||||||
function czYears(n: number): string {
|
function czYears(n: number): string {
|
||||||
const mod100 = n % 100;
|
if (n === 1) return 'rok';
|
||||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
if (n >= 2 && n <= 4) return 'roky';
|
||||||
const mod10 = n % 10;
|
|
||||||
if (mod10 === 1) return 'rok';
|
|
||||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
|
||||||
return 'let';
|
return 'let';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
|
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
|
||||||
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
|
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
|
||||||
|
|
||||||
const ShortlinksAdminPage: React.FC = () => {
|
const ShortlinksAdminPage: React.FC = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = (user as any)?.role === 'admin';
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [targetUrl, setTargetUrl] = React.useState('');
|
const [targetUrl, setTargetUrl] = React.useState('');
|
||||||
const [title, setTitle] = React.useState('');
|
const [title, setTitle] = React.useState('');
|
||||||
@@ -77,7 +80,7 @@ const ShortlinksAdminPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout>
|
<AdminLayout requireAdmin={false}>
|
||||||
<Box>
|
<Box>
|
||||||
<HStack justify="space-between" mb={4}>
|
<HStack justify="space-between" mb={4}>
|
||||||
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
|
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
|
||||||
@@ -125,7 +128,9 @@ const ShortlinksAdminPage: React.FC = () => {
|
|||||||
<HStack>
|
<HStack>
|
||||||
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
|
<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="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>
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
@@ -138,7 +143,8 @@ const ShortlinksAdminPage: React.FC = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Stats modal */}
|
{/* Stats modal (admins only) */}
|
||||||
|
{isAdmin && (
|
||||||
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
|
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
@@ -190,6 +196,7 @@ const ShortlinksAdminPage: React.FC = () => {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Image,
|
Image,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
|
Tabs,
|
||||||
|
TabList,
|
||||||
|
Tab,
|
||||||
|
TabPanels,
|
||||||
|
TabPanel,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import AdminLayout from '../../layouts/AdminLayout';
|
import AdminLayout from '../../layouts/AdminLayout';
|
||||||
@@ -88,15 +93,15 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
const [form, setForm] = useState<any>(defaultForm);
|
const [form, setForm] = useState<any>(defaultForm);
|
||||||
const [editing, setEditing] = useState<Sweepstake | null>(null);
|
const [editing, setEditing] = useState<Sweepstake | null>(null);
|
||||||
|
|
||||||
// Prizes modal state
|
// Prizes state (integrated tab)
|
||||||
const prizesDisc = useDisclosure();
|
|
||||||
const [prizeSweep, setPrizeSweep] = useState<Sweepstake | null>(null);
|
|
||||||
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
|
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 [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 [savingPrize, setSavingPrize] = useState<boolean>(false);
|
||||||
|
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const rulesInputRef = 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) => {
|
const onUploadImage = async (file?: File | null) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -143,24 +148,19 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openPrizes = async (it: Sweepstake) => {
|
const openPrizes = async (it: Sweepstake) => {
|
||||||
try {
|
openEdit(it);
|
||||||
setPrizeSweep(it);
|
setActiveTab(2);
|
||||||
prizesDisc.onOpen();
|
try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); }
|
||||||
const list = await adminListPrizes(it.id);
|
|
||||||
setPrizes(list);
|
|
||||||
} catch {
|
|
||||||
setPrizes([]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addPrize = async () => {
|
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; }
|
if (!prizeForm.name.trim()) { toast({ status: 'error', title: 'Název výhry je povinný' }); return; }
|
||||||
try {
|
try {
|
||||||
setSavingPrize(true);
|
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: '' });
|
setPrizeForm({ name: '', quantity: 1, value: '', image_url: '' });
|
||||||
setPrizes(await adminListPrizes(prizeSweep.id));
|
setPrizes(await adminListPrizes(editing.id));
|
||||||
} catch (e:any) {
|
} catch (e:any) {
|
||||||
toast({ status: 'error', title: 'Nelze uložit výhru' });
|
toast({ status: 'error', title: 'Nelze uložit výhru' });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -169,14 +169,14 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const delPrize = async (p: SweepstakePrize) => {
|
const delPrize = async (p: SweepstakePrize) => {
|
||||||
if (!prizeSweep) return;
|
if (!editing) return;
|
||||||
if (!window.confirm('Smazat výhru?')) return;
|
if (!window.confirm('Smazat výhru?')) return;
|
||||||
await adminDeletePrize(prizeSweep.id, p.id as any);
|
await adminDeletePrize(editing.id, p.id as any);
|
||||||
setPrizes(await adminListPrizes(prizeSweep.id));
|
setPrizes(await adminListPrizes(editing.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const movePrize = async (idx: number, dir: -1 | 1) => {
|
const movePrize = async (idx: number, dir: -1 | 1) => {
|
||||||
if (!prizeSweep) return;
|
if (!editing) return;
|
||||||
const arr = [...prizes];
|
const arr = [...prizes];
|
||||||
const ni = idx + dir;
|
const ni = idx + dir;
|
||||||
if (ni < 0 || ni >= arr.length) return;
|
if (ni < 0 || ni >= arr.length) return;
|
||||||
@@ -184,12 +184,12 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
arr[idx] = arr[ni];
|
arr[idx] = arr[ni];
|
||||||
arr[ni] = tmp;
|
arr[ni] = tmp;
|
||||||
setPrizes(arr);
|
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]);
|
useEffect(() => { load(); }, [status]);
|
||||||
|
|
||||||
const openCreate = () => { setEditing(null); setForm(defaultForm); onOpen(); };
|
const openCreate = () => { setEditing(null); setForm(defaultForm); setPrizes([]); setActiveTab(0); onOpen(); };
|
||||||
const openEdit = (it: Sweepstake) => {
|
const openEdit = (it: Sweepstake) => {
|
||||||
setEditing(it);
|
setEditing(it);
|
||||||
setForm({
|
setForm({
|
||||||
@@ -205,7 +205,9 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
entry_cost_points: (it as any).entry_cost_points ?? 0,
|
entry_cost_points: (it as any).entry_cost_points ?? 0,
|
||||||
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
|
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
|
||||||
});
|
});
|
||||||
|
setActiveTab(0);
|
||||||
onOpen();
|
onOpen();
|
||||||
|
(async ()=>{ try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); } })();
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -229,12 +231,16 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
await adminUpdateSweepstake(editing.id, payload);
|
await adminUpdateSweepstake(editing.id, payload);
|
||||||
toast({ status: 'success', title: 'Uloženo' });
|
toast({ status: 'success', title: 'Uloženo' });
|
||||||
|
onClose();
|
||||||
|
await load();
|
||||||
} else {
|
} else {
|
||||||
await adminCreateSweepstake(payload);
|
const created = await adminCreateSweepstake(payload);
|
||||||
toast({ status: 'success', title: 'Vytvořeno' });
|
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) {
|
} catch (e: any) {
|
||||||
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
|
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
|
||||||
}
|
}
|
||||||
@@ -325,106 +331,206 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal with tabs */}
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
|
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton />
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<VStack spacing={4} align="stretch">
|
<Tabs index={activeTab} onChange={setActiveTab as any} isFitted>
|
||||||
<FormControl isRequired>
|
<TabList>
|
||||||
<FormLabel>Název</FormLabel>
|
<Tab>Základní</Tab>
|
||||||
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
|
<Tab>Termíny a limity</Tab>
|
||||||
</FormControl>
|
<Tab>Výhry</Tab>
|
||||||
<FormControl>
|
</TabList>
|
||||||
<FormLabel>Popis</FormLabel>
|
<TabPanels>
|
||||||
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
|
<TabPanel>
|
||||||
</FormControl>
|
<VStack spacing={4} align="stretch">
|
||||||
<SimpleGrid columns={2} spacing={4}>
|
<FormControl isRequired>
|
||||||
<FormControl>
|
<FormLabel>Název</FormLabel>
|
||||||
<FormLabel>Začátek</FormLabel>
|
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
|
||||||
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
|
</FormControl>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormLabel>Popis</FormLabel>
|
||||||
<FormLabel>Konec</FormLabel>
|
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
|
||||||
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
|
</FormControl>
|
||||||
</FormControl>
|
<SimpleGrid columns={2} spacing={4}>
|
||||||
</SimpleGrid>
|
<FormControl>
|
||||||
<SimpleGrid columns={2} spacing={4}>
|
<FormLabel>Titulní obrázek</FormLabel>
|
||||||
<FormControl>
|
<VStack align="start" spacing={2}>
|
||||||
<FormLabel>Styl vizualizace</FormLabel>
|
<HStack>
|
||||||
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
|
<Image src={coverPreview || form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
|
||||||
<option value="wheel">Kolo štěstí</option>
|
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||||
<option value="cycler">Náhodný přepínač</option>
|
Nahrát
|
||||||
</Select>
|
<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' }); } }} />
|
||||||
</FormControl>
|
</Button>
|
||||||
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
|
{form.image_url && (<Button size="sm" variant="ghost" onClick={()=>{ setForm((prev:any)=>({ ...prev, image_url: '' })); setCoverPreview(''); }}>Odebrat</Button>)}
|
||||||
<FormLabel>Počet výherců</FormLabel>
|
</HStack>
|
||||||
<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 })}>
|
<Input placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
|
||||||
<NumberInputField />
|
</VStack>
|
||||||
</NumberInput>
|
</FormControl>
|
||||||
<FormHelperText>Max. 100 výherců</FormHelperText>
|
<FormControl>
|
||||||
</FormControl>
|
<FormLabel>Pravidla</FormLabel>
|
||||||
</SimpleGrid>
|
<VStack align="start" spacing={2}>
|
||||||
<HStack>
|
<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 as="label" leftIcon={<FiUpload />} variant="outline">
|
||||||
<Button size="sm" onClick={async ()=>{
|
Nahrát PDF/obrázek
|
||||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
|
||||||
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' }); }
|
</Button>
|
||||||
}}>1× Hlavní výhra</Button>
|
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
|
||||||
<Button size="sm" onClick={async ()=>{
|
{form.rules_url && (<Button as={RouterLink} to={form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
|
||||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
</HStack>
|
||||||
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' }); }
|
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
|
||||||
}}>3× Menší výhry</Button>
|
</VStack>
|
||||||
<Button size="sm" onClick={async ()=>{
|
</FormControl>
|
||||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
</SimpleGrid>
|
||||||
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' }); }
|
</VStack>
|
||||||
}}>10× 100 bodů</Button>
|
</TabPanel>
|
||||||
<Button size="sm" onClick={async ()=>{
|
<TabPanel>
|
||||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
<VStack spacing={4} align="stretch">
|
||||||
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' }); }
|
<SimpleGrid columns={2} spacing={4}>
|
||||||
}}>5× 500 XP</Button>
|
<FormControl>
|
||||||
</HStack>
|
<FormLabel>Začátek</FormLabel>
|
||||||
<SimpleGrid columns={3} spacing={4}>
|
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
|
||||||
<FormControl>
|
</FormControl>
|
||||||
<FormLabel>Vstupné (body)</FormLabel>
|
<FormControl>
|
||||||
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
|
<FormLabel>Konec</FormLabel>
|
||||||
<NumberInputField />
|
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
|
||||||
</NumberInput>
|
</FormControl>
|
||||||
</FormControl>
|
</SimpleGrid>
|
||||||
<FormControl>
|
<SimpleGrid columns={2} spacing={4}>
|
||||||
<FormLabel>Max. účastí / uživatel</FormLabel>
|
<FormControl>
|
||||||
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
|
<FormLabel>Styl vizualizace</FormLabel>
|
||||||
<NumberInputField />
|
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
|
||||||
</NumberInput>
|
<option value="wheel">Kolo štěstí</option>
|
||||||
</FormControl>
|
<option value="cycler">Náhodný přepínač</option>
|
||||||
</SimpleGrid>
|
</Select>
|
||||||
<SimpleGrid columns={2} spacing={4}>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
|
||||||
<FormLabel>Titulní obrázek</FormLabel>
|
<FormLabel>Počet výherců</FormLabel>
|
||||||
<HStack>
|
<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 })}>
|
||||||
<Image src={form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
|
<NumberInputField />
|
||||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
</NumberInput>
|
||||||
Nahrát
|
<FormHelperText>Max. 100 výherců</FormHelperText>
|
||||||
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={(e)=>onUploadImage(e.target.files?.[0])} />
|
</FormControl>
|
||||||
</Button>
|
</SimpleGrid>
|
||||||
</HStack>
|
<SimpleGrid columns={3} spacing={4}>
|
||||||
<Input mt={2} placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
|
<FormControl>
|
||||||
</FormControl>
|
<FormLabel>Vstupné (body)</FormLabel>
|
||||||
<FormControl>
|
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
|
||||||
<FormLabel>Pravidla</FormLabel>
|
<NumberInputField />
|
||||||
<HStack>
|
</NumberInput>
|
||||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
</FormControl>
|
||||||
Nahrát PDF/obrázek
|
<FormControl>
|
||||||
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
|
<FormLabel>Max. účastí / uživatel</FormLabel>
|
||||||
</Button>
|
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
|
||||||
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
|
<NumberInputField />
|
||||||
</HStack>
|
</NumberInput>
|
||||||
<Input mt={2} placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
|
</FormControl>
|
||||||
</FormControl>
|
</SimpleGrid>
|
||||||
</SimpleGrid>
|
</VStack>
|
||||||
</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>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<HStack>
|
<HStack>
|
||||||
@@ -434,96 +540,6 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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>
|
</Container>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ function normalize(s: string): string {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
// Unify various dash characters to a simple hyphen
|
// Unify various dash characters to a simple hyphen
|
||||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
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
|
// 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, '');
|
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||||
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||||
@@ -140,6 +142,16 @@ const TeamsAdminPage = () => {
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||||
|
// 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
|
// Build an index by normalized team name for overrides that carry an ID
|
||||||
const overridesNameIndex = useMemo(() => {
|
const overridesNameIndex = useMemo(() => {
|
||||||
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
|
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
|
||||||
@@ -168,7 +180,7 @@ const TeamsAdminPage = () => {
|
|||||||
for (const comp of competitions) {
|
for (const comp of competitions) {
|
||||||
const rows: TableRow[] = comp?.table?.overall || [];
|
const rows: TableRow[] = comp?.table?.overall || [];
|
||||||
for (const r of rows) {
|
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 {
|
else {
|
||||||
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
|
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||||
if (derived) teamIds.add(derived);
|
if (derived) teamIds.add(derived);
|
||||||
@@ -200,8 +212,9 @@ const TeamsAdminPage = () => {
|
|||||||
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||||
// Priority 0: Admin override by team ID
|
// Priority 0: Admin override by team ID
|
||||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
|
const tid = teamId ? String(teamId).toLowerCase() : '';
|
||||||
const u = String(overridesById[teamId].logo_url);
|
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.logo_url) {
|
||||||
|
const u = String(overridesByIdLC[tid].logo_url);
|
||||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
@@ -254,8 +267,8 @@ const TeamsAdminPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: logoapi.sportcreative.eu if we have a team ID
|
// Priority 2: logoapi.sportcreative.eu if we have a team ID
|
||||||
if (teamId && sportLogosMap[teamId]) {
|
if (tid && sportLogosMap[tid]) {
|
||||||
return sportLogosMap[teamId];
|
return sportLogosMap[tid];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: FACR original
|
// Priority 3: FACR original
|
||||||
@@ -268,8 +281,9 @@ const TeamsAdminPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getName = (teamName?: string, teamId?: string) => {
|
const getName = (teamName?: string, teamId?: string) => {
|
||||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
|
const tid = teamId ? String(teamId).toLowerCase() : '';
|
||||||
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
|
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
|
// If no ID, but override exists for the normalized name, use canonical override name
|
||||||
try {
|
try {
|
||||||
@@ -326,6 +340,7 @@ const TeamsAdminPage = () => {
|
|||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const rawName = (r.team || '').trim();
|
const rawName = (r.team || '').trim();
|
||||||
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||||
|
if (teamId) teamId = String(teamId).toLowerCase();
|
||||||
if (!teamId && mainClubId) {
|
if (!teamId && mainClubId) {
|
||||||
const rn = normalize(rawName);
|
const rn = normalize(rawName);
|
||||||
if (
|
if (
|
||||||
@@ -431,7 +446,30 @@ const TeamsAdminPage = () => {
|
|||||||
|
|
||||||
const onSave = useMutation({
|
const onSave = useMutation({
|
||||||
mutationFn: async () => {
|
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).');
|
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
|
||||||
}
|
}
|
||||||
let logoUrl = (form.logo_url || '').trim();
|
let logoUrl = (form.logo_url || '').trim();
|
||||||
@@ -443,8 +481,8 @@ const TeamsAdminPage = () => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
|
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
|
||||||
try {
|
try {
|
||||||
if (!uploadedFile && form.external_team_id) {
|
if (!uploadedFile && extTeamId) {
|
||||||
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
const apiLogo = await fetchLogoFromLogoAPI(extTeamId, primaryName);
|
||||||
if (apiLogo) {
|
if (apiLogo) {
|
||||||
logoUrl = apiLogo;
|
logoUrl = apiLogo;
|
||||||
}
|
}
|
||||||
@@ -482,10 +520,10 @@ const TeamsAdminPage = () => {
|
|||||||
}
|
}
|
||||||
if (logoFileToUpload) {
|
if (logoFileToUpload) {
|
||||||
const logaResult = await uploadToLogaSportcreative(
|
const logaResult = await uploadToLogaSportcreative(
|
||||||
form.external_team_id,
|
extTeamId,
|
||||||
logoFileToUpload,
|
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'
|
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -497,7 +535,7 @@ const TeamsAdminPage = () => {
|
|||||||
try {
|
try {
|
||||||
let confirmedUrl: string | null = null;
|
let confirmedUrl: string | null = null;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
confirmedUrl = await fetchLogoFromLogoAPI(extTeamId, primaryName);
|
||||||
if (confirmedUrl) break;
|
if (confirmedUrl) break;
|
||||||
await new Promise((r) => setTimeout(r, 700));
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -706,7 +744,8 @@ const TeamsAdminPage = () => {
|
|||||||
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
||||||
<Td py={1.5}>
|
<Td py={1.5}>
|
||||||
<Button size="xs" fontSize="xs" onClick={() => {
|
<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 displayName = getName(r.team, tid);
|
||||||
const key = tid ? `id:${tid}` : normalize(displayName);
|
const key = tid ? `id:${tid}` : normalize(displayName);
|
||||||
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
|
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
|
||||||
|
|||||||
@@ -821,6 +821,15 @@ html {
|
|||||||
100% { transform: translateX(-33.333%); }
|
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 */
|
||||||
.matches-slider { margin: 12px 0 20px; }
|
.matches-slider { margin: 12px 0 20px; }
|
||||||
.matches-slider .matches-grid {
|
.matches-slider .matches-grid {
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export type CommentBan = {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
until?: string | null;
|
until?: string | null;
|
||||||
created_at: string;
|
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[] }>{
|
export async function adminListBans(): Promise<{ items: CommentBan[] }>{
|
||||||
@@ -49,6 +58,14 @@ export type UnbanRequest = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
resolved_by_id?: number | null;
|
resolved_by_id?: number | null;
|
||||||
resolved_at?: string | 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[] }>{
|
export async function adminListUnbanRequests(): Promise<{ items: UnbanRequest[] }>{
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ export interface NewsletterStatus {
|
|||||||
interval_minutes: number;
|
interval_minutes: number;
|
||||||
next_approximate: string;
|
next_approximate: string;
|
||||||
newsletter_enabled?: boolean;
|
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> => {
|
export const getNewsletterStatus = async (): Promise<NewsletterStatus> => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type CommentItem = {
|
|||||||
id: number;
|
id: number;
|
||||||
target_type: TargetType;
|
target_type: TargetType;
|
||||||
target_id: string;
|
target_id: string;
|
||||||
|
target_label?: string;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
content: string;
|
content: string;
|
||||||
status?: 'visible' | 'hidden';
|
status?: 'visible' | 'hidden';
|
||||||
|
|||||||
@@ -36,8 +36,15 @@ export async function createPublicShortLink(payload: { target_url: string; title
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listShortLinks(): Promise<{ items: any[] }> {
|
export async function listShortLinks(): Promise<{ items: any[] }> {
|
||||||
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
|
// Prefer editor-accessible endpoint
|
||||||
return res.data;
|
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> {
|
export async function getShortLinkStats(id: number | string): Promise<any> {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func LoadConfig() {
|
|||||||
|
|
||||||
// File upload settings
|
// File upload settings
|
||||||
UploadDir: getEnv("UPLOAD_DIR", "./uploads"),
|
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{
|
AllowedMimeTypes: []string{
|
||||||
// Images
|
// Images
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
|
|||||||
@@ -72,7 +72,65 @@ func generateTeamNameAliases(name string) []string {
|
|||||||
es := abbreviateAmpersand(e)
|
es := abbreviateAmpersand(e)
|
||||||
if es != "" && es != base && es != t && es != e { add(es) }
|
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 {
|
for _, v := range variants {
|
||||||
if strings.TrimSpace(v) == "" { continue }
|
if strings.TrimSpace(v) == "" { continue }
|
||||||
nd := strings.ReplaceAll(v, ".", "")
|
nd := strings.ReplaceAll(v, ".", "")
|
||||||
@@ -84,7 +142,7 @@ func generateTeamNameAliases(name string) []string {
|
|||||||
return out
|
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 {
|
func trimLegalSuffixes(s string) string {
|
||||||
return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, ""))
|
return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, ""))
|
||||||
@@ -96,6 +154,25 @@ var (
|
|||||||
reMultiSpace = regexp.MustCompile(`\s+`)
|
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 {
|
func expandPNAbbrev(s string) string {
|
||||||
if s == "" { return s }
|
if s == "" { return s }
|
||||||
x := reAbbrevP.ReplaceAllString(s, "pod ")
|
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 {
|
if err := bc.DB.Find(&tlovs).Error; err == nil {
|
||||||
tloByID := map[string]models.TeamLogoOverride{}
|
tloByID := map[string]models.TeamLogoOverride{}
|
||||||
for _, it := range tlovs {
|
for _, it := range tlovs {
|
||||||
tloByID[it.ExternalTeamID] = it
|
if it.ExternalTeamID == "" { continue }
|
||||||
|
tloByID[strings.ToLower(it.ExternalTeamID)] = it
|
||||||
}
|
}
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
id, _ := rows[i]["team_id"].(string)
|
id, _ := rows[i]["team_id"].(string)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tlo, ok := tloByID[id]; ok {
|
if tlo, ok := tloByID[strings.ToLower(id)]; ok {
|
||||||
if strings.TrimSpace(tlo.TeamName) != "" {
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
||||||
rows[i]["team"] = tlo.TeamName
|
rows[i]["team"] = tlo.TeamName
|
||||||
}
|
}
|
||||||
@@ -2059,101 +2137,166 @@ func computeEstimatedReadMinutes(html string) int {
|
|||||||
return minutes
|
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) {
|
func (bc *BaseController) GetAdminMatches(c *gin.Context) {
|
||||||
// Read cached events
|
// Read cached FACR club info (contains competitions with matches)
|
||||||
p := filepath.Join("cache", "prefetch", "events_upcoming.json")
|
p := filepath.Join("cache", "prefetch", "facr_club_info.json")
|
||||||
f, err := os.Open(p)
|
f, err := os.Open(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
|
c.JSON(http.StatusNoContent, gin.H{"message": "No cached FACR matches"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load overrides
|
var facr struct {
|
||||||
var movs []models.MatchOverride
|
Competitions []struct {
|
||||||
if err := bc.DB.Find(&movs).Error; err != nil {
|
ID string `json:"id"`
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
|
Name string `json:"name"`
|
||||||
return
|
Matches []struct {
|
||||||
}
|
MatchID string `json:"match_id"`
|
||||||
movByID := map[string]models.MatchOverride{}
|
DateTime string `json:"date_time"`
|
||||||
for _, m := range movs {
|
Date string `json:"date"`
|
||||||
movByID[m.ExternalMatchID] = m
|
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
|
// Helper to pick first non-empty string
|
||||||
if err := bc.DB.Find(&tlovs).Error; err != nil {
|
firstNonEmpty := func(ss ...string) string {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
|
for _, s := range ss {
|
||||||
return
|
s = strings.TrimSpace(s)
|
||||||
}
|
if s != "" {
|
||||||
tloByTeam := map[string]models.TeamLogoOverride{}
|
return s
|
||||||
for _, t := range tlovs {
|
}
|
||||||
tloByTeam[t.ExternalTeamID] = t
|
}
|
||||||
}
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Apply overrides in-place
|
// Flatten and normalize to a simple slice of maps
|
||||||
for _, m := range matches {
|
items := make([]map[string]any, 0, 256)
|
||||||
// External match ID
|
for _, comp := range facr.Competitions {
|
||||||
var matchID string
|
for _, m := range comp.Matches {
|
||||||
if v, ok := m["match_id"].(string); ok {
|
id := strings.TrimSpace(m.MatchID)
|
||||||
matchID = v
|
if id == "" {
|
||||||
} else if v2, ok2 := m["id"].(string); ok2 {
|
continue
|
||||||
matchID = v2
|
}
|
||||||
}
|
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 {
|
// Load overrides and apply
|
||||||
if ov.HomeNameOverride != nil {
|
var movs []models.MatchOverride
|
||||||
m["home"] = *ov.HomeNameOverride
|
if err := bc.DB.Find(&movs).Error; err != nil {
|
||||||
m["home_team"] = *ov.HomeNameOverride
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
|
||||||
}
|
return
|
||||||
if ov.AwayNameOverride != nil {
|
}
|
||||||
m["away"] = *ov.AwayNameOverride
|
movByID := map[string]models.MatchOverride{}
|
||||||
m["away_team"] = *ov.AwayNameOverride
|
for _, m := range movs {
|
||||||
}
|
movByID[m.ExternalMatchID] = m
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Team-logo overrides by team id
|
var tlovs []models.TeamLogoOverride
|
||||||
if homeID, ok := m["home_id"].(string); ok {
|
if err := bc.DB.Find(&tlovs).Error; err != nil {
|
||||||
if tlo, ok := tloByTeam[homeID]; ok {
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
|
||||||
if tlo.LogoURL != "" {
|
return
|
||||||
m["home_logo_url"] = tlo.LogoURL
|
}
|
||||||
}
|
tloByTeam := map[string]models.TeamLogoOverride{}
|
||||||
if tlo.TeamName != "" {
|
for _, t := range tlovs {
|
||||||
m["home"] = tlo.TeamName
|
tloByTeam[t.ExternalTeamID] = t
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ---
|
// --- 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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Best-effort: update public snapshot cache so frontend fallback sees latest aliases
|
||||||
|
go bc.writeTeamLogoOverridesCache()
|
||||||
c.JSON(http.StatusOK, item)
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Successful deletion
|
||||||
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
|
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) {
|
func (bc *BaseController) UploadImage(c *gin.Context) {
|
||||||
f, err := c.FormFile("file")
|
f, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Enforce maximum upload size (bytes)
|
// Enforce maximum upload size (bytes)
|
||||||
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
|
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(f.Filename)
|
name := strings.TrimSpace(f.Filename)
|
||||||
ext := strings.ToLower(filepath.Ext(name))
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".pdf": true}
|
// Allow images, PDFs, Office docs, text, archives, and common media
|
||||||
if !allowed[ext] {
|
allowed := map[string]bool{
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
|
// Images
|
||||||
return
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true,
|
||||||
}
|
// Documents
|
||||||
// Light content sniffing to ensure the uploaded payload matches the declared extension
|
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".csv": true,
|
||||||
// and basic SVG sanitization (reject obvious script/event patterns).
|
// Archives
|
||||||
if src, err := f.Open(); err == nil {
|
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true,
|
||||||
buf := make([]byte, 2048)
|
// Media
|
||||||
n, _ := src.Read(buf)
|
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true,
|
||||||
_ = src.Close()
|
}
|
||||||
detected := http.DetectContentType(buf[:n])
|
if !allowed[ext] {
|
||||||
validCT := false
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
|
||||||
switch ext {
|
return
|
||||||
case ".pdf":
|
}
|
||||||
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
|
// Light content sniffing to ensure payload matches extension and sanitize SVGs
|
||||||
case ".svg":
|
if src, err := f.Open(); err == nil {
|
||||||
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
|
defer src.Close()
|
||||||
default:
|
buf := make([]byte, 2048)
|
||||||
validCT = strings.HasPrefix(detected, "image/")
|
n, _ := io.ReadFull(src, buf)
|
||||||
}
|
if n < 0 { n = 0 }
|
||||||
if !validCT {
|
dl := strings.ToLower(http.DetectContentType(buf[:n]))
|
||||||
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)
|
|
||||||
|
|
||||||
// Build absolute URL from request (supports proxies)
|
validCT := false
|
||||||
scheme := "http"
|
switch ext {
|
||||||
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") {
|
case ".pdf":
|
||||||
scheme = "https"
|
validCT = strings.Contains(dl, "pdf") || dl == "application/octet-stream"
|
||||||
}
|
case ".svg":
|
||||||
host := c.Request.Host
|
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
|
||||||
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
lower := strings.ToLower(string(buf[:n]))
|
||||||
// Take the first value if comma-separated
|
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
|
||||||
parts := strings.Split(xf, ",")
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
|
||||||
if len(parts) > 0 {
|
return
|
||||||
h := strings.TrimSpace(parts[0])
|
}
|
||||||
if h != "" {
|
case ".docx", ".xlsx", ".pptx":
|
||||||
host = h
|
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":
|
||||||
// Append forwarded port when host has no explicit port and it's non-default
|
validCT = strings.Contains(dl, "zip") || dl == "application/octet-stream"
|
||||||
if !strings.Contains(host, ":") {
|
case ".rar":
|
||||||
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
|
validCT = strings.Contains(dl, "rar") || dl == "application/octet-stream"
|
||||||
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
|
case ".7z":
|
||||||
host = host + ":" + xfp
|
validCT = strings.Contains(dl, "7z") || dl == "application/octet-stream"
|
||||||
}
|
case ".tar":
|
||||||
}
|
validCT = strings.Contains(dl, "tar") || dl == "application/octet-stream"
|
||||||
}
|
case ".gz":
|
||||||
absolute := scheme + "://" + host + urlPath
|
validCT = strings.Contains(dl, "gzip") || dl == "application/octet-stream"
|
||||||
c.JSON(http.StatusOK, gin.H{
|
case ".txt", ".csv":
|
||||||
// Always return a backend-relative path for storage
|
validCT = strings.HasPrefix(dl, "text/") || dl == "application/octet-stream"
|
||||||
"url": urlPath,
|
case ".mp4", ".avi", ".mov":
|
||||||
// Convenience absolute URL for immediate usage in UIs
|
validCT = strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
|
||||||
"absolute_url": absolute,
|
case ".mp3", ".wav":
|
||||||
// Basic metadata (best-effort)
|
validCT = strings.HasPrefix(dl, "audio/") || dl == "application/octet-stream"
|
||||||
"name": outName,
|
default:
|
||||||
"type": mimeType,
|
validCT = strings.HasPrefix(dl, "image/")
|
||||||
"size": f.Size,
|
}
|
||||||
})
|
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)
|
// Global newsletter automation instance (set from main)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"fotbal-club/internal/models"
|
"fotbal-club/internal/models"
|
||||||
"fotbal-club/internal/services"
|
"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 {
|
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.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
|
// Admin: lift a ban early by setting until = now
|
||||||
@@ -74,11 +124,12 @@ func (cc *CommentController) React(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, _ := c.Get("userID")
|
uid, _ := c.Get("userID")
|
||||||
// delete previous reaction for this user
|
// Upsert reaction to ensure exactly one reaction per (comment_id,user_id)
|
||||||
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
|
|
||||||
// create new
|
|
||||||
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -140,8 +191,71 @@ func (cc *CommentController) AdminList(c *gin.Context) {
|
|||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
for _, r := range rows { adminLiked[r.CommentID] = true }
|
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))
|
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})
|
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
|
// Admin: list unban requests
|
||||||
func (cc *CommentController) AdminListUnban(c *gin.Context) {
|
func (cc *CommentController) AdminListUnban(c *gin.Context) {
|
||||||
|
// Only pending requests
|
||||||
var items []models.UnbanRequest
|
var items []models.UnbanRequest
|
||||||
_ = cc.DB.Order("created_at DESC").Find(&items).Error
|
_ = cc.DB.Where("status = ?", "pending").Order("created_at DESC").Find(&items).Error
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
// 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
|
// Admin: resolve unban request
|
||||||
@@ -218,6 +383,7 @@ type commentOutput struct {
|
|||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
TargetType string `json:"target_type"`
|
TargetType string `json:"target_type"`
|
||||||
TargetID string `json:"target_id"`
|
TargetID string `json:"target_id"`
|
||||||
|
TargetLabel string `json:"target_label,omitempty"`
|
||||||
ParentID *uint `json:"parent_id,omitempty"`
|
ParentID *uint `json:"parent_id,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -33,16 +33,93 @@ func (cc *ContactController) GetContactMessages(c *gin.Context) {
|
|||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
|
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
|
||||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
|
||||||
if page <= 0 { page = 1 }
|
if page <= 0 {
|
||||||
if limit <= 0 || limit > 200 { limit = 50 }
|
page = 1
|
||||||
items, total, err := models.GetContactMessages(cc.DB, page, limit)
|
}
|
||||||
if err != nil {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
|
||||||
return
|
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)
|
// 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
|
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active).Error
|
||||||
enabled := active > 0
|
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
|
var s models.Settings
|
||||||
_ = cc.DB.First(&s).Error
|
_ = cc.DB.First(&s).Error
|
||||||
if s.ID == 0 {
|
if s.ID == 0 {
|
||||||
s = models.Settings{}
|
s = models.Settings{}
|
||||||
}
|
}
|
||||||
|
changed := false
|
||||||
if s.NewsletterEnabled != enabled {
|
if s.NewsletterEnabled != enabled {
|
||||||
s.NewsletterEnabled = enabled
|
s.NewsletterEnabled = enabled
|
||||||
if s.ID == 0 {
|
changed = true
|
||||||
_ = cc.DB.Create(&s).Error
|
}
|
||||||
} else {
|
if enabled {
|
||||||
_ = cc.DB.Save(&s).Error
|
// 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
|
// Update runtime
|
||||||
@@ -736,6 +830,8 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
|||||||
var total, active int64
|
var total, active int64
|
||||||
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
||||||
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
||||||
|
var s models.Settings
|
||||||
|
_ = cc.DB.First(&s).Error
|
||||||
var subs []models.NewsletterSubscription
|
var subs []models.NewsletterSubscription
|
||||||
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
||||||
sample := make([]string, 0, len(subs))
|
sample := make([]string, 0, len(subs))
|
||||||
@@ -751,6 +847,31 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
next := time.Now().Add(interval)
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"total_subscribers": total,
|
"total_subscribers": total,
|
||||||
"active_subscribers": active,
|
"active_subscribers": active,
|
||||||
@@ -758,6 +879,16 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
|||||||
"interval_minutes": int(interval.Minutes()),
|
"interval_minutes": int(interval.Minutes()),
|
||||||
"next_approximate": next,
|
"next_approximate": next,
|
||||||
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
|
"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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ type EngagementController struct {
|
|||||||
Email email.EmailService
|
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 {
|
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
|
||||||
return &EngagementController{DB: db, Email: es}
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
|
||||||
return
|
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)
|
// 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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not active"})
|
||||||
return
|
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 {
|
if item.Stock == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func (ec *ErrorController) AdminListExternal(c *gin.Context) {
|
|||||||
if !errorLocal {
|
if !errorLocal {
|
||||||
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
|
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"))
|
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
|
||||||
u, err := url.Parse(base)
|
u, err := url.Parse(base)
|
||||||
@@ -263,7 +263,7 @@ func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
|
|||||||
if !errorLocal {
|
if !errorLocal {
|
||||||
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
|
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"))
|
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
|
||||||
u, err := url.Parse(base)
|
u, err := url.Parse(base)
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ func (fc *FilesController) RefreshFileTracking(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "File tracking refreshed successfully",
|
"message": "Sledování souborů bylo úspěšně aktualizováno",
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,35 +162,106 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var updates models.NavigationItem
|
// Bind into a generic map to know which fields are present (partial update)
|
||||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
var raw map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update fields
|
// Allow-list of updatable fields and basic type normalization
|
||||||
item.Label = updates.Label
|
updates := map[string]interface{}{}
|
||||||
item.URL = updates.URL
|
|
||||||
item.Icon = updates.Icon
|
if v, ok := raw["label"]; ok {
|
||||||
item.Type = updates.Type
|
if s, ok2 := v.(string); ok2 { updates["label"] = s }
|
||||||
item.PageType = updates.PageType
|
}
|
||||||
item.PageID = updates.PageID
|
if v, ok := raw["url"]; ok {
|
||||||
item.Visible = updates.Visible
|
if s, ok2 := v.(string); ok2 { updates["url"] = s }
|
||||||
item.DisplayOrder = updates.DisplayOrder
|
}
|
||||||
item.ParentID = updates.ParentID
|
if v, ok := raw["icon"]; ok {
|
||||||
item.Target = updates.Target
|
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
|
||||||
item.CSSClass = updates.CSSClass
|
}
|
||||||
item.RequiresAuth = updates.RequiresAuth
|
if v, ok := raw["type"]; ok {
|
||||||
item.RequiresAdmin = updates.RequiresAdmin
|
if s, ok2 := v.(string); ok2 { updates["type"] = s }
|
||||||
|
}
|
||||||
if err := nc.DB.Save(&item).Error; err != nil {
|
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{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Failed to update navigation item",
|
"error": "Failed to update navigation item",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
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)
|
c.JSON(http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,8 +595,8 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
|||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
if err := createChild(obsah, "Články", "articles", 0); 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, "Aktivity", "activities", 1); err != nil { return err }
|
||||||
if err := createChild(obsah, "Kategorie", "categories", 2); err != nil { return err }
|
// Kategorie admin page removed (categories derived from competition aliases)
|
||||||
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
|
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
|
||||||
|
|
||||||
media, err := createCategory("Média")
|
media, err := createCategory("Média")
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
|
|||||||
@@ -301,10 +301,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
admin.DELETE("/competition-aliases/:code", baseController.DeleteCompetitionAlias)
|
admin.DELETE("/competition-aliases/:code", baseController.DeleteCompetitionAlias)
|
||||||
admin.POST("/competition-aliases/reorder", baseController.ReorderCompetitionAliases)
|
admin.POST("/competition-aliases/reorder", baseController.ReorderCompetitionAliases)
|
||||||
|
|
||||||
// Categories (admin)
|
// Categories admin removed: categories are derived from competition aliases
|
||||||
admin.POST("/categories", baseController.CreateCategory)
|
|
||||||
admin.PUT("/categories/:id", baseController.UpdateCategory)
|
|
||||||
admin.DELETE("/categories/:id", baseController.DeleteCategory)
|
|
||||||
|
|
||||||
// Settings (singleton)
|
// Settings (singleton)
|
||||||
admin.GET("/settings", baseController.GetSettings)
|
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", baseController.GetMatches)
|
||||||
api.GET("/matches/history", baseController.GetMatchesHistory)
|
api.GET("/matches/history", baseController.GetMatchesHistory)
|
||||||
api.GET("/standings", baseController.GetStandings)
|
api.GET("/standings", baseController.GetStandings)
|
||||||
|
|
||||||
api.GET("/gallery/albums", galleryController.GetGalleryAlbums)
|
api.GET("/gallery/albums", galleryController.GetGalleryAlbums)
|
||||||
api.GET("/gallery/albums/:id", galleryController.GetGalleryAlbum)
|
api.GET("/gallery/albums/:id", galleryController.GetGalleryAlbum)
|
||||||
api.GET("/gallery/proxy-image", galleryController.ProxyImage)
|
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/current", sweepstakesController.GetCurrent)
|
||||||
api.GET("/sweepstakes/:id/visual", sweepstakesController.PublicVisualData)
|
api.GET("/sweepstakes/:id/visual", sweepstakesController.PublicVisualData)
|
||||||
|
|
||||||
api.GET("/polls", pollController.GetPolls)
|
pollsPub := api.Group("/polls")
|
||||||
api.GET("/polls/:id", pollController.GetPoll)
|
pollsPub.Use(middleware.JWTOptional(db))
|
||||||
api.POST("/polls/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
|
{
|
||||||
api.GET("/polls/:id/results", pollController.GetPollResults)
|
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("/contact", middleware.RateLimit(10, time.Minute), contactController.SubmitContactForm)
|
||||||
api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter)
|
api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter)
|
||||||
|
|||||||