This commit is contained in:
Tomas Dvorak
2025-10-23 22:26:50 +02:00
parent 63700eedb2
commit 70ea0c3c91
75 changed files with 3337 additions and 1160 deletions
+39
View File
@@ -0,0 +1,39 @@
{
"items": [
{
"ID": 1,
"CreatedAt": "2025-10-23T12:27:56.795628Z",
"UpdatedAt": "2025-10-23T12:30:27.920031Z",
"DeletedAt": null,
"title": "A tým slaví vítězství v Vítkovicích: Obrovský obrat ve druhé půli",
"content": "\u003ch2\u003eA tým slaví vítězství v Vítkovicích: Obrovský obrat ve druhé půli\u003c/h2\u003e\u003cp\u003eV nedávném zápase proti Vítkovicím 'B' dokázal A tým ukázat svou bojovnost a taktickou zdatnost, když po nesourodém prvním poločase zvrátil zápas a nakonec odjel z Vítkovic s cennými třemi body. Hráči A týmu prokázali, že jsou schopni reagovat na nepříznivý průběh zápasu a dokázat se vrátit do hry.\u003c/p\u003e\u003ch3\u003ePrvní poločas: Kostrbaté začátky\u003c/h3\u003e\u003cp\u003eZápas začal pomalu a bez výraznějších akcí. Domácí hráči měli mírně větší kontrolu nad hrou, ale jejich útočné akce často končily nekontrolovanými kopanci, které našemu týmu dělaly potíže. Nicméně naše obrana dokázala držet a udržovat pořádek až do 25. minuty, kdy domácí střelci zaskočili krásnou střelou z úhlu branky 1:0 pro Vítkovice.\u003c/p\u003e\u003cp\u003eDo konce prvního poločasu se ve hře nic výrazného nezměnilo. Hráči A týmu se snažili udržet obranu a minimalizovat riziko, ale diváci nemohli být spokojeni. Přestože jsme odjížděli do Vítkovic s cílem získat alespoň bod, první poločas nevyhověl našim očekáváním.\u003c/p\u003e\u003ch3\u003eDruhý poločas: Taktické přeskupení a obrat\u003c/h3\u003e\u003cp\u003eV přestávce provedl trenér Hrdlička důležité taktické změny. Na hrotu nastoupili zkušenější, i když mladí, hráči Gramel, Hrdlička a Kolbasa. Tato změna se ukázala být klíčová. Od prvních minut druhé půle byli domácí pod tlakem a herní projev našeho týmu se výrazně zlepšil.\u003c/p\u003e\u003cp\u003eV 48. minutě Koval vyrovnal po centru do vápna 1:1. Šancí na naší straně přibývalo a bylo jen otázkou času, kdy skórujeme podruhé. Ve 69. minutě Koval chytře zakončil technickou střelou kolem gólmana a skóre se změnilo na 1:2.\u003c/p\u003e\u003cp\u003eDomácí se snažili vyrovnat, ale naše mužstvo si vítězství vzít nenechalo a spolehlivou hrou udrželo vedení až do konce zápasu.\u003c/p\u003e\u003ch3\u003eHodnocení výkonu\u003c/h3\u003e\u003cp\u003eMusím všechny hráče pochválit, zejména střídající za bojovnost, srdíčko a přístup, zejména ve druhém poločase, kdy svým nasazením dokázali zápas zvrátit a vyhrát. Tři body z Vítkovic bereme s pokorou.\u003c/p\u003e\u003ch3\u003eSlova trenérů\u003c/h3\u003e\u003cp\u003e\u003cstrong\u003eTrenér Hrdlička:\u003c/strong\u003e „První poločas byl nesourodý, domácí využili jednu šanci a dali krásný gól. Ve druhé půli jsme zvolili překvapivou taktiku se dvěma útočníky a jedním podhrodem. To se ukázalo jako správné rozhodnutí během prvních 15 minut jsme měli tři šance a Koval proměnil oba góly. Domácí trenér nestačil reagovat. Vítězství je zasloužené, protože tým hrál aktivně, pod tlakem a srdcem. Velké poděkování patří všem hráčům, hlavně ve druhé půli,“ řekl trenér Hrdlička.\u003c/p\u003e\u003cp\u003e\u003cstrong\u003eTrenér Štůrala:\u003c/strong\u003e „Na hodnocení utkání bych chtěl jen dodat, že naše taktické změny vycházely výborně a tým ukázal, že fotbal opravdu rád hraje. Ve druhé půli jsme měli převahu a díky Kovalovým gólem jsme zaslouženě získali tři body. Jsme spokojeni a těšíme se na další zápasy,“ doplnil Štůrala.\u003c/p\u003e\u003ch3\u003eVýzva pro fanoušky\u003c/h3\u003e\u003cp\u003eTrenér i tým vyzývají fanoušky, aby přišli podpořit A tým na domácí zápasy, které budou náročné a rozhodující pro náš bodový zisk. Vítáme všechny příznivce, aby nám pomohli vytvořit doma neporažitelné prostředí a podpořili našemu týmu k dalším vítězstvím.\u003c/p\u003e\u003ch3\u003eGóly\u003c/h3\u003e\u003cul\u003e\u003cli\u003eKoval 48'\u003c/li\u003e\u003cli\u003eKoval 69'\u003c/li\u003e\u003c/ul\u003e",
"author_id": 1,
"category_id": 1,
"image_url": "https://eu.zonerama.com/photos/571035614_1500x1000.jpg",
"published": true,
"slug": "a-tymu-porazi-vitkovice-b",
"excerpt": "",
"featured": true,
"seo_title": "A tým slaví vítězství v Vítkovicích: Obrovský obrat ve druhé půli | Fotbalový klub",
"seo_description": "Přečtěte si více o a tým slaví vítězství v vítkovicích: obrovský obrat ve druhé půli. Aktuální informace, novinky a zajímavosti z našeho fotbalového klubu.",
"og_image_url": "https://eu.zonerama.com/photos/571035614_1500x1000.jpg",
"external_link": "",
"view_count": 0,
"read_time": 3,
"unique_views": 0,
"category_name": "",
"attachments": "[{\"mime_type\":\"application/pdf\",\"name\":\"pdf-test.pdf\",\"size\":20597,\"url\":\"/uploads/2025/10/20251023-122902-2a8621440a2749f6dcb7b684d9d05486.pdf\"}]",
"gallery_album_id": "",
"gallery_album_url": "",
"gallery_photo_ids": "",
"youtube_video_id": "WKXh4Z6SYMs",
"youtube_video_title": "Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)",
"youtube_video_url": "https://www.youtube.com/watch?v=WKXh4Z6SYMs",
"youtube_video_thumbnail": "https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg",
"match_link": null
}
],
"page": 1,
"page_size": 1,
"total": 1
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[]
[{"id":1,"created_at":"2025-10-23T11:46:44.420056Z","updated_at":"2025-10-23T11:52:46.171879Z","title":"Rodičovská schůzka mládeže program a pozvánka","description":"\u003ch2\u003eRodičovská schůzka mládeže program a pozvánka\u003c/h2\u003e\u003cp\u003eMilí fanoušci a rodiče našich mládežnických týmů, chystáme pro vás příjemnou a užitečnou rodičovskou schůzku! Tato událost je skvělou příležitostí, jak se seznámit s trenéry, dozvědět se o aktuálních tématech a aktivně se zapojit do života klubu.\u003c/p\u003e\u003ch3\u003eProgram\u003c/h3\u003e\u003cp\u003eNa schůzce se dozvíte o plánovaných akcích, soutěžích a dalších aktivitách, které připravujeme pro naše mladé fotbalisty. Budou také představeny nové projekty, které pomohou rozvíjet talenty našich hráčů.\u003c/p\u003e\u003cp\u003eNezapomeňte, že vaše účast a podpora jsou pro nás neocenitelné. Těšíme se na setkání s vámi a na společně strávený čas.\u003c/p\u003e\u003cp\u003ePřijďte a buďte součástí našich úspěchů!\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cimg src=\"http://localhost:8080/uploads/processed_1761213995865.jpg\"\u003e\u003c/p\u003e","start_time":"2025-10-31T16:49:00Z","end_time":"2025-10-31T18:49:00Z","location":"108, Kostelany nad Moravou, okres Uherské Hradiště, Zlínský kraj, Střední Morava, 686 01, Česko","type":"meeting","category_name":"Okresní přebor mladší přípravky (4+1)","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/2025/10/20251023-114709-461ff3a8f1bafee57c90d0cc5acd6164.jpeg","file_url":"","attachments":[{"id":2,"created_at":"2025-10-23T11:52:46.174646Z","updated_at":"2025-10-23T11:52:46.174646Z","event_id":1,"name":"pdf-test.pdf","url":"/uploads/2025/10/20251023-115214-c6bd2db2ed5a1d456a0ab1c1868f152b.pdf","mime_type":"application/pdf","size":20597}],"youtube_url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs","latitude":49.044086,"longitude":17.4040833}]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:45Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:13Z","last_modified":""}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:50Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:16Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"lastUpdated":"2025-10-21T12:50:50Z"}
{"lastUpdated":"2025-10-23T20:00:16Z"}
+7 -7
View File
@@ -1,7 +1,12 @@
{
"baseURL": "http://127.0.0.1:8080/api/v1",
"duration_ms": 7283,
"duration_ms": 5571,
"endpoints": [
{
"path": "/public/team-logo-overrides",
"file": "team_logo_overrides.json",
"ok": true
},
{
"path": "/competition-aliases",
"file": "competition_aliases.json",
@@ -27,11 +32,6 @@
"file": "events_upcoming.json",
"ok": true
},
{
"path": "/public/team-logo-overrides",
"file": "team_logo_overrides.json",
"ok": true
},
{
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
"file": "facr_club_info.json",
@@ -43,5 +43,5 @@
"ok": true
}
],
"lastUpdated": "2025-10-21T12:50:50Z"
"lastUpdated": "2025-10-23T20:00:16Z"
}
+1 -1
View File
@@ -1 +1 @@
{"about_html":"","accent_color":"#ffb500","background_color":"#ffffff","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_logo_url":"http://logoapi.sportcreative.eu/logos/7eacd9f0-bfa0-4928-a9b6-936140168f58?format=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":"Úvoz","contact_city":"Krnov","contact_country":"Česko","contact_email":"info@tdvorak.dev","contact_phone":"+420 778 701 838","contact_zip":"794 01","custom_nav":null,"facebook_url":"https://www.facebook.com/p/FK-Kofola-Krnov-61561103731912/","font_body":"Archivo","font_heading":"Archivo","gallery_label":"Fotogalerie","gallery_url":"https://eu.zonerama.com/FKKofolaKrnov","instagram_url":"https://www.instagram.com/fkkofolakrnov/","location_latitude":50.0860754,"location_longitude":17.6699647,"map_style":"toner-lite","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","primary_color":"#ffd900","secondary_color":"#005fff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":[{"length":"","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","uploaded_at":"2025-10-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-11","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-09-30","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-09-21","url":"https://www.youtube.com/watch?v=ozH8xE7V458"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg","title":"Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti","uploaded_at":"2025-09-21","url":"https://www.youtube.com/watch?v=nrj6_1IoYoo"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","youtube_url":"https://www.youtube.com/@FCBizoniUH"}
{"about_html":"","accent_color":"#ffae00","background_color":"#ffffff","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_logo_url":"http://logoapi.sportcreative.eu/logos/7eacd9f0-bfa0-4928-a9b6-936140168f58?format=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":"Kostelany nad Moravou 108","contact_city":"Kostelany nad Moravou","contact_country":"Česko","contact_email":"info@tdvorak.dev","contact_phone":"+420778701838","contact_zip":"686 01","custom_nav":null,"facebook_url":"https://www.facebook.com/p/FK-Kofola-Krnov-61561103731912/","font_body":"Archivo","font_heading":"Archivo","gallery_label":"Fotogalerie","gallery_url":"https://eu.zonerama.com/FKKofolaKrnov/1470757","instagram_url":"https://www.instagram.com/fkkofolakrnov/","location_latitude":49.044086,"location_longitude":17.4040833,"map_style":"voyager","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","primary_color":"#ffd500","secondary_color":"#0040ff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":[{"length":"","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","uploaded_at":"2025-10-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-11","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-02","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-09-23","url":"https://www.youtube.com/watch?v=ozH8xE7V458"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg","title":"Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti","uploaded_at":"2025-09-23","url":"https://www.youtube.com/watch?v=nrj6_1IoYoo"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","youtube_url":"https://www.youtube.com/@FCBizoniUH/videos"}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"fetched_at":"2025-10-21T12:50:53Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
{"fetched_at":"2025-10-23T14:00:17Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH%2Fvideos"}
+20
View File
@@ -0,0 +1,20 @@
[
{
"id": "571035614",
"album_id": "",
"album_url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14014307/571035614",
"image_url": "https://eu.zonerama.com/photos/571035614_1500x1000.jpg",
"title": "",
"picked_at": "2025-10-23T12:28:54Z"
},
{
"id": "572667656",
"album_id": "14045127",
"album_url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14045127/572667656",
"image_url": "https://eu.zonerama.com/photos/572667656_1500x1000.jpg",
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
"picked_at": "2025-10-22T18:13:39Z"
}
]
+10 -10
View File
@@ -7,7 +7,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -17,7 +17,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -27,7 +27,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -37,7 +37,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -47,7 +47,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -57,7 +57,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -67,7 +67,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -77,7 +77,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -87,7 +87,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
},
{
"id": "",
@@ -97,6 +97,6 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-10-21T12:51:05Z"
"fetched_at": "2025-10-23T14:00:46Z"
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
{
"fetched_at": "2025-10-21T12:51:05Z",
"fetched_at": "2025-10-23T14:00:46Z",
"link": ""
}
+225 -225
View File
@@ -103,7 +103,7 @@
"photos_count": 75,
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
"views_count": 24
"views_count": 50
},
{
"date": "12. 10. 2025",
@@ -208,112 +208,7 @@
"photos_count": 112,
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
"views_count": 101
},
{
"date": "11. 10. 2025",
"id": "14006762",
"photos": [
{
"id": "570605307",
"image_1500": "https://eu.zonerama.com/photos/570605307_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605307"
},
{
"id": "570605293",
"image_1500": "https://eu.zonerama.com/photos/570605293_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605293"
},
{
"id": "570605300",
"image_1500": "https://eu.zonerama.com/photos/570605300_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605300"
},
{
"id": "570605292",
"image_1500": "https://eu.zonerama.com/photos/570605292_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605292"
},
{
"id": "570605286",
"image_1500": "https://eu.zonerama.com/photos/570605286_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605286"
},
{
"id": "570605281",
"image_1500": "https://eu.zonerama.com/photos/570605281_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605281"
},
{
"id": "570605258",
"image_1500": "https://eu.zonerama.com/photos/570605258_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605258"
},
{
"id": "570605262",
"image_1500": "https://eu.zonerama.com/photos/570605262_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605262"
},
{
"id": "570605132",
"image_1500": "https://eu.zonerama.com/photos/570605132_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605132"
},
{
"id": "570605127",
"image_1500": "https://eu.zonerama.com/photos/570605127_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605127"
},
{
"id": "570605128",
"image_1500": "https://eu.zonerama.com/photos/570605128_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605128"
},
{
"id": "570605112",
"image_1500": "https://eu.zonerama.com/photos/570605112_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605112"
},
{
"id": "570605117",
"image_1500": "https://eu.zonerama.com/photos/570605117_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605117"
},
{
"id": "570605107",
"image_1500": "https://eu.zonerama.com/photos/570605107_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605107"
},
{
"id": "570605106",
"image_1500": "https://eu.zonerama.com/photos/570605106_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605106"
},
{
"id": "570605089",
"image_1500": "https://eu.zonerama.com/photos/570605089_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605089"
},
{
"id": "570605088",
"image_1500": "https://eu.zonerama.com/photos/570605088_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605088"
},
{
"id": "570605094",
"image_1500": "https://eu.zonerama.com/photos/570605094_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605094"
},
{
"id": "570605082",
"image_1500": "https://eu.zonerama.com/photos/570605082_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605082"
}
],
"photos_count": 40,
"title": "Kategorie U15 Havířov 3:4 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14006762",
"views_count": 85
"views_count": 113
},
{
"date": "11. 10. 2025",
@@ -418,7 +313,112 @@
"photos_count": 19,
"title": "Kategorie U14 Havířov 6:3 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14006754",
"views_count": 91
"views_count": 100
},
{
"date": "11. 10. 2025",
"id": "14006762",
"photos": [
{
"id": "570605307",
"image_1500": "https://eu.zonerama.com/photos/570605307_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605307"
},
{
"id": "570605293",
"image_1500": "https://eu.zonerama.com/photos/570605293_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605293"
},
{
"id": "570605300",
"image_1500": "https://eu.zonerama.com/photos/570605300_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605300"
},
{
"id": "570605292",
"image_1500": "https://eu.zonerama.com/photos/570605292_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605292"
},
{
"id": "570605286",
"image_1500": "https://eu.zonerama.com/photos/570605286_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605286"
},
{
"id": "570605281",
"image_1500": "https://eu.zonerama.com/photos/570605281_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605281"
},
{
"id": "570605258",
"image_1500": "https://eu.zonerama.com/photos/570605258_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605258"
},
{
"id": "570605262",
"image_1500": "https://eu.zonerama.com/photos/570605262_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605262"
},
{
"id": "570605132",
"image_1500": "https://eu.zonerama.com/photos/570605132_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605132"
},
{
"id": "570605127",
"image_1500": "https://eu.zonerama.com/photos/570605127_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605127"
},
{
"id": "570605128",
"image_1500": "https://eu.zonerama.com/photos/570605128_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605128"
},
{
"id": "570605112",
"image_1500": "https://eu.zonerama.com/photos/570605112_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605112"
},
{
"id": "570605117",
"image_1500": "https://eu.zonerama.com/photos/570605117_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605117"
},
{
"id": "570605107",
"image_1500": "https://eu.zonerama.com/photos/570605107_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605107"
},
{
"id": "570605106",
"image_1500": "https://eu.zonerama.com/photos/570605106_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605106"
},
{
"id": "570605089",
"image_1500": "https://eu.zonerama.com/photos/570605089_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605089"
},
{
"id": "570605088",
"image_1500": "https://eu.zonerama.com/photos/570605088_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605088"
},
{
"id": "570605094",
"image_1500": "https://eu.zonerama.com/photos/570605094_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605094"
},
{
"id": "570605082",
"image_1500": "https://eu.zonerama.com/photos/570605082_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006762/570605082"
}
],
"photos_count": 40,
"title": "Kategorie U15 Havířov 3:4 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14006762",
"views_count": 96
},
{
"date": "4. 10. 2025",
@@ -523,7 +523,7 @@
"photos_count": 79,
"title": "Kategorie U15 FK Krnov 0:1 Hlučín",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967265",
"views_count": 119
"views_count": 126
},
{
"date": "4. 10. 2025",
@@ -618,7 +618,7 @@
"photos_count": 50,
"title": "Kategorie U14 FK Krnov 0:6 Hlučín",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967247",
"views_count": 141
"views_count": 149
},
{
"date": "28. 9. 2025",
@@ -728,7 +728,117 @@
"photos_count": 65,
"title": "Kategorie muži FK Krnov 2:3 TJ Sokol Háj ve Slezsku",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13939668",
"views_count": 169
"views_count": 177
},
{
"date": "20. 9. 2025",
"id": "13903610",
"photos": [
{
"id": "565775564",
"image_1500": "https://eu.zonerama.com/photos/565775564_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775564"
},
{
"id": "565775560",
"image_1500": "https://eu.zonerama.com/photos/565775560_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775560"
},
{
"id": "565775563",
"image_1500": "https://eu.zonerama.com/photos/565775563_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775563"
},
{
"id": "565775568",
"image_1500": "https://eu.zonerama.com/photos/565775568_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775568"
},
{
"id": "565775558",
"image_1500": "https://eu.zonerama.com/photos/565775558_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775558"
},
{
"id": "565775553",
"image_1500": "https://eu.zonerama.com/photos/565775553_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775553"
},
{
"id": "565775552",
"image_1500": "https://eu.zonerama.com/photos/565775552_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775552"
},
{
"id": "565775554",
"image_1500": "https://eu.zonerama.com/photos/565775554_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775554"
},
{
"id": "565775540",
"image_1500": "https://eu.zonerama.com/photos/565775540_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775540"
},
{
"id": "565775549",
"image_1500": "https://eu.zonerama.com/photos/565775549_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775549"
},
{
"id": "565775545",
"image_1500": "https://eu.zonerama.com/photos/565775545_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775545"
},
{
"id": "565775535",
"image_1500": "https://eu.zonerama.com/photos/565775535_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775535"
},
{
"id": "565775539",
"image_1500": "https://eu.zonerama.com/photos/565775539_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775539"
},
{
"id": "565775529",
"image_1500": "https://eu.zonerama.com/photos/565775529_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775529"
},
{
"id": "565775557",
"image_1500": "https://eu.zonerama.com/photos/565775557_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775557"
},
{
"id": "565775527",
"image_1500": "https://eu.zonerama.com/photos/565775527_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775527"
},
{
"id": "565775531",
"image_1500": "https://eu.zonerama.com/photos/565775531_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775531"
},
{
"id": "565775530",
"image_1500": "https://eu.zonerama.com/photos/565775530_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775530"
},
{
"id": "565775517",
"image_1500": "https://eu.zonerama.com/photos/565775517_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775517"
},
{
"id": "565775525",
"image_1500": "https://eu.zonerama.com/photos/565775525_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775525"
}
],
"photos_count": 101,
"title": "Kategorie U15 FK Krnov 2:5 Nový Jičín",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903610",
"views_count": 155
},
{
"date": "20. 9. 2025",
@@ -843,117 +953,7 @@
"photos_count": 55,
"title": "Kategorie U14 FK Krnov 1:12 Nový Jičín",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903599",
"views_count": 126
},
{
"date": "20. 9. 2025",
"id": "13903610",
"photos": [
{
"id": "565775564",
"image_1500": "https://eu.zonerama.com/photos/565775564_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775564"
},
{
"id": "565775560",
"image_1500": "https://eu.zonerama.com/photos/565775560_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775560"
},
{
"id": "565775563",
"image_1500": "https://eu.zonerama.com/photos/565775563_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775563"
},
{
"id": "565775568",
"image_1500": "https://eu.zonerama.com/photos/565775568_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775568"
},
{
"id": "565775558",
"image_1500": "https://eu.zonerama.com/photos/565775558_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775558"
},
{
"id": "565775553",
"image_1500": "https://eu.zonerama.com/photos/565775553_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775553"
},
{
"id": "565775552",
"image_1500": "https://eu.zonerama.com/photos/565775552_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775552"
},
{
"id": "565775554",
"image_1500": "https://eu.zonerama.com/photos/565775554_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775554"
},
{
"id": "565775540",
"image_1500": "https://eu.zonerama.com/photos/565775540_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775540"
},
{
"id": "565775549",
"image_1500": "https://eu.zonerama.com/photos/565775549_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775549"
},
{
"id": "565775545",
"image_1500": "https://eu.zonerama.com/photos/565775545_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775545"
},
{
"id": "565775535",
"image_1500": "https://eu.zonerama.com/photos/565775535_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775535"
},
{
"id": "565775539",
"image_1500": "https://eu.zonerama.com/photos/565775539_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775539"
},
{
"id": "565775529",
"image_1500": "https://eu.zonerama.com/photos/565775529_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775529"
},
{
"id": "565775557",
"image_1500": "https://eu.zonerama.com/photos/565775557_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775557"
},
{
"id": "565775527",
"image_1500": "https://eu.zonerama.com/photos/565775527_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775527"
},
{
"id": "565775531",
"image_1500": "https://eu.zonerama.com/photos/565775531_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775531"
},
{
"id": "565775530",
"image_1500": "https://eu.zonerama.com/photos/565775530_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775530"
},
{
"id": "565775517",
"image_1500": "https://eu.zonerama.com/photos/565775517_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775517"
},
{
"id": "565775525",
"image_1500": "https://eu.zonerama.com/photos/565775525_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/13903610/565775525"
}
],
"photos_count": 101,
"title": "Kategorie U15 FK Krnov 2:5 Nový Jičín",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903610",
"views_count": 149
"views_count": 133
},
{
"date": "17. 9. 2025",
@@ -1063,9 +1063,9 @@
"photos_count": 55,
"title": "Kategorie U15 Třinec 1:4 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13883373",
"views_count": 116
"views_count": 120
}
],
"fetched_at": "2025-10-21T12:51:05Z",
"input_link": "https://eu.zonerama.com/FKKofolaKrnov"
"fetched_at": "2025-10-23T14:00:46Z",
"input_link": "https://eu.zonerama.com/FKKofolaKrnov/1470757"
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"fotbal-club/internal/config"
dbpkg "fotbal-club/pkg/database"
)
func main() {
if len(os.Args) < 2 {
log.Fatalf("usage: go run ./cmd/sqlmigrate <path-to-sql>\n")
}
path := os.Args[1]
config.LoadConfig()
db, err := dbpkg.InitDB()
if err != nil {
log.Fatalf("failed to connect DB: %v", err)
}
b, err := ioutil.ReadFile(path)
if err != nil {
log.Fatalf("failed to read sql file: %v", err)
}
sql := string(b)
if err := db.Exec(sql).Error; err != nil {
log.Fatalf("failed to execute migration: %v", err)
}
fmt.Println("Migration executed successfully:", path)
}
@@ -0,0 +1,3 @@
-- Remove voter info fields from poll_votes (rollback)
ALTER TABLE poll_votes DROP COLUMN IF EXISTS voter_name;
ALTER TABLE poll_votes DROP COLUMN IF EXISTS voter_email;
@@ -0,0 +1,6 @@
-- Add optional voter info fields to poll_votes
ALTER TABLE poll_votes ADD COLUMN IF NOT EXISTS voter_name VARCHAR(150);
ALTER TABLE poll_votes ADD COLUMN IF NOT EXISTS voter_email VARCHAR(200);
-- Optional: index on voter_email for admin lookups
-- CREATE INDEX IF NOT EXISTS idx_poll_votes_voter_email ON poll_votes(voter_email);
@@ -0,0 +1,7 @@
-- Set default role to 'fan' and backfill existing records
ALTER TABLE IF EXISTS users ALTER COLUMN role SET DEFAULT 'fan';
-- Backfill NULL/empty/user -> fan (do not touch admin/editor)
UPDATE users
SET role = 'fan'
WHERE role IS NULL OR btrim(role) = '' OR lower(role) = 'user';
+51 -4
View File
@@ -5,6 +5,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-
import './styles/custom-scrollbar.css';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import AuthPage from './pages/AuthPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage';
import ArticlesListPage from './pages/ArticlesListPage';
import HomePage from './pages/HomePage';
@@ -50,6 +51,7 @@ import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
import FilesAdminPage from './pages/admin/FilesAdminPage';
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
import SetupPage from './pages/SetupPage';
@@ -261,7 +263,7 @@ const App: React.FC = () => {
// Public Route component - redirects to admin if already authenticated
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuth();
const { isAuthenticated, isLoading, user } = useAuth();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
@@ -285,6 +287,10 @@ const App: React.FC = () => {
}
if (isAuthenticated) {
const role = user?.role;
if (role === 'fan') {
return <Navigate to="/semiadmin" replace />;
}
return <Navigate to="/admin" replace />;
}
@@ -374,10 +380,26 @@ const App: React.FC = () => {
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
<Route
path="/semiadmin"
element={
<ProtectedRoute>
<SemiAdminPage />
</ProtectedRoute>
}
/>
<Route path="/403" element={<ForbiddenPage />} />
{/* Admin area (pages include AdminLayout themselves) */}
@@ -388,15 +410,14 @@ const App: React.FC = () => {
}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/media" element={<MediaAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
@@ -455,6 +476,32 @@ const App: React.FC = () => {
}
/>
{/* Editor-accessible content pages (also allow admin) */}
<Route
path="/admin/clanky"
element={
<ProtectedRoute requiredRole="editor">
<ArticlesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/aktivity"
element={
<ProtectedRoute requiredRole="editor">
<AdminActivitiesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/media"
element={
<ProtectedRoute requiredRole="editor">
<MediaAdminPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
+26 -4
View File
@@ -33,6 +33,7 @@ import {
InputGroup,
InputLeftElement,
Input,
useToast,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
@@ -49,6 +50,7 @@ import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -236,7 +238,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
</Drawer>
);
const Navbar = () => {
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -246,12 +248,14 @@ const Navbar = () => {
const theme = useClubTheme();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const menuBg = useColorModeValue('white', '#0f1115');
const dividerColor = useColorModeValue('gray.600', 'gray.300');
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
@@ -261,6 +265,7 @@ const Navbar = () => {
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
// Search modal state
const [query, setQuery] = useState('');
@@ -279,6 +284,21 @@ const Navbar = () => {
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
// Open newsletter preferences for logged-in user (fetch token and redirect)
const openMyNewsletterPrefs = async () => {
try {
const { token } = await getMyNewsletterToken();
navigate(`/newsletter/preferences?token=${encodeURIComponent(token)}`);
} catch (err: any) {
toast({
title: 'Chyba',
description: 'Nelze načíst odkaz na emailové preference. Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
});
}
};
// Also set document title to club name ASAP (SEO component will refine further)
useEffect(() => {
const name = settings?.club_name || theme.name;
@@ -607,8 +627,8 @@ const Navbar = () => {
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={useColorModeValue('gray.50', 'blackAlpha.500')} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW="7xl">
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW={containerMaxW}>
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
{settings?.shop_url && (
@@ -643,7 +663,7 @@ const Navbar = () => {
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW="7xl">
<Container maxW={containerMaxW}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
{/* Club Logo only */}
@@ -768,6 +788,8 @@ const Navbar = () => {
</MenuButton>
<MenuList>
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
</MenuList>
@@ -443,6 +443,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
>
<option value="single">Jedna odpověď</option>
<option value="multiple">Více odpovědí</option>
<option value="rating">Hodnocení</option>
</Select>
</FormControl>
@@ -36,6 +36,7 @@ import {
} from 'lucide-react';
import { Image as ChakraImage } from '@chakra-ui/react';
import { cropAndUpload, quickEditImage } from '../../services/imageProcessing';
import { assetUrl } from '../../utils/url';
interface ImageFilters {
brightness: number;
@@ -245,28 +246,36 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return;
}
// Calculate crop data in pixels
// Calculate crop data in natural image pixels (backend expects absolute pixels)
const img = imgRef.current;
const percToPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
const displayW = img.width;
const displayH = img.height;
const naturalW = img.naturalWidth || displayW;
const naturalH = img.naturalHeight || displayH;
const scaleX = naturalW / Math.max(1, displayW);
const scaleY = naturalH / Math.max(1, displayH);
const toDisplayPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
let cropData = undefined;
if (crop.width && crop.height && crop.width > 0 && crop.height > 0) {
const cropPx = {
x: Math.round(Math.max(0, percToPx(crop.x || 0, img.width))),
y: Math.round(Math.max(0, percToPx(crop.y || 0, img.height))),
width: Math.round(Math.min(img.width, percToPx(crop.width || img.width, img.width))),
height: Math.round(Math.min(img.height, percToPx(crop.height || img.height, img.height))),
};
// Convert selection from displayed coordinates to natural pixel coordinates
const dispX = Math.max(0, toDisplayPx(crop.x || 0, displayW));
const dispY = Math.max(0, toDisplayPx(crop.y || 0, displayH));
const dispW = Math.min(displayW, toDisplayPx(crop.width || displayW, displayW));
const dispH = Math.min(displayH, toDisplayPx(crop.height || displayH, displayH));
// Adjust crop to fit image bounds
if (cropPx.x + cropPx.width > img.width) {
cropPx.width = img.width - cropPx.x;
}
if (cropPx.y + cropPx.height > img.height) {
cropPx.height = img.height - cropPx.y;
}
let natX = Math.round(dispX * scaleX);
let natY = Math.round(dispY * scaleY);
let natW = Math.round(dispW * scaleX);
let natH = Math.round(dispH * scaleY);
cropData = cropPx;
// Clamp within natural bounds
if (natX + natW > naturalW) natW = naturalW - natX;
if (natY + natH > naturalH) natH = naturalH - natY;
natW = Math.max(1, natW);
natH = Math.max(1, natH);
cropData = { x: natX, y: natY, width: natW, height: natH };
}
toast({ title: 'Zpracování obrázku...', status: 'info', duration: 2000 });
@@ -290,16 +299,25 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const range = quill.getSelection();
const index = range ? range.index : quill.getLength();
// Insert the image
quill.insertEmbed(index, 'image', res.url, 'api');
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Force content change to trigger re-render
onChangeRef.current(quill.root.innerHTML);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
const absoluteUrl = assetUrl(res.url) || res.url;
const img = new Image();
img.onload = () => {
try {
quill.insertEmbed(index, 'image', absoluteUrl, 'api');
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Force content change to trigger re-render
onChangeRef.current(quill.root.innerHTML);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
} catch (e) {
console.error('Insert after preload error:', e);
toast({ title: 'Chyba při vkládání obrázku', description: String(e), status: 'error' });
}
};
img.onerror = () => {
toast({ title: 'Obrázek nelze načíst', description: absoluteUrl, status: 'error' });
};
img.src = absoluteUrl;
} catch (embedError) {
console.error('Error inserting image:', embedError);
toast({ title: 'Chyba při vkládání obrázku', description: String(embedError), status: 'error' });
@@ -716,6 +734,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Delete selected image on Delete key
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
// Do not act on Delete/Backspace if user is typing in an input, textarea, or contentEditable
if (tag === 'INPUT' || tag === 'TEXTAREA' || (target && (target as any).isContentEditable)) {
return;
}
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
selectedImage.remove();
@@ -754,6 +778,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
editor.root.addEventListener('scroll', handleScroll);
editor.root.addEventListener('dragstart', handleDragStart);
document.addEventListener('keydown', handleKeyDown);
// Also reposition on window resize and any document scroll (capture phase)
window.addEventListener('resize', handleScroll);
document.addEventListener('scroll', handleScroll, true);
return () => {
editor.root.removeEventListener('click', handleImageClick);
@@ -761,10 +788,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
editor.root.removeEventListener('scroll', handleScroll);
editor.root.removeEventListener('dragstart', handleDragStart);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleScroll);
document.removeEventListener('scroll', handleScroll, true);
removeResizeHandle();
deselectImage();
};
}, [readOnly, toast]);
}, [readOnly, toast, isMounted]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
@@ -850,7 +879,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
});
// Replace image src
selectedImageElement.src = res.url;
const absoluteUrl = assetUrl(res.url) || res.url;
selectedImageElement.src = absoluteUrl;
// Reset filters to default since they're now baked into the image
setImageFilters({
@@ -873,6 +903,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
@@ -904,6 +936,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
}
@@ -924,6 +958,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
} else {
@@ -1218,9 +1254,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
maxH="80vh"
overflowY="auto"
pointerEvents="auto"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseUp={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); }}
onMouseDown={(e) => { e.stopPropagation(); }}
onMouseUp={(e) => { e.stopPropagation(); }}
css={{
'&::-webkit-scrollbar': {
width: '6px',
@@ -1302,7 +1338,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
type="number"
value={manualWidth}
onChange={(e) => setManualWidth(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && applyManualWidth()}
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); applyManualWidth(); } }}
placeholder="Šířka v px"
min={50}
/>
+35 -307
View File
@@ -1,28 +1,15 @@
import React, { useState } from 'react';
import React from 'react';
import {
Box,
Button,
HStack,
Icon,
Link as ChakraLink,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
ModalFooter,
Text,
useDisclosure,
Image,
VStack,
Badge,
useColorModeValue,
AspectRatio,
} from '@chakra-ui/react';
import {
FiDownload,
FiEye,
FiExternalLink,
FiFile,
FiFileText,
FiImage,
@@ -44,10 +31,7 @@ const FilePreview: React.FC<FilePreviewProps> = ({
name,
mimeType = '',
size,
showInline = false,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [imageError, setImageError] = useState(false);
const fullUrl = assetUrl(url) || url;
const fileName = name || url.split('/').pop() || 'file';
@@ -56,7 +40,6 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const borderColor = useColorModeValue('gray.200', 'gray.700');
const cardBg = useColorModeValue('white', 'gray.800');
const mutedText = useColorModeValue('gray.600', 'gray.300');
const linkColor = useColorModeValue('blue.600', 'blue.300');
// Determine file type and icon
const getFileInfo = () => {
@@ -89,297 +72,42 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
// Render preview content based on file type
const renderPreviewContent = () => {
if (fileInfo.type === 'image') {
if (imageError) {
return (
<VStack spacing={4} py={10}>
<Icon as={FiImage} boxSize={12} color="gray.400" />
<Text color={mutedText}>Obrázek se nepodařilo načíst</Text>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
>
Stáhnout soubor
</Button>
</VStack>
);
}
return (
<Image
src={fullUrl}
alt={fileName}
maxW="100%"
maxH="70vh"
objectFit="contain"
onError={() => setImageError(true)}
/>
);
}
if (fileInfo.type === 'pdf') {
// Try multiple PDF viewing methods due to CSP restrictions
return (
<VStack spacing={4} w="100%" minH="70vh">
{/* Primary: Try direct iframe embed */}
<Box w="100%" h="70vh" borderWidth="1px" borderRadius="md" overflow="hidden">
<iframe
src={`${fullUrl}#view=FitH&toolbar=1`}
title={fileName}
style={{ border: 'none', width: '100%', height: '100%' }}
onError={(e) => {
console.error('PDF iframe load error:', e);
}}
/>
</Box>
{/* Fallback options */}
<VStack spacing={2} w="100%">
<Text fontSize="sm" color={mutedText} textAlign="center">
Pokud se PDF nezobrazuje, použijte jedno z tlačítek níže:
</Text>
<HStack spacing={3} flexWrap="wrap" justify="center">
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiEye />}
colorScheme="blue"
size="sm"
>
Otevřít v novém okně
</Button>
<Button
as={ChakraLink}
href={`https://mozilla.github.io/pdf.js/web/viewer.html?file=${encodeURIComponent(fullUrl)}`}
isExternal
leftIcon={<FiEye />}
colorScheme="purple"
size="sm"
>
Zobrazit pomocí PDF.js
</Button>
<Button
as={ChakraLink}
href={`https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`}
isExternal
leftIcon={<FiEye />}
colorScheme="green"
size="sm"
>
Zobrazit přes Google
</Button>
<Button
as={ChakraLink}
href={fullUrl}
download
leftIcon={<FiDownload />}
colorScheme="gray"
size="sm"
>
Stáhnout PDF
</Button>
</HStack>
</VStack>
</VStack>
);
}
if (fileInfo.type === 'video') {
return (
<AspectRatio ratio={16 / 9} w="100%">
<video controls style={{ width: '100%', height: '100%' }}>
<source src={fullUrl} type={mime} />
Váš prohlížeč nepodporuje přehrávání videa.
</video>
</AspectRatio>
);
}
if (fileInfo.type === 'audio') {
return (
<VStack spacing={4} py={10}>
<Icon as={FiMusic} boxSize={12} color={fileInfo.color} />
<audio controls style={{ width: '100%', maxWidth: '500px' }}>
<source src={fullUrl} type={mime} />
Váš prohlížeč nepodporuje přehrávání zvuku.
</audio>
</VStack>
);
}
// For Office documents, show info and download option
return (
<VStack spacing={4} py={10}>
<Icon as={fileInfo.icon} boxSize={16} color={fileInfo.color} />
<VStack spacing={2}>
<Text fontSize="lg" fontWeight="medium">{fileName}</Text>
{sizeStr && <Badge colorScheme="gray">{sizeStr}</Badge>}
<Text color={mutedText} fontSize="sm" textAlign="center">
{fileInfo.type === 'presentation' && 'PowerPoint prezentace'}
{fileInfo.type === 'document' && 'Word dokument'}
{fileInfo.type === 'spreadsheet' && 'Excel tabulka'}
</Text>
</VStack>
<HStack spacing={3}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
>
Stáhnout
</Button>
<Button
as={ChakraLink}
href={`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(fullUrl)}`}
isExternal
leftIcon={<FiEye />}
variant="outline"
>
Zobrazit online
</Button>
</HStack>
<Text fontSize="xs" color={mutedText}>
Pro zobrazení .pptx, .docx, .xlsx můžete použít "Zobrazit online"
</Text>
</VStack>
);
};
// Inline preview for images
if (showInline && fileInfo.type === 'image') {
return (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={cardBg}
>
<Image
src={fullUrl}
alt={fileName}
w="100%"
maxH="400px"
objectFit="cover"
cursor="pointer"
onClick={onOpen}
_hover={{ opacity: 0.9 }}
onError={() => setImageError(true)}
/>
{!imageError && (
<HStack justify="space-between" p={3} borderTopWidth="1px">
<Text fontSize="sm" color={mutedText} isTruncated maxW="60%">
{fileName}
</Text>
<HStack spacing={2}>
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen}>
Náhled
</Button>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
variant="ghost"
leftIcon={<FiDownload />}
>
Stáhnout
</Button>
</HStack>
</HStack>
)}
</Box>
);
}
// Compact button view
// Simplified preview: only provide an "Open in new window" action
return (
<>
<HStack
justify="space-between"
p={3}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<ChakraLink
href={fullUrl}
isExternal
color={linkColor}
fontWeight="medium"
isTruncated
maxW="100%"
_hover={{ textDecoration: 'underline' }}
>
{fileName}
</ChakraLink>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
<HStack spacing={2} flexShrink={0}>
{fileInfo.canPreview && (
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen} variant="outline">
Náhled
</Button>
)}
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiDownload />}
colorScheme="blue"
<HStack
justify="space-between"
p={3}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<Text
fontWeight="medium"
isTruncated
maxW="100%"
>
Stáhnout
</Button>
</HStack>
{fileName}
</Text>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
{/* Preview Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<Text>{fileName}</Text>
{sizeStr && <Text fontSize="sm" fontWeight="normal" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6} overflow="auto">
{renderPreviewContent()}
</ModalBody>
<ModalFooter>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
mr={3}
>
Stáhnout
</Button>
<Button variant="ghost" onClick={onClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
<HStack spacing={2} flexShrink={0}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiExternalLink />}
colorScheme="blue"
>
Otevřít v novém okně
</Button>
</HStack>
</HStack>
);
};
@@ -83,7 +83,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
};
return links[element] || [
{ label: 'Admin Dashboard', url: '/admin', icon: FiSettings, description: 'Go to admin panel' },
{ label: 'Administrace', url: '/admin', icon: FiSettings, description: 'Přejít do administrace' },
];
};
@@ -95,7 +95,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
<HStack>
<Icon as={FiExternalLink} color="blue.500" />
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Quick Admin Links
Rychlé odkazy administrace
</Text>
</HStack>
@@ -152,7 +152,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
</Box>
<Text fontSize="xs" color="gray.500" textAlign="center">
💡 These links help you manage content for this section
💡 Tyto odkazy vám pomohou spravovat obsah této sekce
</Text>
</VStack>
</Box>
@@ -18,23 +18,32 @@ import {
AlertIcon,
useColorModeValue,
Divider,
Spinner,
} from '@chakra-ui/react';
import { FiCode, FiEye, FiSave, FiRefreshCw } from 'react-icons/fi';
import { FiCode, FiEye, FiSave, FiRefreshCw, FiZap } from 'react-icons/fi';
import { generateCSSAI, AIGenerateCSSReq } from '../../services/ai';
import { ELEMENT_TSX_CONTEXT } from './elementContext';
interface CustomCSSEditorProps {
elementName: string;
onCSSChange: (css: string) => void;
currentCSS?: string;
currentStyles?: Record<string, any>;
theme?: Record<string, string>;
}
const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
elementName,
onCSSChange,
currentCSS = '',
currentStyles = {},
theme = {},
}) => {
const [css, setCSS] = useState(currentCSS);
const [isValid, setIsValid] = useState(true);
const [preview, setPreview] = useState(false);
const [aiPrompt, setAIPrompt] = useState('Zvýrazni tento blok: moderní vzhled, zaoblené rohy, stín, lepší hover efekt; respektuj klubové barvy a responzivitu.');
const [aiLoading, setAILoading] = useState(false);
const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -58,6 +67,84 @@ const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
}
};
// Collect rich AI context about the current element and page layout
const collectAIContext = (elName: string) => {
try {
const rootSelector = `[data-element="${elName}"]`;
const el = document.querySelector(rootSelector) as HTMLElement | null;
const container = (document.querySelector('.myuibrix-viewport-wrapper') as HTMLElement) || (document.querySelector('.container') as HTMLElement) || null;
const cssVars = (() => {
const style = getComputedStyle(document.documentElement);
const keys = ['--primary','--primary-light','--secondary','--text','--bg','--bg-soft','--club-primary','--club-text-on-primary'];
const out: Record<string,string> = {};
keys.forEach(k => { const v = style.getPropertyValue(k); if (v) out[k] = v.trim(); });
return out;
})();
const elComputed = el ? getComputedStyle(el) : null;
const computed = elComputed ? {
display: elComputed.display,
gridTemplateColumns: elComputed.gridTemplateColumns,
gridTemplateRows: elComputed.gridTemplateRows,
gap: elComputed.gap,
justifyItems: (elComputed as any).justifyItems,
alignItems: (elComputed as any).alignItems,
color: elComputed.color,
backgroundColor: elComputed.backgroundColor,
padding: `${elComputed.paddingTop} ${elComputed.paddingRight} ${elComputed.paddingBottom} ${elComputed.paddingLeft}`,
margin: `${elComputed.marginTop} ${elComputed.marginRight} ${elComputed.marginBottom} ${elComputed.marginLeft}`,
fontFamily: elComputed.fontFamily,
fontSize: elComputed.fontSize,
} : {};
const rect = el ? el.getBoundingClientRect() : null;
const neighborInfo = (() => {
try {
const nodes = Array.from((container || document).querySelectorAll('[data-element]')) as HTMLElement[];
const names = nodes.map(n => n.getAttribute('data-element')) as (string|null)[];
const index = names.findIndex(n => n === elName);
const prev = index > 0 ? names[index-1] : null;
const next = index >= 0 && index < names.length - 1 ? names[index+1] : null;
return { index, total: names.length, previous: prev, next };
} catch { return {}; }
})();
const containerComputed = container ? getComputedStyle(container) : null;
const containerInfo = containerComputed ? {
display: containerComputed.display,
gridTemplateColumns: containerComputed.gridTemplateColumns,
gridAutoFlow: containerComputed.gridAutoFlow,
gap: containerComputed.gap,
} : {};
// HTML snapshot (truncate to keep payload small)
const rootHtml = el ? el.outerHTML.slice(0, 6000) : '';
const tsxCtx = ELEMENT_TSX_CONTEXT[elName] || {};
return {
page_path: typeof window !== 'undefined' ? window.location.pathname : '',
element: {
name: elName,
variant: el?.getAttribute('data-variant') || null,
classList: el ? Array.from(el.classList) : [],
attributes: el ? Array.from(el.attributes).map(a => ({ name: a.name, value: a.value })) : [],
rect: rect ? { width: rect.width, height: rect.height } : undefined,
computed,
root_html_snapshot: rootHtml,
},
container: containerInfo,
neighbors: neighborInfo,
css_variables: cssVars,
tsx_snippet: tsxCtx.tsx || undefined,
known_selectors: tsxCtx.selectors || undefined,
design_notes: tsxCtx.notes || undefined,
};
} catch {
return {};
}
};
const handleCSSChange = (value: string) => {
setCSS(value);
const valid = validateCSS(value);
@@ -79,11 +166,29 @@ const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
// Create new style element
const style = document.createElement('style');
style.id = `custom-css-${elementName}`;
style.textContent = `
[data-element="${elementName}"] {
${cssString}
}
`;
// If the CSS contains braces or at-rules, assume full CSS block already scoped
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(cssString);
if (hasBlocks) {
style.textContent = cssString;
} else {
// Treat as declarations, scope under element
// Ensure each declaration is marked important to override theme CSS
const importantDecls = cssString
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
// Avoid double !important
return /!important\s*$/.test(s) ? s : `${s} !important`;
})
.join(';\n ');
style.textContent = `
[data-element="${elementName}"] {
${importantDecls};
}
`;
}
document.head.appendChild(style);
}
};
@@ -166,6 +271,12 @@ overflow: hidden;`,
<Text>Examples</Text>
</HStack>
</Tab>
<Tab>
<HStack spacing={2}>
<FiZap />
<Text>AI (beta)</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
@@ -288,6 +399,88 @@ border-radius: 10px;`}
))}
</VStack>
</TabPanel>
{/* AI Tab */}
<TabPanel>
<VStack align="stretch" spacing={3} p={4}>
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Vygenerovat CSS pomocí AI
</Text>
<Textarea
value={aiPrompt}
onChange={(e) => setAIPrompt(e.target.value)}
placeholder="Popište, jak má daný blok vypadat (česky). Např.: Tmavé pozadí, světlý text, zaoblené rohy, 2-sloupcový layout na desktopu, jeden sloupec na mobilu."
fontSize="sm"
minHeight="120px"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor={borderColor}
/>
<HStack spacing={2} flexWrap="wrap">
{[
'Tmavé pozadí a světlý text',
'Skleněný efekt (glassmorphism)',
'Zaoblené rohy a měkký stín',
'Dvou-sloupcový grid, mobil 1 sloupec',
'Akcent klubových barev',
].map((t, idx) => (
<Button key={idx} size="xs" variant="outline" onClick={() => setAIPrompt(p => `${p} ${t}.`)}>
{t}
</Button>
))}
</HStack>
<HStack>
<Button
leftIcon={aiLoading ? <Spinner size="xs" /> : <FiZap />}
colorScheme="purple"
size="sm"
isLoading={aiLoading}
onClick={async () => {
try {
setAILoading(true);
const payload: AIGenerateCSSReq = {
prompt: aiPrompt,
element_name: elementName,
root_selector: `[data-element="${elementName}"]`,
current_css: css,
current_styles: currentStyles || {},
theme: (theme as any) || {},
breakpoints: [640, 960, 1200],
context: collectAIContext(elementName),
};
const res = await generateCSSAI(payload);
const next = (res?.css || '').trim();
if (!next) {
toast({ title: 'AI nevrátila CSS', status: 'warning', duration: 2500 });
return;
}
setCSS(next);
setIsValid(true);
setPreview(true);
applyCSS(next);
// Persist into parent editor state so it survives panel close
onCSSChange(next);
toast({ title: 'CSS vygenerováno', status: 'success', duration: 1500 });
} catch (e: any) {
toast({ title: 'Chyba při generování CSS', description: e?.message || 'Zkuste to znovu', status: 'error', duration: 3000 });
} finally {
setAILoading(false);
}
}}
>
Vygenerovat CSS
</Button>
<Button size="sm" variant="ghost" onClick={() => setAIPrompt('')}>Vymazat zadání</Button>
</HStack>
<Alert status="info" borderRadius="md" fontSize="xs">
<AlertIcon />
AI výstup je automaticky scope-nutý pod `[data-element="název"]`. Po vygenerování se CSS předvyplní do editoru a lze ho dál upravovat.
</Alert>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
+607 -114
View File
@@ -150,11 +150,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [viewport] = useState<'desktop'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(true);
const [stylePanelRight, setStylePanelRight] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [showHelpHint, setShowHelpHint] = useState(true);
const [baseline, setBaseline] = useState<{ variants: Record<string, string>; visible: Set<string>; order: string[]; css: Record<string, string> }>({ variants: {}, visible: new Set<string>(), order: [], css: {} });
const overlayRef = useRef<HTMLDivElement>(null);
const isReorderingRef = useRef(false);
const [allowCrossContainer, setAllowCrossContainer] = useState(false);
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
const [containerGridCols, setContainerGridCols] = useState<number>(0);
const elementOrderRef = useRef<string[]>([]);
useEffect(() => { elementOrderRef.current = elementOrder; }, [elementOrder]);
// Draggable panel states
const [panelPositions, setPanelPositions] = useState({
@@ -322,6 +329,83 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Toggle body class for edit mode so other parts can detect reliably
useEffect(() => {
try {
if (isEditing) {
document.body.classList.add('myuibrix-edit-mode');
} else {
document.body.classList.remove('myuibrix-edit-mode');
}
} catch {}
return () => {
try {
document.body.classList.remove('myuibrix-edit-mode');
} catch {}
};
}, [isEditing]);
// Editor-only CSS: hide MyClub watermark and stabilize footer appearance in edit mode
useEffect(() => {
let styleEl: HTMLStyleElement | null = null;
try {
if (isEditing) {
styleEl = document.createElement('style');
styleEl.id = 'myuibrix-footer-editor-fixes';
styleEl.textContent = `
body.myuibrix-edit-mode [data-watermark="myclub"] { display: none !important; }
body.myuibrix-edit-mode [data-element="footer"] { position: relative; z-index: 0; }
`;
document.head.appendChild(styleEl);
}
} catch {}
return () => {
try { const n = document.getElementById('myuibrix-footer-editor-fixes'); if (n) n.remove(); } catch {}
};
}, [isEditing]);
// Toggle cross-container reorder mode class for global checks
useEffect(() => {
try {
if (allowCrossContainer) {
document.body.classList.add('myuibrix-cross-container-reorder');
} else {
document.body.classList.remove('myuibrix-cross-container-reorder');
}
} catch {}
return () => {
try { document.body.classList.remove('myuibrix-cross-container-reorder'); } catch {}
};
}, [allowCrossContainer]);
// Auto-open Layers panel on the left by default when entering edit mode
useEffect(() => {
if (isEditing) {
setShowLayersPanel(true);
}
}, [isEditing]);
// Detect grid columns on main container for grid insertion UI
useEffect(() => {
try {
const el = safeDOM.querySelector('.container') as HTMLElement | null;
if (!el) { setContainerGridCols(0); return; }
const cs = window.getComputedStyle(el);
if (cs.display !== 'grid') { setContainerGridCols(0); return; }
const gtc = cs.gridTemplateColumns || '';
let cols = 0;
const m = gtc.match(/repeat\((\d+)/);
if (m) cols = parseInt(m[1], 10);
if (!cols) {
// Fallback: naive split
cols = gtc.split(' ').filter(Boolean).length || 2;
}
setContainerGridCols(cols);
} catch {
setContainerGridCols(0);
}
}, [isEditing, elementStyles]);
// Auto-dismiss help hint after 5 seconds
useEffect(() => {
if (isEditing && showHelpHint) {
@@ -342,6 +426,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant)
}));
// Load saved custom CSS from settings
const cssByElement: Record<string, string> = {};
sanitizedConfigs.forEach(cfg => {
const css = (cfg.settings && (cfg.settings as any).customCSS) || '';
if (css) cssByElement[cfg.element_name] = String(css);
});
setConfigs(sanitizedConfigs);
const changes: Record<string, string> = {};
const visible = new Set<string>();
@@ -358,10 +448,45 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setLocalChanges(changes);
setVisibleElements(visible);
setElementOrder(order);
// Prime style state with saved custom CSS
if (Object.keys(cssByElement).length > 0) {
setElementStyles(prev => {
const next = { ...prev } as Record<string, any>;
Object.entries(cssByElement).forEach(([name, css]) => {
next[name] = { ...(next[name] || {}), customCSS: css };
});
return next;
});
// Inject saved CSS for preview (admin only)
Object.entries(cssByElement).forEach(([name, css]) => {
try {
const styleId = `custom-css-${name}`;
const existing = document.getElementById(styleId);
if (existing) existing.remove();
const style = document.createElement('style');
style.id = styleId;
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(css);
if (hasBlocks) {
style.textContent = css;
} else {
const importantDecls = css
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => (/!important\s*$/.test(s) ? s : `${s} !important`))
.join(';\n ');
style.textContent = `\n [data-element="${name}"] {\n ${importantDecls};\n }\n `;
}
document.head.appendChild(style);
} catch {}
});
}
setBaseline({ variants: { ...changes }, visible: new Set<string>(visible), order: [...order], css: cssByElement });
// If using defaults and no data exists, mark as has changes so user can save
// If using defaults and no data exists, treat everything as unsaved
if (data.length === 0) {
setHasChanges(true);
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
}
} catch (error) {
console.error('Failed to load page element configs:', error);
@@ -385,12 +510,48 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setVisibleElements(visible);
setElementOrder(order);
setHasChanges(true);
// Treat fallback like unsaved defaults so counter encourages save
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
}
};
loadConfigs();
}, [pageType, normalizeVariant]);
// Compute unsaved changes count by diffing against baseline
const unsavedCount = useMemo(() => {
try {
const saved = baseline || { variants: {}, visible: new Set<string>(), order: [], css: {} } as any;
const names = new Set<string>([
...Object.keys(localChanges || {}),
...Object.keys(saved.variants || {}),
...elementOrder,
...saved.order,
]);
const changedElements = new Set<string>();
names.forEach((name) => {
const curVar = normalizeVariant(name, localChanges[name]);
const savVar = normalizeVariant(name, saved.variants[name]);
if ((curVar || '') !== (savVar || '')) changedElements.add(name);
const curVis = visibleElements.has(name);
const savVis = saved.visible.has(name);
if (curVis !== savVis) changedElements.add(name);
const curCSS = String((elementStyles[name]?.customCSS || '')).trim();
const savCSS = String((saved.css?.[name] || '')).trim();
if (curCSS !== savCSS) changedElements.add(name);
});
const orderEqual = (elementOrder.length === saved.order.length) && elementOrder.every((n, i) => n === saved.order[i]);
return changedElements.size + (orderEqual ? 0 : 1);
} catch {
return 0;
}
}, [localChanges, visibleElements, elementOrder, baseline, normalizeVariant]);
// Keep hasChanges in sync with computed counter
useEffect(() => {
setHasChanges(unsavedCount > 0);
}, [unsavedCount]);
// Keyboard shortcuts
useEffect(() => {
if (!isEditing) return;
@@ -433,6 +594,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, [isEditing, selectedElement, showElementPicker, showLayersPanel, hasChanges]);
// Add element highlighting and click handlers when editing
// Also re-run when order/visibility changes so overlays are added for newly shown elements
useEffect(() => {
if (!isEditing) {
// Clean up overlays when exiting edit mode
@@ -466,6 +628,8 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
transition: all 0.2s;
z-index: 9998;
cursor: move;
user-select: none;
-webkit-user-select: none;
`;
const badge = document.createElement('div');
@@ -564,10 +728,64 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
deleteBtn.onmouseover = () => deleteBtn.style.transform = 'scale(1.1)';
deleteBtn.onmouseout = () => deleteBtn.style.transform = 'scale(1)';
// Add before button
const addBeforeBtn = document.createElement('button');
addBeforeBtn.innerHTML = '';
addBeforeBtn.title = 'Přidat před';
addBeforeBtn.style.cssText = editBtn.style.cssText;
addBeforeBtn.onmouseover = () => addBeforeBtn.style.transform = 'scale(1.1)';
addBeforeBtn.onmouseout = () => addBeforeBtn.style.transform = 'scale(1)';
// Add after button
const addAfterBtn = document.createElement('button');
addAfterBtn.innerHTML = '';
addAfterBtn.title = 'Přidat za';
addAfterBtn.style.cssText = editBtn.style.cssText;
addAfterBtn.onmouseover = () => addAfterBtn.style.transform = 'scale(1.1)';
addAfterBtn.onmouseout = () => addAfterBtn.style.transform = 'scale(1)';
// Grid column quick-insert buttons (add into specific grid column)
let colWrap: HTMLDivElement | null = null;
if (containerGridCols > 1) {
colWrap = document.createElement('div');
colWrap.className = 'elementor-col-picker';
colWrap.style.cssText = `
display: flex;
gap: 4px;
`;
for (let c = 0; c < containerGridCols; c++) {
const colBtn = document.createElement('button');
colBtn.innerHTML = '';
colBtn.title = `Přidat do sloupce ${c + 1}`;
colBtn.style.cssText = editBtn.style.cssText;
colBtn.onmouseover = () => (colBtn.style.transform = 'scale(1.1)');
colBtn.onmouseout = () => (colBtn.style.transform = 'scale(1)');
colBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const cols = Math.max(1, containerGridCols || 1);
const L = elementOrderRef.current.length;
let countInCol = 0;
for (let i = 0; i < L; i++) {
if (i % cols === c) countInCol++;
}
const targetIndex = c + countInCol * cols;
setPendingInsertIndex(Math.min(targetIndex, elementOrderRef.current.length));
setShowElementPicker(true);
} catch {}
});
safeDOM.appendChild(colWrap!, colBtn);
}
}
// Use safeDOM to build overlay structure
safeDOM.appendChild(actionsBar, editBtn);
safeDOM.appendChild(actionsBar, moveUpBtn);
safeDOM.appendChild(actionsBar, moveDownBtn);
safeDOM.appendChild(actionsBar, addBeforeBtn);
safeDOM.appendChild(actionsBar, addAfterBtn);
if (containerGridCols > 1 && colWrap) {
safeDOM.appendChild(actionsBar, colWrap);
}
safeDOM.appendChild(actionsBar, deleteBtn);
safeDOM.appendChild(overlay, badge);
@@ -652,10 +870,35 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
});
// Add before/after handlers
addBeforeBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const idx = elementOrderRef.current.indexOf(elementName);
if (idx >= 0) {
setPendingInsertIndex(idx);
setShowElementPicker(true);
}
} catch {}
});
addAfterBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const idx = elementOrderRef.current.indexOf(elementName);
if (idx >= 0) {
setPendingInsertIndex(idx + 1);
setShowElementPicker(true);
}
} catch {}
});
// per-column insert handled by elementor-col-picker buttons above
// Make overlay draggable
overlay.draggable = true;
overlay.addEventListener('dragstart', (e) => {
e.stopPropagation();
try { (e as DragEvent).dataTransfer?.setData('text/plain', elementName); } catch {}
setDraggedElement(elementName);
overlay.style.opacity = '0.5';
});
@@ -669,6 +912,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
overlay.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
try { (e as DragEvent).dataTransfer!.dropEffect = 'move'; } catch {}
if (draggedElement && draggedElement !== elementName) {
overlay.style.border = `3px solid ${secondaryColor}`;
setDragOverElement(elementName);
@@ -688,13 +932,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
e.stopPropagation();
if (draggedElement && draggedElement !== elementName) {
// Reorder elements
const newOrder = [...elementOrder];
const draggedIndex = newOrder.indexOf(draggedElement);
const newOrder = [...elementOrderRef.current];
const draggedIndex = newOrder.indexOf(draggedElement as string);
const targetIndex = newOrder.indexOf(elementName);
if (draggedIndex !== -1 && targetIndex !== -1) {
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedElement);
newOrder.splice(targetIndex, 0, draggedElement as string);
setElementOrder(newOrder);
setHasChanges(true);
applyVisualReorder(newOrder);
@@ -706,13 +950,23 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
});
};
// Only add overlays for elements that are actually implemented on this page
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
}
});
// Add overlays for all present [data-element] nodes in DOM (dynamic)
try {
const nodes = Array.from(safeDOM.querySelectorAll('[data-element]')) as HTMLElement[];
const names = Array.from(new Set(nodes
.map(n => n.getAttribute('data-element'))
.filter((v): v is string => !!v && v !== 'container')
));
names.forEach(name => addOverlay(name));
} catch {
// fallback to implemented list if needed
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
}
});
}
// Close panel on escape
const handleEscape = (e: KeyboardEvent) => {
@@ -753,7 +1007,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
clearTimeout(debounceTimerRef.current);
}
};
}, [isEditing, selectedElement, pageType]);
}, [isEditing, selectedElement, pageType, elementOrder, visibleElements]);
// Update selected element overlay styling
useEffect(() => {
@@ -848,6 +1102,71 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
applyChange();
}, [localChanges, visibleElements, isEditing, toast, getAvailableVariants, normalizeVariant]);
// Apply visual reordering with optional cross-container moves
const applyVisualReorder = useCallback((order: string[]) => {
if (isReorderingRef.current) return;
isReorderingRef.current = true;
requestAnimationFrame(() => {
try {
const cross = allowCrossContainer || (typeof document !== 'undefined' && document.body?.classList?.contains('myuibrix-cross-container-reorder'));
if (cross) {
// Prefer the in-page content wrapper as the canonical parent
const primary = (safeDOM.querySelector('.container') as HTMLElement) ||
(safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement) ||
(safeDOM.querySelector('main') as HTMLElement) || null;
if (primary) {
// Move any element not under the primary into it
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el && el.parentElement !== primary) {
safeDOM.appendChild(primary, el);
}
});
// Append in requested order
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el) {
try { el.style.order = ''; } catch {}
safeDOM.appendChild(primary, el);
}
});
}
} else {
// Reorder only within each element's existing parent
const parentMap = new Map<HTMLElement, HTMLElement[]>();
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (!el || !el.parentElement) return;
const parent = el.parentElement as HTMLElement;
if (!parentMap.has(parent)) parentMap.set(parent, []);
parentMap.get(parent)!.push(el);
});
parentMap.forEach((els, parent) => {
els.forEach((el) => { try { el.style.order = ''; } catch {} });
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el && el.parentElement === parent) {
safeDOM.appendChild(parent, el);
}
});
});
}
// Notify listeners (HomePage hook) about the new order
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
setTimeout(() => { isReorderingRef.current = false; }, 50);
} catch (error) {
console.error('Error during DOM reordering:', error);
isReorderingRef.current = false;
}
});
}, [allowCrossContainer]);
// Debounce style changes to prevent lag
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -873,20 +1192,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, 100); // 100ms debounce
}, [isEditing]);
const handleAddElement = useCallback((elementName: string) => {
// Helper: compute insert index for a given grid column (append at end of that column)
const computeInsertIndexForColumn = useCallback((col: number) => {
const cols = Math.max(1, containerGridCols || 1);
const L = elementOrderRef.current.length;
let countInCol = 0;
for (let i = 0; i < L; i++) {
if (i % cols === col) countInCol++;
}
return col + countInCol * cols;
}, [containerGridCols]);
// Add element from picker and make it visible + ordered in preview
const handleAddElement = useCallback((elementName: string, insertAt?: number) => {
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
if (!element) return;
const newVisible = new Set(visibleElements);
newVisible.add(elementName);
setVisibleElements(newVisible);
const existingVariant = localChanges[elementName];
const defaultVariant = normalizeVariant(elementName, element.defaultVariant);
const defaultVariant = normalizeVariant(elementName, element?.defaultVariant || 'default');
const variantToUse = normalizeVariant(elementName, existingVariant || defaultVariant);
if (!localChanges[elementName]) {
setLocalChanges(prev => ({ ...prev, [elementName]: variantToUse }));
}
// Mark as visible in editor state
setVisibleElements(prev => {
const next = new Set(prev);
next.add(elementName);
return next;
});
// Ensure config entry exists and is visible
setConfigs(prev => {
const index = prev.findIndex(cfg => cfg.element_name === elementName);
if (index !== -1) {
@@ -894,27 +1227,104 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
updated[index] = { ...updated[index], variant: variantToUse, visible: true };
return updated;
}
return [...prev, {
page_type: pageType,
element_name: elementName,
variant: variantToUse,
visible: true,
display_order: prev.length,
}];
return [
...prev,
{
page_type: pageType,
element_name: elementName,
variant: variantToUse,
visible: true,
display_order: prev.length,
}
];
});
// Close picker UI
setHasChanges(true);
setShowElementPicker(false);
setSearchQuery('');
setSelectedCategory('all');
// Live preview ONLY during editing
// Add into ordering at desired position and apply reordering
setElementOrder(prev => {
const targetIndex = (typeof insertAt === 'number') ? insertAt : (pendingInsertIndex != null ? pendingInsertIndex : undefined);
if (prev.includes(elementName)) {
try {
toast({
title: 'Duplicitní element',
description: 'Tento element již na stránce existuje. Přesouvám existující na zvolené místo.',
status: 'warning',
duration: 2500,
isClosable: true,
});
} catch {}
const existingIdx = prev.indexOf(elementName);
if (typeof targetIndex === 'number' && targetIndex !== existingIdx) {
const reordered = [...prev];
reordered.splice(existingIdx, 1);
reordered.splice(Math.min(targetIndex, reordered.length), 0, elementName);
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(reordered);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', { detail: { order: reordered, previewMode: true } }));
});
}
return reordered;
}
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(prev);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', { detail: { order: prev, previewMode: true } }));
});
}
return prev;
}
const newOrder = [...prev];
if (typeof targetIndex === 'number') {
newOrder.splice(Math.min(targetIndex, newOrder.length), 0, elementName);
} else {
newOrder.push(elementName);
}
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
});
}
// Ensure element is visible in DOM for editing
setTimeout(() => {
try {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = '';
}
} catch {}
}, 0);
return newOrder;
});
// Notify preview consumers to render element
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: variantToUse, visible: true, previewMode: true }
}));
}
}, [visibleElements, localChanges, isEditing, normalizeVariant, pageType]);
setPendingInsertIndex(null);
// Auto-select and open style panel for the newly added element
try {
setSelectedElement(elementName);
setTimeout(() => {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`) as HTMLElement | null;
if (el) {
const rect = el.getBoundingClientRect();
setElementPosition({ top: rect.top, left: rect.left, width: rect.width, height: rect.height });
setShowStylePanel(true);
}
}, 0);
} catch {}
}, [localChanges, isEditing, normalizeVariant, pageType, applyVisualReorder, pendingInsertIndex]);
const handleRemoveElement = useCallback((elementName: string) => {
// Update state - React will handle DOM removal
@@ -940,50 +1350,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, 0);
}, [visibleElements, localChanges, isEditing]);
// Apply visual reordering using CSS order property instead of DOM manipulation
const applyVisualReorder = useCallback((order: string[]) => {
// Prevent concurrent reordering operations
if (isReorderingRef.current) {
return;
}
isReorderingRef.current = true;
// Use CSS order property to avoid DOM manipulation conflicts with React
requestAnimationFrame(() => {
try {
order.forEach((elementName, index) => {
const element = safeDOM.querySelector(`[data-element="${elementName}"]`) as HTMLElement;
if (element) {
// Use CSS order instead of moving DOM nodes
element.style.order = String(index);
}
});
// Ensure parent container uses flexbox
const viewport = safeDOM.querySelector('.myuibrix-viewport-wrapper');
const container = viewport || safeDOM.querySelector('.container') || safeDOM.querySelector('main');
if (container) {
(container as HTMLElement).style.display = 'flex';
(container as HTMLElement).style.flexDirection = 'column';
}
console.log('Visual reorder applied via CSS order');
// Dispatch reorder event for HomePage to update
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
setTimeout(() => {
isReorderingRef.current = false;
}, 50);
} catch (error) {
console.error('Error during visual reordering:', error);
isReorderingRef.current = false;
}
});
}, []);
const handleMoveUp = useCallback((elementName: string) => {
const currentIndex = elementOrder.indexOf(elementName);
@@ -1087,6 +1454,57 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
}, [draggedElement, elementOrder, isEditing, applyVisualReorder]);
// Start with a blank layout: hide all elements and clear order
const handleStartBlank = useCallback(() => {
try {
if (!confirm('Začít s prázdným rozložením? Všechny sekce (kromě hlavičky a patičky) budou skryté.')) {
return;
}
} catch {}
const previouslyVisible = Array.from(visibleElements);
const keep = new Set(['header', 'footer']);
const toHide = previouslyVisible.filter(n => !keep.has(n));
const newVisible = new Set<string>();
previouslyVisible.forEach(n => { if (keep.has(n)) newVisible.add(n); });
setVisibleElements(newVisible);
setElementOrder(prev => prev.filter(n => keep.has(n)));
setHasChanges(true);
if (isEditing) {
toHide.forEach((elementName) => {
try {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName], visible: false, previewMode: true }
}));
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = 'none';
}
} catch {}
});
requestAnimationFrame(() => {
const order = Array.from(newVisible).filter(n => keep.has(n));
applyVisualReorder(order);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
});
}
try {
toast({
title: 'Prázdné rozložení',
description: 'Všechny sekce byly skryty. Můžete začít přidávat prvky.',
status: 'info',
duration: 2500,
isClosable: true,
});
} catch {}
setShowElementPicker(true);
}, [visibleElements, isEditing, localChanges, applyVisualReorder, toast]);
const handleSave = async () => {
try {
const configsToSave: PageElementConfig[] = elementOrder.map((elementName, index) => ({
@@ -1095,6 +1513,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
variant: localChanges[elementName] || 'default',
visible: visibleElements.has(elementName),
display_order: index,
settings: {
...(configs.find(c => c.element_name === elementName)?.settings || {}),
customCSS: (elementStyles[elementName]?.customCSS || ''),
},
}));
await batchUpdatePageElementConfigs(configsToSave);
@@ -1107,6 +1529,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
isClosable: true,
});
// Update baseline to current state so counter resets immediately
setBaseline({
variants: { ...localChanges },
visible: new Set<string>(visibleElements),
order: [...elementOrder],
css: Object.fromEntries(Object.entries(elementStyles).map(([k, v]) => [k, String((v as any)?.customCSS || '')])),
});
setHasChanges(false);
// Reload the page to apply changes in production view
@@ -1438,7 +1867,27 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Right: Actions */}
<HStack spacing={2}>
{hasChanges && (
<Button
leftIcon={<FaPaintBrush />}
size="sm"
variant={showStylePanel ? 'solid' : 'outline'}
colorScheme={showStylePanel ? 'blue' : 'whiteAlpha'}
onClick={() => setShowStylePanel(!showStylePanel)}
borderRadius="xl"
>
Vizuální styly
</Button>
<Tooltip label={stylePanelRight ? 'Ukotvit vlevo' : 'Ukotvit vpravo'}>
<IconButton
aria-label="Dock panel"
icon={stylePanelRight ? <FaAngleLeft /> : <FaAngleRight />}
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={() => setStylePanelRight(!stylePanelRight)}
/>
</Tooltip>
{unsavedCount > 0 && (
<Badge
bg="yellow.400"
color="gray.900"
@@ -1457,7 +1906,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
animation: 'bounce 1s infinite'
}}
>
{Object.keys(localChanges).length} neuložených změn
{unsavedCount} neuložených změn
</Badge>
)}
<Button
@@ -1492,18 +1941,17 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
</HStack>
</Flex>
</Box>
{/* Left Visual Style Panel */}
{showStylePanel && selectedElement && (
{/* Visual Style Panel (anchored, non-movable) */}
{showStylePanel && (
<Box
className="myuibrix-panel"
position="fixed"
left={`${panelPositions.visualStylePanel.x}px`}
top={`${panelPositions.visualStylePanel.y}px`}
width={`${panelPositions.visualStylePanel.width}px`}
height={`${panelPositions.visualStylePanel.height}px`}
left={stylePanelRight ? undefined : 4}
right={stylePanelRight ? 4 : undefined}
top={64}
bottom={4}
width="380px"
zIndex={9998}
onMouseDown={(e) => handlePanelMouseDown('visualStylePanel', e)}
cursor={draggingPanel === 'visualStylePanel' ? 'grabbing' : 'default'}
overflow="hidden"
display="flex"
flexDirection="column"
@@ -1526,14 +1974,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
bgGradient={`linear(135deg, ${primaryColor}, ${primaryColor}dd)`}
color="white"
p={3}
cursor="move"
cursor="default"
display="flex"
alignItems="center"
justifyContent="space-between"
flexShrink={0}
borderTopRadius="2xl"
borderBottom="1px solid rgba(255,255,255,0.2)"
boxShadow="0 2px 8px rgba(0,0,0,0.1)"
borderBottom="1px solid rgba(255,255,255,0.2)"
>
<HStack>
<Icon as={FaPaintBrush} />
@@ -1549,29 +1997,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
/>
</Box>
<Box flex="1" overflow="auto">
<VisualStylePanel
elementName={selectedElement}
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
currentStyles={elementStyles[selectedElement]}
/>
{selectedElement ? (
<VisualStylePanel
elementName={selectedElement}
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
currentStyles={elementStyles[selectedElement]}
/>
) : (
<Box p={4} color="gray.600" fontSize="sm">
<Text fontWeight="bold" mb={2}>Vyberte sekci</Text>
<Text>Vyberte sekci na stránce pro úpravu vizuálních stylů. Klikněte na zvýrazněný překryv sekce nebo vyberte ze seznamu vrstev.</Text>
</Box>
)}
</Box>
{/* Resize handle */}
<Box
position="absolute"
bottom={0}
right={0}
width="24px"
height="24px"
cursor="nwse-resize"
bgGradient="linear(135deg, transparent, rgba(0,0,0,0.15))"
opacity={0.4}
_hover={{ opacity: 1 }}
onMouseDown={(e) => handleResizeStart('visualStylePanel', e)}
sx={{
clipPath: 'polygon(100% 0, 100% 100%, 0 100%)'
}}
transition="opacity 0.2s"
/>
</Box>
)}
</>
@@ -1579,6 +2017,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Floating Control Panel - Minimalist */}
<Box
className="myuibrix-toolbar"
position="fixed"
left={4}
bottom={4}
@@ -2016,6 +2455,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
</Box>
)}
{/* Grid insertion pickers */}
{containerGridCols > 1 && (
<Box p={4} borderBottom="1px" borderColor="gray.100" bg="white">
<VStack align="stretch" spacing={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.600">Vložit do sloupce</Text>
<HStack spacing={2} flexWrap="wrap">
{Array.from({ length: containerGridCols }).map((_, col) => (
<Button
key={col}
size="sm"
variant={pendingInsertIndex != null && (pendingInsertIndex % containerGridCols) === col ? 'solid' : 'outline'}
colorScheme={pendingInsertIndex != null && (pendingInsertIndex % containerGridCols) === col ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
const idx = computeInsertIndexForColumn(col);
setPendingInsertIndex(idx);
toast({ title: 'Pozice zvolena', description: `Sloupec ${col + 1}`, status: 'info', duration: 1500 });
}}
>
Sloupec {col + 1}
</Button>
))}
<Button size="sm" variant="ghost" onClick={() => setPendingInsertIndex(null)}>Zrušit pozici</Button>
</HStack>
</VStack>
</Box>
)}
{/* Layers Panel - Visual element list with drag-drop */}
{isEditing && showLayersPanel && (
<Box
@@ -2023,10 +2490,11 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
position="fixed"
left={panelPositions.layersPanel.x === 0 ? undefined : `${panelPositions.layersPanel.x}px`}
right={panelPositions.layersPanel.x === 0 ? 4 : undefined}
top={panelPositions.layersPanel.y === 0 ? "50%" : `${panelPositions.layersPanel.y}px`}
transform={panelPositions.layersPanel.y === 0 ? "translateY(-50%)" : undefined}
top={panelPositions.layersPanel.y === 0 ? 4 : `${panelPositions.layersPanel.y}px`}
bottom={panelPositions.layersPanel.y === 0 ? 4 : undefined}
transform={undefined}
width={`${panelPositions.layersPanel.width}px`}
height={`${panelPositions.layersPanel.height}px`}
height={panelPositions.layersPanel.y === 0 ? 'auto' : `${panelPositions.layersPanel.height}px`}
bg="rgba(255, 255, 255, 0.97)"
backdropFilter="blur(16px) saturate(180%)"
borderRadius="2xl"
@@ -2038,10 +2506,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
onMouseDown={(e) => handlePanelMouseDown('layersPanel', e)}
cursor={draggingPanel === 'layersPanel' ? 'grabbing' : 'default'}
fontFamily="var(--chakra-fonts-body)"
display="flex"
flexDirection="column"
sx={{
'@keyframes slideInRight': {
from: { opacity: 0, transform: panelPositions.layersPanel.y === 0 ? 'translate(40px, -50%)' : 'translateX(40px)' },
to: { opacity: 1, transform: panelPositions.layersPanel.y === 0 ? 'translateY(-50%)' : 'translateX(0)' }
from: { opacity: 0, transform: 'translateX(40px)' },
to: { opacity: 1, transform: 'translateX(0)' }
},
animation: 'slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
}}
@@ -2073,8 +2543,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
/>
</Flex>
{/* Blank layout action */}
<Box p={2} borderBottom="1px" borderColor="whiteAlpha.400" bg="whiteAlpha.200">
<HStack justify="space-between">
<Text fontSize="xs" opacity={0.85}>Začít s prázdným rozložením</Text>
<Button size="xs" variant="outline" onClick={handleStartBlank}>
Začít s prázdným rozložením
</Button>
</HStack>
</Box>
{/* Layers List */}
<VStack align="stretch" p={3} spacing={2} maxH="calc(80vh - 60px)" overflowY="auto">
<VStack align="stretch" p={3} spacing={2} flex={1} overflowY="auto">
{elementOrder.map((elementName, index) => {
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
const isVisible = visibleElements.has(elementName);
@@ -2192,6 +2672,20 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
newVisible.add(elementName);
setVisibleElements(newVisible);
setHasChanges(true);
// Live preview: show element again
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName], visible: true, previewMode: true }
}));
}
// Restore element display and re-apply current visual order
setTimeout(() => {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = '';
}
applyVisualReorder(elementOrder);
}, 0);
}
}}
/>
@@ -2404,8 +2898,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
);
const availableElements = elements.filter(e => {
if (visibleElements.has(e.name)) return false;
// Filter by search query
// Filter by search query only; allow duplicates (we'll warn and move existing)
if (searchQuery) {
const query = searchQuery.toLowerCase();
return e.label.toLowerCase().includes(query) ||
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
VStack,
@@ -34,6 +34,7 @@ import CustomCSSEditor from './CustomCSSEditor';
import ColumnLayoutManager from './ColumnLayoutManager';
import ContextualAdminLinks from './ContextualAdminLinks';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { FONT_PAIRINGS, loadGoogleFont } from '../../config/fonts';
interface VisualStylePanelProps {
elementName: string;
@@ -94,12 +95,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
...currentStyles,
});
// Sync local styles state when switching element or when parent provides updated styles
useEffect(() => {
setStyles({
// Typography
fontFamily: currentStyles.fontFamily || 'Inter',
fontSize: currentStyles.fontSize || 16,
fontWeight: currentStyles.fontWeight || 400,
lineHeight: currentStyles.lineHeight || 1.5,
letterSpacing: currentStyles.letterSpacing || 0,
textTransform: currentStyles.textTransform || 'none',
// Colors
color: currentStyles.color || '#000000',
backgroundColor: currentStyles.backgroundColor || '#ffffff',
// Spacing
paddingTop: currentStyles.paddingTop || 0,
paddingRight: currentStyles.paddingRight || 0,
paddingBottom: currentStyles.paddingBottom || 0,
paddingLeft: currentStyles.paddingLeft || 0,
marginTop: currentStyles.marginTop || 0,
marginRight: currentStyles.marginRight || 0,
marginBottom: currentStyles.marginBottom || 0,
marginLeft: currentStyles.marginLeft || 0,
// Layout
width: currentStyles.width || 'auto',
height: currentStyles.height || 'auto',
display: currentStyles.display || 'block',
// Grid Layout
gridTemplateColumns: currentStyles.gridTemplateColumns || 'repeat(3, 1fr)',
gridTemplateRows: currentStyles.gridTemplateRows || 'auto',
gridColumnGap: currentStyles.gridColumnGap || 16,
gridRowGap: currentStyles.gridRowGap || 16,
gridAutoFlow: currentStyles.gridAutoFlow || 'row',
alignItems: currentStyles.alignItems || 'stretch',
justifyItems: currentStyles.justifyItems || 'stretch',
// Custom CSS
customCSS: currentStyles.customCSS || '',
...currentStyles,
});
}, [elementName, currentStyles]);
const updateStyle = (key: string, value: any) => {
const newStyles = { ...styles, [key]: value };
setStyles(newStyles);
onStyleChange(newStyles);
};
// Font pairing and curated font options
const [pairingId, setPairingId] = useState<string>(FONT_PAIRINGS[0]?.id || '');
const selectedPairing = useMemo(() => FONT_PAIRINGS.find(p => p.id === pairingId) || FONT_PAIRINGS[0], [pairingId]);
const curatedFonts = useMemo(() => {
const map = new Map<string, { name: string; googleFontsUrl: string }>();
FONT_PAIRINGS.forEach(p => {
map.set(p.heading, { name: p.heading, googleFontsUrl: p.googleFontsUrl });
map.set(p.body, { name: p.body, googleFontsUrl: p.googleFontsUrl });
});
return Array.from(map.values());
}, []);
return (
<Box
width="280px"
@@ -112,24 +171,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<Tabs size="sm" colorScheme="blue">
<TabList px={2} flexWrap="wrap">
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
<Tab><FiColumns /> <Text ml={1}>Layout</Text></Tab>
<Tab><FiType /> <Text ml={1}>Obsah</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Styl</Text></Tab>
<Tab><FiColumns /> <Text ml={1}>Rozvržení</Text></Tab>
<Tab><FiCode /> <Text ml={1}>CSS</Text></Tab>
<Tab><FiExternalLink /> <Text ml={1}>Admin</Text></Tab>
</TabList>
<TabPanels>
{/* Content Tab */}
{/* Záložka: Obsah */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Typography
Typografie
</Text>
{/* Font Family */}
{/* Font pairing from Setup (curated) */}
<FormControl>
<FormLabel fontSize="xs">Font Family</FormLabel>
<FormLabel fontSize="xs">Párování fontů (Setup)</FormLabel>
<HStack>
<Select
size="sm"
value={pairingId}
onChange={(e) => {
const id = e.target.value;
setPairingId(id);
const p = FONT_PAIRINGS.find(pp => pp.id === id);
if (p) {
loadGoogleFont(p.googleFontsUrl);
}
}}
>
{FONT_PAIRINGS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</Select>
</HStack>
<HStack spacing={2} mt={2}>
<Button size="xs" variant="outline" onClick={() => { if (selectedPairing) { loadGoogleFont(selectedPairing.googleFontsUrl); updateStyle('fontFamily', selectedPairing.cssHeading); } }}>Použít nadpisový</Button>
<Button size="xs" variant="outline" onClick={() => { if (selectedPairing) { loadGoogleFont(selectedPairing.googleFontsUrl); updateStyle('fontFamily', selectedPairing.cssBody); } }}>Použít textový</Button>
</HStack>
</FormControl>
{/* Curated font list (unique from pairing set) */}
<FormControl>
<FormLabel fontSize="xs">Dostupné fonty (Setup)</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
onChange={(e) => {
const val = e.target.value;
updateStyle('fontFamily', val);
const found = FONT_PAIRINGS.find(p => p.cssHeading === val || p.cssBody === val);
if (found) loadGoogleFont(found.googleFontsUrl);
}}
>
{curatedFonts.map(f => (
<option key={f.name} value={f.name}>{f.name}</option>
))}
</Select>
</FormControl>
{/* Rodina písma */}
<FormControl>
<FormLabel fontSize="xs">Písmo</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
@@ -146,9 +251,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</Select>
</FormControl>
{/* Font Size */}
{/* Velikost písma */}
<FormControl>
<FormLabel fontSize="xs">Size (px)</FormLabel>
<FormLabel fontSize="xs">Velikost (px)</FormLabel>
<HStack>
<NumberInput
size="sm"
@@ -167,9 +272,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Font Weight */}
{/* Tloušťka písma */}
<FormControl>
<FormLabel fontSize="xs">Weight</FormLabel>
<FormLabel fontSize="xs">Tloušťka</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.fontWeight}
@@ -188,9 +293,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Line Height */}
{/* Řádkování */}
<FormControl>
<FormLabel fontSize="xs">Line Height</FormLabel>
<FormLabel fontSize="xs">Řádkování</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.lineHeight}
@@ -209,9 +314,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Letter Spacing */}
{/* Mezery mezi písmeny */}
<FormControl>
<FormLabel fontSize="xs">Letter Spacing (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi písmeny (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.letterSpacing}
@@ -230,33 +335,33 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Text Transform */}
{/* Transformace textu */}
<FormControl>
<FormLabel fontSize="xs">Transform</FormLabel>
<FormLabel fontSize="xs">Transformace</FormLabel>
<Select
size="sm"
value={styles.textTransform}
onChange={(e) => updateStyle('textTransform', e.target.value)}
>
<option value="none">None</option>
<option value="uppercase">UPPERCASE</option>
<option value="lowercase">lowercase</option>
<option value="capitalize">Capitalize</option>
<option value="none">Žádné</option>
<option value="uppercase">VELKÁ PÍSMENA</option>
<option value="lowercase">malá písmena</option>
<option value="capitalize">První písmena velká</option>
</Select>
</FormControl>
</VStack>
</TabPanel>
{/* Style Tab */}
{/* Záložka: Styl */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Colors
Barvy
</Text>
{/* Text Color */}
{/* Barva textu */}
<FormControl>
<FormLabel fontSize="xs">Text Color</FormLabel>
<FormLabel fontSize="xs">Barva textu</FormLabel>
<HStack>
<Input
type="color"
@@ -275,9 +380,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Background Color */}
{/* Barva pozadí */}
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<FormLabel fontSize="xs">Barva pozadí</FormLabel>
<HStack>
<Input
type="color"
@@ -299,15 +404,15 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider my={2} />
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Spacing
Odsazení a okraje
</Text>
{/* Padding */}
{/* Vnitřní odsazení (padding) */}
<FormControl>
<FormLabel fontSize="xs">Padding (px)</FormLabel>
<FormLabel fontSize="xs">Vnitřní odsazení (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<Text fontSize="xs" minW="20px">N</Text>
<NumberInput
size="xs"
value={styles.paddingTop}
@@ -319,7 +424,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<Text fontSize="xs" minW="20px">P</Text>
<NumberInput
size="xs"
value={styles.paddingRight}
@@ -331,7 +436,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<Text fontSize="xs" minW="20px">D</Text>
<NumberInput
size="xs"
value={styles.paddingBottom}
@@ -357,12 +462,12 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</FormControl>
{/* Margin */}
{/* Vnější okraj (margin) */}
<FormControl>
<FormLabel fontSize="xs">Margin (px)</FormLabel>
<FormLabel fontSize="xs">Vnější okraj (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<Text fontSize="xs" minW="20px">N</Text>
<NumberInput
size="xs"
value={styles.marginTop}
@@ -373,7 +478,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<Text fontSize="xs" minW="20px">P</Text>
<NumberInput
size="xs"
value={styles.marginRight}
@@ -384,7 +489,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<Text fontSize="xs" minW="20px">D</Text>
<NumberInput
size="xs"
value={styles.marginBottom}
@@ -410,16 +515,16 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Layout Tab (was Grid Tab) */}
{/* Záložka: Rozvržení (mřížka) */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Grid Layout
Mřížkové rozložení
</Text>
{/* Enable Grid */}
{/* Povolit mřížkové rozložení */}
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="xs" mb={0} flex={1}>Enable Grid Layout</FormLabel>
<FormLabel fontSize="xs" mb={0} flex={1}>Povolit mřížkové rozložení</FormLabel>
<Switch
size="sm"
isChecked={styles.display === 'grid'}
@@ -437,9 +542,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<>
<Divider />
{/* Quick Templates */}
{/* Rychlé šablony */}
<FormControl>
<FormLabel fontSize="xs" fontWeight="bold">Quick Templates</FormLabel>
<FormLabel fontSize="xs" fontWeight="bold">Rychlé šablony</FormLabel>
<VStack spacing={2}>
<Button
size="xs"
@@ -450,7 +555,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiSmartphone />
<Text>Single Column</Text>
<Text>Jeden sloupec</Text>
</HStack>
</Button>
<Button
@@ -462,7 +567,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaColumns />
<Text>Two Equal (50% / 50%)</Text>
<Text>Dva stejné (50 % / 50 %)</Text>
</HStack>
</Button>
<Button
@@ -474,7 +579,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiBarChart2 />
<Text>Left Larger (66% / 33%)</Text>
<Text>Vlevo větší (66 % / 33 %)</Text>
</HStack>
</Button>
<Button
@@ -486,7 +591,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
<Text>Right Larger (33% / 66%)</Text>
<Text>Vpravo větší (33 % / 66 %)</Text>
</HStack>
</Button>
<Button
@@ -498,7 +603,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiGrid />
<Text>Three Equal (33% / 33% / 33%)</Text>
<Text>Tři stejné (33 % / 33 % / 33 %)</Text>
</HStack>
</Button>
<Button
@@ -510,7 +615,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaRegNewspaper />
<Text>Featured + Two (50% / 25% / 25%)</Text>
<Text>Zvýrazněný + dva (50 % / 25 % / 25 %)</Text>
</HStack>
</Button>
<Button
@@ -522,7 +627,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaRegSquare />
<Text>Four Equal (25% each)</Text>
<Text>Čtyři stejné (25 % každá)</Text>
</HStack>
</Button>
<Button
@@ -534,7 +639,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiSidebar />
<Text>Main + Sidebar (75% / 25%)</Text>
<Text>Hlavní + postranní (75 % / 25 %)</Text>
</HStack>
</Button>
</VStack>
@@ -542,38 +647,38 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider />
{/* Custom Columns */}
{/* Vlastní sloupce */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Columns</FormLabel>
<FormLabel fontSize="xs">Sloupce mřížky</FormLabel>
<Input
size="sm"
value={styles.gridTemplateColumns}
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
placeholder="e.g. 1fr 2fr or 300px 1fr"
placeholder="např. 1fr 2fr nebo 300px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
<Text fontSize="10px" color="gray.500" mt={1}>
Examples: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
Příklady: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
</Text>
</FormControl>
{/* Grid Template Rows */}
{/* Řádky mřížky */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Rows</FormLabel>
<FormLabel fontSize="xs">Řádky mřížky</FormLabel>
<Input
size="sm"
value={styles.gridTemplateRows}
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
placeholder="auto or 200px 1fr"
placeholder="auto nebo 200px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
</FormControl>
{/* Column Gap */}
{/* Mezera mezi sloupci */}
<FormControl>
<FormLabel fontSize="xs">Column Gap (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi sloupci (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridColumnGap}
@@ -592,9 +697,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Row Gap */}
{/* Mezera mezi řádky */}
<FormControl>
<FormLabel fontSize="xs">Row Gap (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi řádky (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridRowGap}
@@ -615,49 +720,49 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider />
{/* Grid Auto Flow */}
{/* Automatické rozmístění */}
<FormControl>
<FormLabel fontSize="xs">Auto Flow</FormLabel>
<FormLabel fontSize="xs">Automatické rozmístění</FormLabel>
<Select
size="sm"
value={styles.gridAutoFlow}
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
>
<option value="row">Row (horizontal)</option>
<option value="column">Column (vertical)</option>
<option value="row dense">Row Dense</option>
<option value="column dense">Column Dense</option>
<option value="row">Řádek (vodorovně)</option>
<option value="column">Sloupec (svisle)</option>
<option value="row dense">Řádek (zahuštěný)</option>
<option value="column dense">Sloupec (zahuštěný)</option>
</Select>
</FormControl>
{/* Align Items */}
{/* Zarovnání (vertikálně) */}
<FormControl>
<FormLabel fontSize="xs">Align Items (vertical)</FormLabel>
<FormLabel fontSize="xs">Zarovnání prvků (vertikálně)</FormLabel>
<Select
size="sm"
value={styles.alignItems}
onChange={(e) => updateStyle('alignItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="baseline">Baseline</option>
<option value="stretch">Roztáhnout</option>
<option value="start">Začátek</option>
<option value="center">Střed</option>
<option value="end">Konec</option>
<option value="baseline">Základní řádek</option>
</Select>
</FormControl>
{/* Justify Items */}
{/* Zarovnání (horizontálně) */}
<FormControl>
<FormLabel fontSize="xs">Justify Items (horizontal)</FormLabel>
<FormLabel fontSize="xs">Zarovnání prvků (horizontálně)</FormLabel>
<Select
size="sm"
value={styles.justifyItems}
onChange={(e) => updateStyle('justifyItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="stretch">Roztáhnout</option>
<option value="start">Začátek</option>
<option value="center">Střed</option>
<option value="end">Konec</option>
</Select>
</FormControl>
</>
@@ -665,16 +770,18 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Custom CSS Tab */}
{/* Záložka: Vlastní CSS */}
<TabPanel p={0}>
<CustomCSSEditor
elementName={elementName}
onCSSChange={(css) => updateStyle('customCSS', css)}
currentCSS={styles.customCSS || ''}
currentStyles={styles}
theme={{ primary: clubTheme.primary, secondary: clubTheme.secondary, accent: (clubTheme as any).accent }}
/>
</TabPanel>
{/* Admin Links Tab */}
{/* Záložka: Admin odkazy */}
<TabPanel>
<ContextualAdminLinks elementName={elementName} />
</TabPanel>
@@ -0,0 +1,111 @@
// Static TSX context snippets for AI CSS generation. Keep concise and representative.
// These snippets help the AI understand structure and common selectors per section.
export const ELEMENT_TSX_CONTEXT: Record<string, { tsx: string; selectors?: string[]; notes?: string }> = {
hero: {
tsx: `
<section data-element="hero" className="hero-grid">
{/* variant: grid | scroller | swiper | swiper_full */}
<a className="hero-card big">
<div className="bg" />
<div className="meta">
<div className="tag">Aktuality</div>
<h2 className="title">Nadpis</h2>
</div>
</a>
<a className="hero-card" />
<a className="hero-card" />
</section>
`.trim(),
selectors: ['.hero-grid', '.hero-card', '.bg', '.meta', '.tag', '.title'],
notes: 'Full-bleed variants may use negative margins and viewport width tricks.'
},
matches: {
tsx: `
<section data-element="matches" className="next-match">
<button className="nav prev" />
<div className="team">
<img className="logo" />
<div>Domácí</div>
</div>
<div className="countdown">Začátek zápasu</div>
<div className="team">
<img className="logo" />
<div>Hosté</div>
</div>
<button className="nav next" />
</section>
`.trim(),
selectors: ['.next-match', '.team', '.logo', '.countdown', '.nav.prev', '.nav.next'],
notes: 'Center content, strong contrast on countdown. Keep buttons accessible.'
},
'matches-slider': {
tsx: `
<section data-element="matches-slider" className="matches-slider">
<div className="section-head">
<h3>Zápasy</h3>
<a className="see-all" />
</div>
<div className="matches-grid">
<div className="matches-track">
<div className="match-card">
<div className="match-meta" />
<div className="teams">
<div className="team"><img /><div className="name" /></div>
<div className="score"><span className="home" /><span className="sep" /><span className="away" /></div>
<div className="team"><img /><div className="name" /></div>
</div>
</div>
</div>
<div className="matches-tabs"><button className="active" /></div>
</div>
</section>
`.trim(),
selectors: ['.matches-slider', '.section-head', '.see-all', '.matches-grid', '.matches-track', '.match-card', '.match-meta', '.teams', '.team', '.score', '.matches-tabs'],
notes: 'Horizontal scrolling track with cards; consider responsive card widths and gaps.'
},
news: {
tsx: `
<section data-element="news" className="news-list">
<div className="section-head"><h3>Další aktuality</h3></div>
<div className="blog-list">
<a className="card">
<div className="thumb" />
<div><h4>Title</h4><div className="excerpt" /></div>
</a>
</div>
<div><a className="btn">Zobrazit všechny aktuality</a></div>
</section>
`.trim(),
selectors: ['.news-list', '.section-head', '.blog-list', '.card', '.thumb', '.btn'],
},
table: {
tsx: `
<section data-element="table" className="standings">
<div className="table-card">
<div className="section-head">
<h3>Tabulky</h3>
<a className="see-all" />
</div>
<div className="standings-table-wrapper">
<table className="standings-table-compact">
<thead><tr><th>#</th><th>Tým</th><th>Z</th><th>V</th><th>R</th><th>P</th><th className="hide-mobile">Skóre</th><th>Body</th></tr></thead>
<tbody><tr><td>#1</td><td><img />Tým</td><td>…</td></tr></tbody>
</table>
</div>
</div>
</section>
`.trim(),
selectors: ['.standings', '.table-card', '.section-head', '.standings-table-wrapper', '.standings-table-compact', '.see-all'],
notes: 'Compact table; mind overflow-x on small screens.'
},
sponsors: {
tsx: `
<section data-element="sponsors" className="sponsors">
<div className="section-head"><h3>Sponzoři</h3></div>
<div className="sponsors-grid"><a className="sponsor-tile"><img /></a></div>
</section>
`.trim(),
selectors: ['.sponsors', '.section-head', '.sponsors-grid', '.sponsor-tile'],
},
};
@@ -0,0 +1,177 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import '../../styles/sparta-styles.css';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getCategories, Category } from '../../services/public';
// Minimal NavLink type used to render items
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
const SpartaNavbar: React.FC = () => {
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
// Load dynamic navigation
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getNavigationItems();
if (active && Array.isArray(items)) {
const publicItems = items.filter(item => !item.requires_admin);
if (publicItems.length === 0) {
try {
await seedDefaultNavigation();
const newItems = await getNavigationItems();
if (active && Array.isArray(newItems)) {
const publicNewItems = newItems.filter(item => !item.requires_admin);
setDynamicNavItems(publicNewItems);
}
} catch {
setDynamicNavItems([]);
}
} else {
setDynamicNavItems(publicItems);
}
}
} catch {
// leave empty, fallback will handle
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, []);
// Load categories (for Blog dropdown fallback)
useEffect(() => {
let active = true;
(async () => {
try {
const cats = await getCategories();
if (active && Array.isArray(cats) && cats.length > 0) {
setNavCategories(cats);
} else if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
} catch {
if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
}
})();
return () => { active = false };
}, [settings?.categories]);
const isPathActive = (to?: string) => {
if (!to) return false;
return location.pathname === to || location.pathname.startsWith((to || '') + '/');
};
const convertToNavLink = (item: NavigationItem): NavLink => {
const link: NavLink = {
label: item.label,
to: item.url || '#',
external: item.type === 'external',
};
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({ label: child.label, to: child.url || '#' }));
}
return link;
};
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({ label: cat.name, to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog') }));
}, [navCategories]);
const NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
const navLinks = dynamicNavItems.map(convertToNavLink);
if (categoryItems.length > 0) {
const idx = navLinks.findIndex(l => l.label === 'Články' || l.label === 'Blog' || l.to === '/blog');
if (idx !== -1) navLinks[idx] = { ...navLinks[idx], items: categoryItems };
}
return navLinks;
}
// Fallback minimal menu
const links: NavLink[] = [
{ label: 'Domů', to: '/' },
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
{ label: 'Kalendář', to: '/kalendar' },
{ label: 'Zápasy', to: '/zapasy' },
{ label: 'Aktivity', to: '/aktivity' },
{ label: 'Hráči', to: '/hraci' },
categoryItems.length > 0 ? { label: 'Články', to: '/blog', items: categoryItems } : { label: 'Články', to: '/blog' },
{ label: 'Videa', to: '/videa' },
{ label: settings?.gallery_label || 'Fotogalerie', to: '/galerie' },
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
{ label: 'Sponzoři', to: '/sponzori' },
{ label: 'Kontakt', to: '/kontakt' },
];
return links;
}, [navLoading, dynamicNavItems, settings?.show_about_in_nav, settings?.shop_url, settings?.gallery_label, categoryItems]);
const logoUrl = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const clubName = settings?.club_name || theme.name || 'Klub';
return (
<div className="sparta-navbar-container">
<div className="sparta-navbar">
{/* Burger toggle for mobile */}
<button
aria-label="Menu"
className="sparta-navbar-toggle"
onClick={() => setMobileOpen(o => !o)}
>
<div className="sparta-burger-icon" aria-hidden>
<div className="sparta-burger-line" />
<div className="sparta-burger-line" />
<div className="sparta-burger-line" />
</div>
</button>
{/* Brand */}
<RouterLink to="/" className="sparta-navbar-brand" onClick={() => setMobileOpen(false)}>
<img src={logoUrl} alt={clubName} />
</RouterLink>
{/* Links */}
<nav
className="sparta-navbar-links"
style={{ display: mobileOpen ? 'flex' : undefined, flexWrap: 'wrap' }}
>
{NAV_LINKS.map((nav) => {
const isActive = isPathActive(nav.to);
const className = isActive ? 'sparta-button-tertiary' : 'sparta-button-tertiary';
if (nav.external && nav.to) {
return (
<a key={nav.label} href={nav.to} target="_blank" rel="noreferrer" className={className} onClick={() => setMobileOpen(false)}>
{nav.label}
</a>
);
}
return (
<RouterLink key={nav.label} to={nav.to || '#'} className={className} onClick={() => setMobileOpen(false)}>
{nav.label}
</RouterLink>
);
})}
</nav>
</div>
</div>
);
};
export default SpartaNavbar;
+1 -1
View File
@@ -237,7 +237,7 @@ const Footer: React.FC = () => {
</Box>
{/* MyClub Watermark - Clean White Branding */}
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6}>
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6} data-watermark="myclub">
<Container maxW="container.xl">
<Stack
direction={{ base: 'column', md: 'row' }}
+39 -6
View File
@@ -3,13 +3,18 @@ import { ReactNode, useEffect, useState } from 'react';
import { FiChevronUp } from 'react-icons/fi';
import Navbar from '../Navbar';
import Footer from './Footer';
import { useAllPageElementConfigs } from '../../hooks/usePageElementConfig';
import SpartaNavbar from '../elements/SpartaNavbar';
interface MainLayoutProps {
children: ReactNode;
headerInsideContainer?: boolean;
}
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideContainer = false }) => {
const [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified');
useEffect(() => {
const onScroll = () => {
@@ -33,11 +38,39 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
return (
<Box minH="100vh" bg="bg.app" overflowX="hidden">
<Box id="top" position="absolute" top={0} left={0} />
<Navbar />
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Footer />
{headerInsideContainer ? (
<>
<Container maxW="container.xl" py={8}>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
)}
</Box>
{children}
</Container>
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
) : (
<>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
)}
</Box>
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
)}
{showTop && (
<IconButton
aria-label="Zpět nahoru"
@@ -40,9 +40,9 @@ export default function NewsletterSubscribe() {
toast({
title: 'Přihlášení k odběru proběhlo úspěšně',
description: 'Děkujeme za přihlášení k odběru našeho newsletteru!',
description: 'Vytvořili jsme vám fanouškovský účet a poslali email s heslem a odkazy pro správu newsletteru.',
status: 'success',
duration: 5000,
duration: 7000,
isClosable: true,
});
reset();
@@ -77,7 +77,7 @@ export default function NewsletterSubscribe() {
Přihlaste se k odběru novinek
</Text>
<Text textAlign="center" color={textColor} mb={2}>
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu.
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu. Současně pro vás vytvoříme fanouškovský účet a pošleme heslo emailem.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
@@ -118,8 +118,7 @@ export default function NewsletterSubscribe() {
</form>
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
Odesláním formuláře souhlasíte se zpracováním osobních údajů.
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek.
Odesláním formuláře souhlasíte se zpracováním osobních údajů. Z odběru se můžete kdykoli odhlásit a nastavení upravit v zaslaném emailu. Heslo lze změnit přes stránku pro obnovení hesla.
</Text>
</VStack>
</Box>
+148 -4
View File
@@ -15,9 +15,13 @@ import {
Image,
Heading,
useColorModeValue,
Input,
FormControl,
FormLabel,
Link,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon } from '@chakra-ui/icons';
import { CheckIcon, StarIcon } from '@chakra-ui/icons';
import {
Poll,
PollOption,
@@ -25,6 +29,9 @@ import {
getPollResults,
generateSessionToken,
} from '../../services/polls';
import { useUmami } from '../../hooks/useUmami';
import { useAuth } from '../../contexts/AuthContext';
import { Link as RouterLink, useLocation } from 'react-router-dom';
interface PollCardProps {
poll: Poll;
@@ -43,11 +50,32 @@ const PollCard: React.FC<PollCardProps> = ({
}) => {
const toast = useToast();
const queryClient = useQueryClient();
const { trackEvent } = useUmami();
const { isAuthenticated, user } = useAuth();
const location = useLocation();
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
const [hasVoted, setHasVoted] = useState(initialHasVoted);
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
const [results, setResults] = useState<any[]>([]);
const [showingResults, setShowingResults] = useState(initialCanShowResults);
const [voterName, setVoterName] = useState('');
const [voterEmail, setVoterEmail] = useState('');
const isRating = poll.type === 'rating';
const ratingOptionsSorted = [...poll.options].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const maxRating = ratingOptionsSorted.length > 0
? Math.max(...ratingOptionsSorted.map(o => (o.display_order || 0))) || ratingOptionsSorted.length
: 5;
const [ratingValue, setRatingValue] = useState<number | null>(null);
const selectOptionForRating = (value: number) => {
setRatingValue(value);
const byOrder = ratingOptionsSorted.find(o => (o.display_order || 0) === value);
const fallback = ratingOptionsSorted[value - 1];
const opt = byOrder || fallback;
if (opt) {
setSelectedOptions([opt.id]);
}
};
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -60,6 +88,8 @@ const PollCard: React.FC<PollCardProps> = ({
return votePoll(poll.id, {
option_ids: selectedOptions,
session_token: sessionToken,
voter_name: isAuthenticated ? (voterName || (user as any)?.name || undefined) : undefined,
voter_email: isAuthenticated ? (voterEmail || user?.email || undefined) : undefined,
});
},
onSuccess: async () => {
@@ -85,6 +115,19 @@ const PollCard: React.FC<PollCardProps> = ({
duration: 3000,
});
// Analytics tracking (Umami + backend)
try {
trackEvent('Poll Vote', {
poll_id: poll.id,
poll_title: poll.title,
type: poll.type,
option_ids: selectedOptions,
rating: ratingValue || undefined,
});
} catch (e) {
// swallow
}
if (onVoteSuccess) {
onVoteSuccess();
}
@@ -134,6 +177,28 @@ const PollCard: React.FC<PollCardProps> = ({
}
};
const handleOptionClick = (optionId: number) => {
if (poll.allow_multiple) {
const isSelected = selectedOptions.includes(optionId);
if (isSelected) {
setSelectedOptions(selectedOptions.filter((id) => id !== optionId));
} else {
if (selectedOptions.length >= poll.max_choices) {
toast({
title: 'Příliš mnoho voleb',
description: `Můžete vybrat maximálně ${poll.max_choices} možností.`,
status: 'warning',
duration: 3000,
});
return;
}
setSelectedOptions([...selectedOptions, optionId]);
}
} else {
setSelectedOptions([optionId]);
}
};
const loadResults = async () => {
try {
const resultsData = await getPollResults(poll.id);
@@ -263,7 +328,38 @@ const PollCard: React.FC<PollCardProps> = ({
{isActive && (
<>
{poll.allow_multiple ? (
{isRating ? (
<VStack align="stretch" spacing={2}>
{maxRating <= 5 ? (
<HStack>
{Array.from({ length: maxRating }).map((_, i) => (
<StarIcon
key={i}
boxSize={6}
cursor="pointer"
color={i < (ratingValue || 0) ? 'yellow.400' : 'gray.300'}
onClick={() => selectOptionForRating(i + 1)}
/>
))}
<Text ml={2}>{ratingValue ? `${ratingValue}/${maxRating}` : 'Vyberte hodnocení'}</Text>
</HStack>
) : (
<HStack flexWrap="wrap" spacing={2}>
{Array.from({ length: maxRating }).map((_, i) => (
<Button
key={i}
size="sm"
variant={ratingValue === i + 1 ? 'solid' : 'outline'}
colorScheme="blue"
onClick={() => selectOptionForRating(i + 1)}
>
{i + 1}
</Button>
))}
</HStack>
)}
</VStack>
) : poll.allow_multiple ? (
<CheckboxGroup
value={selectedOptions.map(String)}
onChange={handleMultipleChoice}
@@ -280,8 +376,17 @@ const PollCard: React.FC<PollCardProps> = ({
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Checkbox value={String(option.id)}>
<Checkbox value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
@@ -309,8 +414,17 @@ const PollCard: React.FC<PollCardProps> = ({
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Radio value={String(option.id)}>
<Radio value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
@@ -342,6 +456,36 @@ const PollCard: React.FC<PollCardProps> = ({
</RadioGroup>
)}
{isAuthenticated ? (
<VStack spacing={3} align="stretch">
<FormControl>
<FormLabel fontSize="sm">Jméno (volitelné)</FormLabel>
<Input
size="sm"
value={voterName || ((user as any)?.name || '')}
onChange={(e) => setVoterName(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">E-mail (volitelné)</FormLabel>
<Input
size="sm"
type="email"
value={voterEmail || (user?.email || '')}
onChange={(e) => setVoterEmail(e.target.value)}
/>
</FormControl>
</VStack>
) : (
<Text fontSize="sm" color="gray.500">
Chcete připojit své jméno k hlasu?{' '}
<Link as={RouterLink} color="blue.500" to="/login" state={{ from: location }}>
Přihlaste se
</Link>
.
</Text>
)}
<Button
colorScheme="blue"
onClick={handleVote}
+35 -8
View File
@@ -6,20 +6,31 @@ import { PageElementConfig } from '../services/pageElements';
// Elements that are actually implemented on HomePage
// Only these should be available in the editor
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
'header', // Site navigation/header
'hero', // Hero section with news cards (grid/scroller/swiper variants)
'news', // Featured news articles
'matches', // Upcoming/recent matches
'matches-slider', // Matches slider by competition
'table', // League standings table
'team', // Players scroller
'gallery', // Photo gallery albums from Zonerama
'videos', // Videos section
'merch', // Merchandise/fanshop
'newsletter',// Newsletter subscription
'poll', // Polls / voting widget
'sponsors', // Sponsors/partners
'banner', // Advertisement banners (various placements)
];
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
{
page_type: 'homepage',
element_name: 'header',
variant: 'unified',
visible: true,
display_order: 0,
settings: {},
},
{
page_type: 'homepage',
element_name: 'hero',
@@ -33,7 +44,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'news',
variant: 'grid',
visible: true,
display_order: 2,
display_order: 11,
settings: {},
},
{
@@ -41,7 +52,15 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'matches',
variant: 'compact',
visible: true,
display_order: 3,
display_order: 2,
settings: {},
},
{
page_type: 'homepage',
element_name: 'matches-slider',
variant: 'carousel',
visible: true,
display_order: 4,
settings: {},
},
{
@@ -49,7 +68,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'sponsors',
variant: 'grid',
visible: true,
display_order: 4,
display_order: 9,
settings: {},
},
{
@@ -65,7 +84,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'videos',
variant: 'grid',
visible: false,
display_order: 6,
display_order: 7,
settings: {},
},
{
@@ -73,7 +92,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'team',
variant: 'grid',
visible: false,
display_order: 7,
display_order: 6,
settings: {},
},
{
@@ -81,7 +100,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'merch',
variant: 'grid',
visible: true,
display_order: 7,
display_order: 8,
settings: {},
},
{
@@ -89,7 +108,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'table',
variant: 'split_news',
visible: true,
display_order: 8,
display_order: 3,
settings: {},
},
{
@@ -105,7 +124,15 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
element_name: 'newsletter',
variant: 'default',
visible: false,
display_order: 9,
display_order: 4,
settings: {},
},
{
page_type: 'homepage',
element_name: 'poll',
variant: 'vertical',
visible: false,
display_order: 12,
settings: {},
},
];
+20 -3
View File
@@ -91,8 +91,18 @@ export const useAllPageElementConfigs = (pageType: string) => {
setVisibility(visMap);
setElementOrder(order);
// Apply initial order to DOM if elements exist
if (order.length > 0) {
// Apply initial order to DOM only in editor/preview mode
const isEditingMode = (() => {
try {
if (typeof document !== 'undefined' && (document.body?.classList?.contains('myuibrix-edit-mode'))) return true;
const params = new URLSearchParams(window.location.search);
return params.get('myuibrix') === 'edit';
} catch {
return false;
}
})();
if (order.length > 0 && isEditingMode) {
requestAnimationFrame(() => {
applyDOMOrder(order);
});
@@ -137,7 +147,14 @@ export const useAllPageElementConfigs = (pageType: string) => {
const handleMyUIbrixReorder = ((event: CustomEvent) => {
const { order } = event.detail;
setElementOrder(order);
applyDOMOrder(order);
try {
const inEdit = document.body?.classList?.contains('myuibrix-edit-mode') || false;
if (inEdit) {
applyDOMOrder(order);
}
} catch {
// no-op
}
}) as EventListener;
// Listen for style changes from VisualStylePanel
+35 -40
View File
@@ -3,7 +3,7 @@ import MainLayout from '../components/layout/MainLayout';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getEvent } from '../services/eventService';
import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button, Image, Link as ChakraLink, Divider, Icon, useColorModeValue } from '@chakra-ui/react';
import { FiDownload, FiFile, FiImage, FiMapPin, FiClock } from 'react-icons/fi';
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url';
import EventLocationMap from '../components/events/EventLocationMap';
@@ -90,61 +90,33 @@ const ActivityDetailPage: React.FC = () => {
)}
{!loading && !error && data && (
<VStack align="stretch" spacing={5}>
{/* Hero image */}
{data.image_url && (
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
</Box>
)}
{/* Title and meta */}
<VStack align="stretch" spacing={1}>
<HStack justify="space-between" align="start">
<Heading as="h1" size="lg" lineHeight={1.2}>{data.title}</Heading>
<Badge colorScheme={typeColor(data.type)}>{typeLabel(data.type)}</Badge>
</HStack>
<HStack spacing={4} color={mutedText} fontSize="sm">
<HStack>
<Icon as={FiClock} />
<Text>
{new Date(data.start_time).toLocaleString()} {data.end_time ? ` ${new Date(data.end_time).toLocaleString()}` : ''}
</Text>
</HStack>
{data.location && (
<HStack>
<Icon as={FiMapPin} />
<Text>{data.location}</Text>
</HStack>
)}
<HStack>
<Icon as={FiClock} />
<Text>
{new Date(data.start_time).toLocaleString()} {data.end_time ? ` ${new Date(data.end_time).toLocaleString()}` : ''}
</Text>
</HStack>
</HStack>
</VStack>
{data.location && (
<EventLocationMap
location={data.location}
title={data.title}
latitude={data.latitude}
longitude={data.longitude}
/>
)}
{/* YouTube Video */}
{data.youtube_url && getYouTubeEmbedUrl(data.youtube_url) && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<Box position="relative" paddingBottom="56.25%" height={0}>
<iframe
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
title={data.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Box>
</Box>
)}
{/* Content */}
{data.description && (
<Box
bg={cardBg}
@@ -164,7 +136,34 @@ const ActivityDetailPage: React.FC = () => {
/>
)}
{/* Attachments with Preview */}
{data.youtube_url && getYouTubeEmbedUrl(data.youtube_url) && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<Box position="relative" paddingBottom="56.25%" height={0}>
<iframe
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
title={data.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Box>
</Box>
)}
{data.location && (
<EventLocationMap
location={data.location}
title={data.title}
latitude={data.latitude}
longitude={data.longitude}
/>
)}
{data?.id && (
<EmbeddedPoll eventId={data.id} />
)}
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
<VStack align="stretch" spacing={3}>
<Heading as="h3" size="sm">Přílohy</Heading>
@@ -183,7 +182,6 @@ const ActivityDetailPage: React.FC = () => {
</VStack>
)}
{/* Legacy single file_url */}
{data.file_url && (
<HStack>
<Button as={ChakraLink} href={assetUrl(data.file_url) || data.file_url} isExternal variant="outline" leftIcon={<FiDownload />}>
@@ -192,7 +190,6 @@ const ActivityDetailPage: React.FC = () => {
</HStack>
)}
{/* Back links */}
<Divider />
<HStack>
<Button as={RouterLink} to="/aktivity" variant="outline">Zpět na aktivity</Button>
@@ -203,8 +200,6 @@ const ActivityDetailPage: React.FC = () => {
</Container>
</Box>
{/* Embedded Poll - shows polls related to this event */}
{data?.id && <EmbeddedPoll eventId={data.id} />}
</MainLayout>
);
};
+1 -1
View File
@@ -60,7 +60,7 @@ const AuthPage: React.FC = () => {
return <Navigate to="/admin" replace />;
}
const from = (location.state as LocationState)?.from?.pathname || '/admin';
const from = (location.state as LocationState)?.from?.pathname || '/';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
+107 -146
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
import MainLayout from '../components/layout/MainLayout';
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import '../styles/theme.css';
import '../styles/sparta-styles.css';
import './styles/UnifiedHome.css';
import { getPublicSettings } from '../services/settings';
import { assetUrl, sanitizeClubName } from '../utils/url';
@@ -11,9 +12,11 @@ import BlogCardsScroller from '../components/home/BlogCardsScroller';
import BlogSwiper from '../components/home/BlogSwiper';
import VideosSection from '../components/home/VideosSection';
import MerchSection from '../components/home/MerchSection';
import PollsWidget from '../components/home/PollsWidget';
import GallerySection from '../components/home/GallerySection';
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
@@ -91,6 +94,7 @@ const HomePage: React.FC = () => {
type UiSponsor = { id:number|string; name:string; logo:string; url?:string };
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
type UiEvent = { id:number|string; title:string; start_time:string; end_time?:string|null; location?:string|null; type?:string; image_url?:string|null };
const [players, setPlayers] = useState<UiPlayer[]>([]);
const [sponsors, setSponsors] = useState<UiSponsor[]>([]);
const [banners, setBanners] = useState<UiBanner[]>([]);
@@ -99,6 +103,7 @@ const HomePage: React.FC = () => {
const [videosRich, setVideosRich] = useState<Array<{ url:string; title?:string; length?:string; uploaded_at?:string; thumbnail_url?:string }>>([]);
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
// Aliases
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
@@ -541,75 +546,6 @@ const HomePage: React.FC = () => {
}
}, [facrCompetitions, matchesTab, closestIndexByComp]);
// Auto-theme from club logo dominant color
useEffect(() => {
if (!clubLogo) return;
let disposed = false;
const toHex = (v: number) => {
const h = Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
return h;
};
const rgbToHex = (r: number, g: number, b: number) => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
const lighten = (r: number, g: number, b: number, amt = 20) => [
Math.min(255, r + amt),
Math.min(255, g + amt),
Math.min(255, b + amt),
] as const;
const darkenIfLowContrast = (r: number, g: number, b: number) => {
// ensure contrast versus white text (used in .next-match)
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
if (luminance > 0.75) {
// too light, darken
return [r * 0.6, g * 0.6, b * 0.6] as const;
}
return [r, g, b] as const;
};
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = assetUrl(clubLogo) || clubLogo;
img.onload = () => {
if (disposed) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = 100, h = 100;
canvas.width = w; canvas.height = h;
try {
ctx.drawImage(img, 0, 0, w, h);
const data = ctx.getImageData(0, 0, w, h).data;
let r = 0, g = 0, b = 0, n = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 64) continue; // skip transparent
const rr = data[i], gg = data[i + 1], bb = data[i + 2];
// skip near-white background pixels to better catch logo color
if (rr > 240 && gg > 240 && bb > 240) continue;
r += rr; g += gg; b += bb; n++;
}
if (n > 0) {
r /= n; g /= n; b /= n;
// adjust for readability
[r, g, b] = darkenIfLowContrast(r, g, b);
const [lr, lg, lb] = lighten(r, g, b, 24);
const primary = rgbToHex(r, g, b);
const primaryLight = rgbToHex(lr, lg, lb);
// secondary: golden fallback if color is blueish, else a subtle accent
const isBlueish = b > r && b > g;
const secondary = isBlueish ? '#d69e2e' : '#2c5282';
const root = document.documentElement;
root.style.setProperty('--primary', primary);
root.style.setProperty('--primary-light', primaryLight);
root.style.setProperty('--secondary', secondary);
}
} catch {
// ignore CORS or canvas tainting
}
};
return () => { disposed = true; };
}, [clubLogo]);
// MyUIbrix events are handled by useAllPageElementConfigs hook
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
@@ -656,6 +592,26 @@ const HomePage: React.FC = () => {
return () => clearInterval(id);
}, [matches, facrCompetitions, matchesTab]);
useEffect(() => {
let active = true;
(async () => {
try {
const evs = await getUpcomingEvents();
const mapped: UiEvent[] = (evs || []).map((e: any) => ({
id: e.id,
title: e.title,
start_time: e.start_time,
end_time: e.end_time,
location: e.location,
type: e.type,
image_url: e.image_url,
}));
if (active) setUpcomingEvents(mapped);
} catch {}
})();
return () => { active = false; };
}, []);
// Removed: Edge auto-cycle
// Removed: Aurora layout
@@ -1381,8 +1337,8 @@ const HomePage: React.FC = () => {
// }
return (
<MainLayout>
<div className="container">
<MainLayout headerInsideContainer>
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
{/* Header: logo + club name */}
<div className="home-header">
<img src={assetUrl(clubLogo) || '/images/club-logo.png'} alt="Klub" />
@@ -1553,7 +1509,7 @@ const HomePage: React.FC = () => {
</section>
) : null}
{/* Matches slider with scores by competition */}
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
@@ -1612,54 +1568,54 @@ const HomePage: React.FC = () => {
</div>
</section>
)}
{/* Competition tables moved into right column below */}
{/* Standings: tabs per competition (only FACR), clicking row opens ClubModal */}
{isVisible('table', true) && (() => {
// Match standings to current competition by name instead of assuming same index
{/* News + Tables: split into two independent sections */}
{(() => {
// Compute matching standings for the selected competition
const currentCompetition = facrCompetitions[matchesTab];
const currentCompetitionName = currentCompetition?.name || '';
const matchingStanding = standings.find((s: any) => s.name === currentCompetitionName);
const hasStandingsForCurrentTab = matchingStanding && (
const hasStandingsForCurrentTab = !!matchingStanding && (
(matchingStanding.table && matchingStanding.table.length > 0) ||
(matchingStanding.rows && matchingStanding.rows.length > 0)
);
return (
<section
data-element="table"
className="standings"
data-variant={hasStandingsForCurrentTab ? undefined : 'standard'}
style={{ marginTop: 32, ...getStyles('table') }}
>
<div>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
</div>
<div className="blog-list">
{news.length > 0 ? news.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
<>
{isVisible('news', true) && (
<section data-element="news" className="news-list" style={{ marginTop: 32, ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
</div>
<div className="blog-list">
{news.length > 0 ? news.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
</div>
</a>
)) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>Zatím nejsou k dispozici žádné aktuality.</p>
</div>
</a>
)) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>Zatím nejsou k dispozici žádné aktuality.</p>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
</div>
{hasStandingsForCurrentTab && (
<div>
</section>
)}
{isVisible('table', true) && hasStandingsForCurrentTab && (
<section
data-element="table"
className="standings"
style={{ marginTop: 32, ...getStyles('table') }}
>
<div className="table-card">
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
@@ -1709,12 +1665,10 @@ const HomePage: React.FC = () => {
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateX(2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
e.currentTarget.style.borderColor = 'var(--primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'var(--card-border)';
}}
@@ -1745,13 +1699,39 @@ const HomePage: React.FC = () => {
</table>
</div>
</div>
</div>
</section>
)}
</section>
</>
);
})()}
{/* Players scroller (optional) */}
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section data-element="activities" style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="blog-list">
{upcomingEvents.slice(0,4).map((e) => (
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{e.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
{new Date(e.start_time).toLocaleDateString()} {e.location ? `${e.location}` : ''}
</div>
</div>
</a>
))}
</div>
</div>
</section>
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<div className="section-head">
@@ -1770,22 +1750,6 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Merchandise / clothing (optional; only if shop URL is set) */}
{shopUrl && (
<section className="merch-cta">
<div className="card">
<div>
<h3>Oficiální fanshop</h3>
<p>Pořiďte si dresy, šály a další. Podpořte tým!</p>
<a className="btn" href={shopUrl || undefined} target="_blank" rel="noopener noreferrer">Přejít do eshopu</a>
</div>
<div className="mockup" aria-hidden>
<div className="shirt" />
</div>
</div>
</section>
)}
{/* Gallery */}
{isVisible('gallery', false) && (
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
@@ -1812,27 +1776,15 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Newsletter subscription CTA */}
{isVisible('newsletter', false) && (
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</section>
)}
{/* Banner: homepage_top */}
{(banners || []).some(b => b.placement === 'homepage_top') && (
<section data-element="banner" className="banner banner-top" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
{(banners || []).filter(b => b.placement === 'homepage_top').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
)}
{/* Banner: homepage_footer */}
{(banners || []).some(b => b.placement === 'homepage_footer') && (
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
@@ -1845,6 +1797,15 @@ const HomePage: React.FC = () => {
</section>
)}
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
</div>
</section>
)}
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */}
{isVisible('sponsors', true) && (
<section
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Button,
FormControl,
FormLabel,
Input,
VStack,
Heading,
useToast,
Text,
Link,
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
interface LocationState {
from: {
pathname: string;
};
}
const RegisterPage: React.FC = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const from = (location.state as LocationState)?.from?.pathname || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// Backend Register accepts name or first/last
const response = await api.post('/auth/register', {
email,
password,
name,
});
const { token, user } = response.data;
await login(token, user, true);
toast({ title: 'Účet vytvořen', status: 'success', duration: 3000 });
navigate(from, { replace: true });
} catch (error: any) {
toast({
title: 'Registrace selhala',
description: error?.response?.data?.error || error?.message || 'Zkuste to znovu.',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
return (
<Box minH="100vh" display="flex" alignItems="center" justifyContent="center">
<Box w="100%" maxW="md" p={8} borderWidth={1} borderRadius={8} boxShadow="lg">
<VStack as="form" onSubmit={handleSubmit} spacing={4} align="stretch">
<Heading as="h2" size="lg" textAlign="center" mb={2}>
Vytvořit účet
</Heading>
<FormControl id="name" isRequired>
<FormLabel>Jméno a příjmení</FormLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="např. Jan Novák"
/>
</FormControl>
<FormControl id="email" isRequired>
<FormLabel>Email</FormLabel>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="např. jan@klub.cz"
/>
</FormControl>
<FormControl id="password" isRequired>
<FormLabel>Heslo</FormLabel>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Zadejte heslo (min. 8 znaků)"
/>
</FormControl>
<Button type="submit" colorScheme="blue" width="full" mt={2} isLoading={isLoading}>
Zaregistrovat se
</Button>
<Text fontSize="sm" textAlign="center">
máte účet?{' '}
<Link color="blue.500" href="/login">
Přihlaste se
</Link>
</Text>
</VStack>
</Box>
</Box>
);
};
export default RegisterPage;
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
Container,
FormControl,
FormLabel,
Input,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Heading,
Text,
useToast,
VStack,
HStack,
Link as ChakraLink,
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
const SemiAdminPage: React.FC = () => {
const { user, updateUser } = useAuth();
const splitName = (full?: string) => {
const v = String(full || '').trim();
if (!v) return { fn: '', ln: '' };
const parts = v.split(/\s+/);
if (parts.length === 1) return { fn: parts[0], ln: '' };
return { fn: parts[0], ln: parts.slice(1).join(' ') };
};
const init = splitName(user?.name);
const [firstName, setFirstName] = useState(init.fn);
const [lastName, setLastName] = useState(init.ln);
const [isSaving, setIsSaving] = useState(false);
const [prefsToken, setPrefsToken] = useState<string>('');
const toast = useToast();
useEffect(() => {
const s = splitName(user?.name);
setFirstName(s.fn);
setLastName(s.ln);
}, [user?.name]);
useEffect(() => {
(async () => {
try {
const res = await api.get('/newsletter/token/me');
setPrefsToken(res.data?.token || '');
} catch {}
})();
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const res = await api.put('/me', { first_name: firstName, last_name: lastName });
const updated = res.data?.user;
if (updated) {
const n = `${updated.first_name || firstName} ${updated.last_name || lastName}`.trim();
updateUser({ name: n });
}
toast({ title: 'Uloženo', description: 'Osobní údaje byly aktualizovány.', status: 'success', duration: 3000 });
} catch (err: any) {
toast({ title: 'Chyba', description: err?.response?.data?.error || 'Nelze uložit změny', status: 'error' });
} finally {
setIsSaving(false);
}
};
const prefsUrl = prefsToken ? `/newsletter/preferences?token=${encodeURIComponent(prefsToken)}` : '';
return (
<Container maxW="5xl" py={8}>
<Heading size="lg" mb={6}>Fan zóna</Heading>
<Tabs colorScheme="blue" isFitted variant="enclosed">
<TabList>
<Tab>Osobní údaje</Tab>
<Tab>Newsletter</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Box as="form" onSubmit={handleSave} maxW="lg">
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Jméno</FormLabel>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Jméno" />
</FormControl>
<FormControl>
<FormLabel>Příjmení</FormLabel>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Příjmení" />
</FormControl>
<HStack>
<Button type="submit" colorScheme="blue" isLoading={isSaving}>Uložit</Button>
</HStack>
</VStack>
</Box>
</TabPanel>
<TabPanel>
<VStack align="start" spacing={4}>
<Text>Spravujte předvolby newsletteru nebo se odhlaste.</Text>
{prefsUrl ? (
<Button as={ChakraLink} href={prefsUrl} colorScheme="blue">Otevřít nastavení newsletteru</Button>
) : (
<Text>Načítám odkaz na nastavení</Text>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Container>
);
};
export default SemiAdminPage;
+32 -4
View File
@@ -19,6 +19,22 @@ import MapStyleSelector from '../components/admin/MapStyleSelector';
import { MapCoordinates } from '../utils/mapUrlParser';
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
const normalizePhone = (raw: string, country?: string) => {
let s = (raw || '').trim();
if (!s) return '';
s = s.replace(/[\s\-.()]/g, '');
s = s.replace(/^00/, '+');
if (s.startsWith('+')) return s;
if (/^420\d{9}$/.test(s)) return '+' + s;
if (/^\d{9}$/.test(s)) {
const c = (country || '').toLowerCase();
if (c.includes('česk') || c.includes('czech')) {
return '+420' + s;
}
}
return s;
};
const SetupPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -126,6 +142,8 @@ const SetupPage: React.FC = () => {
return out;
};
const isValidEmail = (val: string) => /^(?:[^\s@]+)@(?:[^\s@]+)\.(?:[^\s@]+)$/.test((val || '').trim());
useEffect(() => {
let mounted = true;
@@ -175,7 +193,7 @@ const SetupPage: React.FC = () => {
// Auto-fill SMTP username from contact email
useEffect(() => {
if (contactEmail && !smtpUser) {
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
setSmtpUser(contactEmail);
}
}, [contactEmail, smtpUser]);
@@ -285,7 +303,7 @@ const SetupPage: React.FC = () => {
contact_city: contactCity || undefined,
contact_zip: contactPostalCode || undefined,
contact_country: contactCountry || undefined,
contact_phone: contactPhone || undefined,
contact_phone: normalizePhone(contactPhone, contactCountry) || undefined,
contact_email: contactEmail || undefined,
smtp: (smtpHost || smtpPort || smtpUser || smtpPass || smtpFromName) ? {
host: smtpHost || undefined,
@@ -352,6 +370,12 @@ const SetupPage: React.FC = () => {
});
if (logoApiRes.ok) {
toast({ title: 'Logo nahráno', description: 'Logo bylo nahráno na logoapi i lokálně', status: 'success', duration: 3000 });
try {
const apiUrl = await fetchLogoFromLogoAPI(clubId, clubName || undefined);
if (apiUrl) {
setClubLogoUrl(apiUrl);
}
} catch {}
}
} catch (logoApiErr) {
console.warn('Failed to upload to logoapi:', logoApiErr);
@@ -726,7 +750,11 @@ const SetupPage: React.FC = () => {
setGpsLat(coords.latitude);
setGpsLng(coords.longitude);
// Auto-fill address fields if available from geocoding
if (coords.street) setContactStreet(coords.street);
if (coords.street) {
setContactStreet(coords.street);
} else if (coords.houseNumber && coords.city) {
setContactStreet(`${coords.city} ${coords.houseNumber}`);
}
if (coords.city) setContactCity(coords.city);
if (coords.zip) setContactPostalCode(coords.zip);
if (coords.country) setContactCountry(coords.country);
@@ -768,7 +796,7 @@ const SetupPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUser && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
</FormControl>
</SimpleGrid>
+18 -11
View File
@@ -40,13 +40,7 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
retry: false,
});
// Show loading state while fetching
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
const mid = (linkQ.data as any)?.external_match_id;
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
const facrQ = useQuery({
queryKey: ['facr-cached-match', mid],
@@ -77,6 +71,13 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
}
});
// Show loading state while fetching (after hooks are declared to keep order consistent)
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
// Guard against errors
if (facrQ.isError || linkQ.isError) {
return <Badge colorScheme="red">Chyba načítání</Badge>;
@@ -1164,6 +1165,12 @@ const ArticlesAdminPage = () => {
}
};
const matchBgSelected = useColorModeValue('blue.50', 'blue.900');
const matchBgDefault = useColorModeValue('white', 'gray.700');
const matchHoverBg = useColorModeValue('blue.50', 'gray.600');
const albumLinkHasPhotosBg = useColorModeValue('green.50', 'green.900');
const albumCardBg = useColorModeValue('white', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
<Box>
@@ -1500,9 +1507,9 @@ const ArticlesAdminPage = () => {
borderWidth="2px"
borderRadius="md"
borderColor={isSelected ? 'blue.500' : 'gray.200'}
bg={isSelected ? useColorModeValue('blue.50', 'blue.900') : useColorModeValue('white', 'gray.700')}
bg={isSelected ? matchBgSelected : matchBgDefault}
cursor="pointer"
_hover={{ borderColor: 'blue.300', bg: useColorModeValue('blue.50', 'gray.600') }}
_hover={{ borderColor: 'blue.300', bg: matchHoverBg }}
transition="all 0.2s"
onClick={async () => {
const val = matchId;
@@ -1605,7 +1612,7 @@ const ArticlesAdminPage = () => {
placeholder="https://eu.zonerama.com/…"
value={zAlbumLink}
onChange={(e) => setZAlbumLink(e.target.value)}
bg={zAlbumPhotos.length > 0 ? useColorModeValue('green.50', 'green.900') : undefined}
bg={zAlbumPhotos.length > 0 ? albumLinkHasPhotosBg : undefined}
/>
</InputGroup>
<FormHelperText fontSize="xs">
@@ -2111,7 +2118,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
@@ -2200,7 +2207,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
+81 -12
View File
@@ -38,14 +38,14 @@ import {
Select
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TeamLogo } from '../../components/common/TeamLogo';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
import { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
@@ -63,6 +63,60 @@ const MatchesAdminPage = () => {
notes: '',
});
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const normalizeName = (s: string) => {
let out = String(s || '');
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g');
out = out.replace(re, ' ');
}
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl;
}
if (facrOriginal) return facrOriginal;
return '/dist/img/logo-club-empty.svg';
};
// External logo upload helpers/state
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
@@ -137,6 +191,23 @@ const MatchesAdminPage = () => {
}));
},
});
useEffect(() => {
if (!Array.isArray(matches) || matches.length === 0) return;
const ids = new Set<string>();
for (const m of matches as any[]) {
if (m.home_id) ids.add(String(m.home_id));
if (m.away_id) ids.add(String(m.away_id));
}
if (ids.size === 0) return;
(async () => {
try {
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
setSportLogosMap(map);
} catch (e) {
console.warn('Failed to batch fetch logos:', e);
}
})();
}, [matches]);
// Filters
const [teamFilter, setTeamFilter] = useState('');
@@ -870,12 +941,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.home_id}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="custom"
<Image
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -888,12 +958,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.away_id}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="custom"
<Image
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
+65 -15
View File
@@ -4,6 +4,7 @@ import {
Button,
FormControl,
FormLabel,
FormErrorMessage,
Heading,
HStack,
IconButton,
@@ -229,6 +230,13 @@ const PlayersAdminPage: React.FC = () => {
const [editing, setEditing] = useState<Editing | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const JERSEY_MIN = 0;
const JERSEY_MAX = 99;
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
const WEIGHT_MAX = 200;
// Local state to persist partial DOB selections so the user sees what they picked
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
@@ -276,14 +284,47 @@ const PlayersAdminPage: React.FC = () => {
},
});
const maybeSplitName = () => {
setEditing((p) => {
if (!p) return p;
const fn = (p.first_name || '').trim();
const ln = (p.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return { ...(p as any), first_name: parts[0], last_name: parts[parts.length - 1] } as any;
}
}
return p as any;
});
};
const onSubmit = async () => {
if (!editing) return;
const fn = (editing.first_name || '').trim();
const ln = (editing.last_name || '').trim();
let fn = (editing.first_name || '').trim();
let ln = (editing.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
fn = parts[0];
ln = parts[parts.length - 1];
}
}
if (!fn || !ln) {
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
return;
}
const tooBig = (
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
) || (
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
) || (
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
);
if (tooBig) {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
@@ -291,10 +332,16 @@ const PlayersAdminPage: React.FC = () => {
};
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) payload.jersey_number = editing.jersey_number;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
payload.jersey_number = editing.jersey_number;
}
if (editing.nationality) payload.nationality = editing.nationality;
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
if (typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > 0) {
payload.height = editing.height;
}
if (typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > 0) {
payload.weight = editing.weight;
}
if (editing.image_url) payload.image_url = editing.image_url;
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
const email = ((editing as any).email || '').trim();
@@ -373,7 +420,7 @@ const PlayersAdminPage: React.FC = () => {
<SimpleGrid columns={[1, 2]} spacing={4}>
<FormControl isRequired>
<FormLabel>Jméno</FormLabel>
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} />
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} onBlur={maybeSplitName} />
</FormControl>
<FormControl isRequired>
<FormLabel>Příjmení</FormLabel>
@@ -414,11 +461,12 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
<FormLabel>Číslo dresu</FormLabel>
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
</FormControl>
<FormControl>
@@ -466,17 +514,19 @@ const PlayersAdminPage: React.FC = () => {
</VStack>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.height === 'number' && (editing?.height as number) > HEIGHT_MAX}>
<FormLabel>Výška (cm)</FormLabel>
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={HEIGHT_MIN} max={HEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.height === 'number' ? editing?.height : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální výška je {HEIGHT_MAX} cm.</FormErrorMessage>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.weight === 'number' && (editing?.weight as number) > WEIGHT_MAX}>
<FormLabel>Váha (kg)</FormLabel>
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={WEIGHT_MIN} max={WEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.weight === 'number' ? editing?.weight : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální váha je {WEIGHT_MAX} kg.</FormErrorMessage>
</FormControl>
{/* Optional contact info (not shown publicly) */}
<FormControl>
+77 -6
View File
@@ -211,6 +211,65 @@ const PollsAdminPage: React.FC = () => {
onOpen();
};
const applyPreset = (preset: 'rating5' | 'rating10' | 'attendance') => {
if (preset === 'rating5') {
const options = Array.from({ length: 5 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'rating10') {
const options = Array.from({ length: 10 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu (110)',
description: 'Ohodnoťte zápas (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'attendance') {
setFormData({
title: 'Dorazíš na schůzku?',
description: 'Dej nám vědět, zda dorazíš.',
type: 'single',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
],
});
}
onOpen();
};
const handleOpenEdit = (poll: Poll) => {
setEditingPoll(poll);
setFormData({
@@ -362,9 +421,21 @@ const PollsAdminPage: React.FC = () => {
<VStack spacing={6} align="stretch">
<HStack justify="space-between">
<Heading size="lg">Správa anket</Heading>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
<HStack>
<Menu>
<MenuButton as={Button} rightIcon={<ChevronDownIcon />} variant="outline">
Předvolby
</MenuButton>
<MenuList>
<MenuItem onClick={() => applyPreset('rating5')}>Hodnocení zápasu (5 hvězd)</MenuItem>
<MenuItem onClick={() => applyPreset('rating10')}>Hodnocení zápasu (110)</MenuItem>
<MenuItem onClick={() => applyPreset('attendance')}>Dorazíš na schůzku?</MenuItem>
</MenuList>
</Menu>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
</HStack>
</HStack>
<Alert status="info">
@@ -818,7 +889,7 @@ const PollsAdminPage: React.FC = () => {
Výsledky
</Heading>
<VStack spacing={2} align="stretch">
{statsData.poll.options.map((option) => {
{(statsData.poll.options || []).map((option) => {
const percentage =
statsData.poll.total_votes > 0
? (option.vote_count / statsData.poll.total_votes) * 100
@@ -851,13 +922,13 @@ const PollsAdminPage: React.FC = () => {
</VStack>
</Box>
{statsData.votes_by_day.length > 0 && (
{(statsData.votes_by_day?.length ?? 0) > 0 && (
<Box>
<Heading size="sm" mb={4}>
Hlasy podle dnů
</Heading>
<VStack spacing={2} align="stretch">
{statsData.votes_by_day.map((day) => (
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
+10 -19
View File
@@ -51,7 +51,7 @@ import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides,
import { getFacrTablesCache } from '../../services/facr/cache';
import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react';
import { TeamLogo } from '../../components/common/TeamLogo';
type TableRow = {
rank?: string;
@@ -291,26 +291,16 @@ const TeamsAdminPage = () => {
.map((s) => s.trim())
.filter(Boolean);
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
// If no file was uploaded but we have a logo URL, fetch it as blob
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
// Upload to the logo service (loga.sportcreative.eu)
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
@@ -319,10 +309,8 @@ const TeamsAdminPage = () => {
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
// Use the URL from loga.sportcreative.eu
if (logaResult.url) {
logoUrl = logaResult.url;
}
@@ -340,6 +328,11 @@ const TeamsAdminPage = () => {
}
}
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
return true;
},
onSuccess: () => {
@@ -495,12 +488,10 @@ const TeamsAdminPage = () => {
<Td py={1.5} fontSize="xs">{r.rank}</Td>
<Td py={1.5}>
<HStack spacing={2} align="center">
<TeamLogo
teamId={(r as any).team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
size="small"
<Image
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
alt={r.team}
boxSize="24px"
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
+5 -4
View File
@@ -49,7 +49,7 @@ interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor';
role: 'admin' | 'editor' | 'fan';
isActive: boolean;
createdAt: string;
}
@@ -65,7 +65,7 @@ const UsersAdminPage = () => {
email: '',
password: '',
currentPassword: '',
role: 'editor' as 'admin' | 'editor',
role: 'editor' as 'admin' | 'editor' | 'fan',
isActive: true,
});
const toast = useToast();
@@ -254,8 +254,8 @@ const UsersAdminPage = () => {
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
{user.role === 'admin' ? 'Admin' : 'Editor'}
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
</Badge>
</Td>
<Td>
@@ -385,6 +385,7 @@ const UsersAdminPage = () => {
value={formData.role}
onChange={handleInputChange}
>
<option value="fan">Fan</option>
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
<option value="admin">Admin</option>
</Select>
+24
View File
@@ -28,6 +28,30 @@ export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGene
return parsedData;
}
export interface AIGenerateCSSReq {
prompt: string;
element_name?: string;
root_selector?: string;
current_css?: string;
current_styles?: Record<string, any>;
theme?: Record<string, string>;
breakpoints?: number[];
context?: Record<string, any>;
}
export interface AIGenerateCSSResp {
css: string;
}
export async function generateCSSAI(payload: AIGenerateCSSReq): Promise<AIGenerateCSSResp> {
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload);
let parsed = data as any;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { css: '' }; }
}
return parsed as AIGenerateCSSResp;
}
export interface AIGenerateAboutReq {
prompt: string;
club_name?: string;
+8
View File
@@ -122,6 +122,7 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Content - Obsah
{ name: 'news', label: 'Novinky', description: 'Nejnovější články a zprávy', icon: FaNewspaper, category: 'content', defaultVariant: 'grid' },
{ name: 'matches', label: 'Zápasy', description: 'Nadcházející a poslední zápasy', icon: FaFutbol, category: 'content', defaultVariant: 'compact' },
{ name: 'matches-slider', label: 'Zápasy (slider)', description: 'Přehled zápasů podle soutěže ve slideru', icon: FaFutbol, category: 'content', defaultVariant: 'carousel' },
{ name: 'team', label: 'Tým', description: 'Hráči a realizační tým', icon: FaUsers, category: 'content', defaultVariant: 'grid' },
{ name: 'table', label: 'Tabulka', description: 'Ligová tabulka', icon: FaTable, category: 'content', defaultVariant: 'split_news' },
{ name: 'stats', label: 'Statistiky', description: 'Týmové a hráčské statistiky', icon: FaChartLine, category: 'content', defaultVariant: 'cards' },
@@ -161,6 +162,8 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'sticky', label: 'Přilepený', description: 'Pevně přilepená hlavička při scrollování' },
{ value: 'transparent', label: 'Průhledný', description: 'Průhledná hlavička s efektem' },
{ value: 'sparta_navbar', label: 'Sparta Navbar', description: 'AC Sparta Praha styl - burger menu, logo, navigace, vyhledávání' },
{ value: 'current', label: 'Současný', description: 'Stávající navigace' },
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
],
hero: [
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
@@ -192,6 +195,11 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'scoreboard', label: 'Tabule', description: 'TV broadcast style - velká tabule skóre s live aktualizacemi' },
{ value: 'ticker', label: 'Ticker', description: 'Scrollující ticker s výsledky a nadcházejícími zápasy' },
],
'matches-slider': [
{ value: 'carousel', label: 'Karusel', description: 'Horizontální karusel zápasů' },
{ value: 'scroller', label: 'Posuvník', description: 'Plynulý horizontální posuvník' },
{ value: 'ticker', label: 'Ticker', description: 'Úzký ticker výsledků a zápasů' },
],
sponsors: [
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
{ value: 'slider', label: 'Posuvník', description: 'Animovaný posuvník' },
+2
View File
@@ -70,6 +70,8 @@ export interface PollOption {
export interface PollVoteRequest {
option_ids: number[];
session_token?: string;
voter_name?: string;
voter_email?: string;
}
export interface PollResult {
@@ -96,3 +96,9 @@ export async function unsubscribeToken(token: string): Promise<{ message: string
const { data } = await api.post<{ message: string }>(`/newsletter/unsubscribe-token`, { token });
return data;
}
// Fetch a short-lived newsletter preferences token for the currently authenticated user
export async function getMyNewsletterToken(): Promise<{ token: string }> {
const { data } = await api.get<{ token: string }>(`/newsletter/token/me`);
return data;
}
+14 -3
View File
@@ -1,11 +1,22 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
function resolveBackendOrigin() {
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
try {
const u = new URL(raw);
u.pathname = '/';
return u.toString();
} catch (e) {
return 'http://localhost:8080';
}
}
module.exports = function(app) {
// Proxy /uploads requests to backend
app.use(
'/uploads',
createProxyMiddleware({
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
@@ -18,7 +29,7 @@ module.exports = function(app) {
app.use(
'/static',
createProxyMiddleware({
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
@@ -31,7 +42,7 @@ module.exports = function(app) {
app.use(
'/cache',
createProxyMiddleware({
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
+2
View File
@@ -11,6 +11,7 @@ export interface MapCoordinates {
source: 'mapy.cz' | 'google-maps' | 'unknown';
// Detailed address components from reverse geocoding
street?: string;
houseNumber?: string;
city?: string;
zip?: string;
country?: string;
@@ -210,6 +211,7 @@ export async function reverseGeocode(lat: number, lng: number): Promise<Partial<
return {
address: data.display_name,
street: addr.road || addr.street || addr.pedestrian || addr.footway,
houseNumber: addr.house_number,
city: addr.city || addr.town || addr.village || addr.municipality,
zip: addr.postcode,
country: addr.country || 'Česká republika',
+2 -7
View File
@@ -216,7 +216,7 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
try {
// Check cache first
const cached = await getCachedLogo(teamId);
if (cached?.url) {
if (cached?.url && !cached.url.startsWith('blob:')) {
await updateLastUsed(teamId);
return cached.url;
}
@@ -230,15 +230,10 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
if (!res.ok) return null;
const data = await res.json();
let url = data.logo_url_svg || data.logo_url_png || data.logo_url;
const url = data.logo_url_svg || data.logo_url_png || data.logo_url;
if (!url) return null;
// Optimize SVG if it's an SVG
if (url.includes('.svg') || data.logo_url_svg) {
url = await optimizeSVG(url);
}
// Cache the logo
await saveCachedLogo({
id: teamId,
+104
View File
@@ -20,6 +20,96 @@ type AIController struct {
DB *gorm.DB
}
// GenerateCSS creates scoped CSS for a page element
func (ac *AIController) GenerateCSS(c *gin.Context) {
var req aiCSSRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
rootSelector := strings.TrimSpace(req.RootSelector)
if rootSelector == "" {
en := strings.TrimSpace(req.ElementName)
if en == "" { en = "element" }
rootSelector = fmt.Sprintf("[data-element=\"%s\"]", en)
}
themeJSON, _ := json.Marshal(req.Theme)
stylesJSON, _ := json.Marshal(req.CurrentStyles)
system := "Jsi zkušený CSS návrhář pro klubové weby. Piš čistý, přístupný a responzivní CSS. VÝSTUP POUZE JSON: {\"css\":\"...\"}. Nepoužívej reset, neovlivňuj globální prvky. CSS MUSÍ být scope-nuté POUZE pod kořenový selektor, žádný selektor mimo. Používej CSS proměnné (např. --club-primary, --club-secondary). Čeština není nutná v kódu, ale požadavky jsou v češtině."
user := fmt.Sprintf("Požadavek: %s\nKořenový selektor: %s\nAktuální CSS (může být prázdné):\n---\n%s\n---\nAktuální styly (JSON): %s\nTéma (JSON): %s\nBreakpoints: %v\nPožadavky: 1) Scope pouze pod kořenový selektor. 2) Žádné !important. 3) Media queries pro mobil/tablet/desktop dle potřeby. 4) Zaměř se na vzhled prvků uvnitř bloku. 5) Nepřidávej inline styly ani globální sel. 6) Používej proměnné, zachovej kontrast a čitelnost.",
strings.TrimSpace(req.Prompt), rootSelector, strings.TrimSpace(req.CurrentCSS), string(stylesJSON), string(themeJSON), req.Breakpoints)
callModel := func(modelName string) (string, int, error) {
payload := map[string]interface{}{
"model": modelName,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.3,
"max_tokens": 1200,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil { return "", http.StatusInternalServerError, err }
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
if err != nil { return "", http.StatusBadGateway, err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err }
if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") }
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
content, _, err := callModel(model)
if err != nil || strings.TrimSpace(content) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return }
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
}
}
sanitized := sanitizeAIResponse(content)
var out aiCSSResponse
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
if strings.TrimSpace(out.CSS) == "" {
out.CSS = fmt.Sprintf("%s { }", rootSelector)
}
c.JSON(http.StatusOK, out)
}
// GenerateAboutPage creates about page content using the OpenRouter API
func (ac *AIController) GenerateAboutPage(c *gin.Context) {
var req aiAboutRequest
@@ -194,6 +284,20 @@ type aiAboutResponse struct {
SEODescription string `json:"seo_description"`
}
type aiCSSRequest struct {
Prompt string `json:"prompt" binding:"required"`
ElementName string `json:"element_name"`
RootSelector string `json:"root_selector"`
CurrentCSS string `json:"current_css"`
CurrentStyles map[string]interface{} `json:"current_styles"`
Theme map[string]string `json:"theme"`
Breakpoints []int `json:"breakpoints"`
}
type aiCSSResponse struct {
CSS string `json:"css"`
}
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
func (ac *AIController) GenerateBlog(c *gin.Context) {
var req aiBlogRequest
+5 -1
View File
@@ -27,7 +27,7 @@ func NewArticleController(db *gorm.DB) *ArticleController {
// CreateArticleRequest represents the request body for creating an article
type CreateArticleRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Content string `json:"content"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
@@ -138,6 +138,10 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
if req.Published != nil {
published = *req.Published
}
if published && strings.TrimSpace(req.Content) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Obsah je povinný pro publikovaný článek"})
return
}
var publishedAt *time.Time
if req.PublishedAt != nil && strings.TrimSpace(*req.PublishedAt) != "" {
if t, err := time.Parse(time.RFC3339, *req.PublishedAt); err == nil {
+37 -4
View File
@@ -109,7 +109,7 @@ func (ac *AuthController) Register(c *gin.Context) {
// Check if this is the first user (admin)
var userCount int64
ac.DB.Model(&models.User{}).Count(&userCount)
role := "editor"
role := "fan"
isFirstUser := userCount == 0
if isFirstUser {
role = "admin"
@@ -287,6 +287,39 @@ func (ac *AuthController) GetCurrentUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(user.(*models.User))})
}
// UpdateCurrentUser allows the authenticated user to update their personal information
func (ac *AuthController) UpdateCurrentUser(c *gin.Context) {
u, exists := c.Get("user")
if !exists || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
current := u.(*models.User)
var req struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
fn := strings.TrimSpace(req.FirstName)
ln := strings.TrimSpace(req.LastName)
if fn != "" {
current.FirstName = fn
}
if ln != "" {
current.LastName = ln
}
if err := ac.DB.Save(current).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(current)})
}
// AdminExists returns whether any admin user exists
func (ac *AuthController) AdminExists(c *gin.Context) {
var count int64
@@ -435,8 +468,8 @@ func (ac *AuthController) AdminCreateUser(c *gin.Context) {
return
}
// role
role := req.Role
if role != "admin" && role != "editor" {
role := strings.TrimSpace(req.Role)
if role != "admin" && role != "editor" && role != "fan" {
role = "editor"
}
// active
@@ -527,7 +560,7 @@ func (ac *AuthController) AdminUpdateUser(c *gin.Context) {
user.Email = email
}
if req.Role != "" {
if req.Role != "admin" && req.Role != "editor" {
if req.Role != "admin" && req.Role != "editor" && req.Role != "fan" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
return
}
+221 -21
View File
@@ -40,6 +40,31 @@ type BaseController struct {
DB *gorm.DB
}
func normalizePhone(raw, country string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
re := regexp.MustCompile(`[\s\-\.\(\)]`)
s = re.ReplaceAllString(s, "")
if strings.HasPrefix(s, "00") {
s = "+" + s[2:]
}
if strings.HasPrefix(s, "+") {
return s
}
if matched, _ := regexp.MatchString(`^420\d{9}$`, s); matched {
return "+" + s
}
if matched, _ := regexp.MatchString(`^\d{9}$`, s); matched {
c := strings.ToLower(country)
if strings.Contains(c, "česk") || strings.Contains(c, "czech") {
return "+420" + s
}
}
return s
}
// GetMatchesHistory returns cached past matches with overrides applied (public)
// Optional query: q= filters by home/away/venue/competition
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
@@ -600,6 +625,24 @@ func (bc *BaseController) CreateCategory(c *gin.Context) {
Name: name,
Description: strings.TrimSpace(body.Description),
}
// Ensure category slug is set and unique
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
@@ -1715,10 +1758,31 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
ClubName string `json:"club_name"`
ClubLogoURL string `json:"club_logo_url"`
ClubURL string `json:"club_url"`
// Social profiles (optional)
FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"`
YoutubeURL string `json:"youtube_url"`
// Gallery (optional)
GalleryURL string `json:"gallery_url"`
GalleryLabel string `json:"gallery_label"`
// Location/Contact (optional)
ContactAddress string `json:"contact_address"`
ContactCity string `json:"contact_city"`
ContactZip string `json:"contact_zip"`
ContactCountry string `json:"contact_country"`
ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"`
LocationLatitude float64 `json:"location_latitude"`
LocationLongitude float64 `json:"location_longitude"`
MapStyle string `json:"map_style"`
// Frontpage style (optional)
FrontpageStyle string `json:"frontpage_style"`
// Theme (optional, can set later)
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
AccentColor string `json:"accent_color"`
@@ -1726,7 +1790,9 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
TextColor string `json:"text_color"`
FontHeading string `json:"font_heading"`
FontBody string `json:"font_body"`
SMTP *struct {
// SMTP optional
SMTP *struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
@@ -1789,24 +1855,36 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
s.YoutubeURL = v
}
// Always allow updating SMTP via setup when admin already exists (idempotent)
// Gallery
if body.GalleryURL != "" {
s.GalleryURL = strings.TrimSpace(body.GalleryURL)
}
if body.GalleryLabel != "" {
s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
}
// Frontpage style
if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle
}
// SMTP overrides from initial setup
if body.SMTP != nil {
if host := strings.TrimSpace(body.SMTP.Host); host != "" {
s.SMTPHost = host
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
s.SMTPHost = v
}
if body.SMTP.Port > 0 {
s.SMTPPort = body.SMTP.Port
}
if u := strings.TrimSpace(body.SMTP.Username); u != "" {
s.SMTPUser = u
if v := strings.TrimSpace(body.SMTP.Username); v != "" {
s.SMTPUser = v
s.SMTPAuth = true
}
if p := body.SMTP.Password; p != "" {
s.SMTPPassword = p
if v := body.SMTP.Password; v != "" {
s.SMTPPassword = v
}
if from := strings.TrimSpace(body.SMTP.From); from != "" {
s.SMTPFrom = from
if v := strings.TrimSpace(body.SMTP.From); v != "" {
s.SMTPFrom = v
}
// Default FromName if empty
if s.SMTPFromName == "" {
s.SMTPFromName = "Fotbal Club"
}
@@ -1834,7 +1912,6 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
return
}
}
// Trigger background prefetch and YouTube cache refresh when settings are updated post-setup
scheme := "http"
if c.Request.TLS != nil {
@@ -2035,7 +2112,7 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
s.ContactCountry = v
}
if v := strings.TrimSpace(body.ContactPhone); v != "" {
s.ContactPhone = v
s.ContactPhone = normalizePhone(v, body.ContactCountry)
}
if v := strings.TrimSpace(body.ContactEmail); v != "" {
s.ContactEmail = v
@@ -2530,7 +2607,25 @@ func (bc *BaseController) CreateArticle(c *gin.Context) {
var cat models.Category
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create category with unique slug
cat = models.Category{Name: name}
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL (kategorie)"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
@@ -2685,6 +2780,8 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
YouTubeVideoTitle *string `json:"youtube_video_title"`
YouTubeVideoURL *string `json:"youtube_video_url"`
YouTubeVideoThumbnail *string `json:"youtube_video_thumbnail"`
// Attachments array from frontend, stored as JSON string in model
Attachments []map[string]any `json:"attachments"`
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
@@ -2729,7 +2826,25 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
var cat models.Category
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create category with unique slug
cat = models.Category{Name: name}
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL (kategorie)"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
@@ -2758,8 +2873,25 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
art.Featured = *body.Featured
}
if body.Slug != nil {
art.Slug = strings.TrimSpace(*body.Slug)
}
s := strings.TrimSpace(*body.Slug)
if s == "" {
s = makeSlug(art.Title)
}
// Ensure slug is unique across other articles
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Article{}).Where("slug = ? AND id != ?", s, art.ID).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
art.Slug = s
}
if body.SeoTitle != nil {
art.SEOTitle = strings.TrimSpace(*body.SeoTitle)
}
@@ -2791,6 +2923,12 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
if body.YouTubeVideoThumbnail != nil {
art.YouTubeVideoThumbnail = strings.TrimSpace(*body.YouTubeVideoThumbnail)
}
// Attachments
if len(body.Attachments) > 0 {
if b, err := json.Marshal(body.Attachments); err == nil {
art.Attachments = string(b)
}
}
// Auto-fill SEO if still empty after updates
if strings.TrimSpace(art.SEOTitle) == "" && strings.TrimSpace(art.Title) != "" {
@@ -2801,7 +2939,7 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
}
if err := bc.DB.Save(&art).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"})
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny", "error": err.Error()})
return
}
if art.ImageURL == "" {
@@ -3108,13 +3246,46 @@ func (bc *BaseController) CreatePlayer(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
return
}
// Auto-split full name if last name missing and first contains spaces; drop middle names
first := strings.TrimSpace(body.FirstName)
last := strings.TrimSpace(body.LastName)
if last == "" && strings.Contains(first, " ") {
parts := strings.Fields(first)
if len(parts) >= 2 {
first = parts[0]
last = parts[len(parts)-1]
}
}
if first == "" || last == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Jméno a příjmení jsou povinné"})
return
}
// Validate numeric limits
if body.JerseyNumber != nil {
if *body.JerseyNumber < 0 || *body.JerseyNumber > 99 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Číslo dresu musí být v rozmezí 099"})
return
}
}
if body.Height != nil {
if *body.Height < 50 || *body.Height > 250 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Výška musí být v rozmezí 50250 cm"})
return
}
}
if body.Weight != nil {
if *body.Weight < 30 || *body.Weight > 200 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Váha musí být v rozmezí 30200 kg"})
return
}
}
p := models.Player{
FirstName: strings.TrimSpace(body.FirstName),
LastName: strings.TrimSpace(body.LastName),
FirstName: first,
LastName: last,
Position: strings.TrimSpace(body.Position),
Nationality: strings.TrimSpace(body.Nationality),
Email: strings.TrimSpace(body.Email),
Phone: strings.TrimSpace(body.Phone),
Phone: normalizePhone(body.Phone, ""),
ImageURL: strings.TrimSpace(body.ImageURL),
}
if body.TeamID != nil {
@@ -3204,6 +3375,18 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
if body.LastName != nil {
p.LastName = strings.TrimSpace(*body.LastName)
}
// Auto-split if last name empty and first contains spaces; ensure both present
if strings.TrimSpace(p.LastName) == "" && strings.Contains(strings.TrimSpace(p.FirstName), " ") {
parts := strings.Fields(strings.TrimSpace(p.FirstName))
if len(parts) >= 2 {
p.FirstName = parts[0]
p.LastName = parts[len(parts)-1]
}
}
if strings.TrimSpace(p.FirstName) == "" || strings.TrimSpace(p.LastName) == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Jméno a příjmení jsou povinné"})
return
}
if body.Position != nil {
p.Position = strings.TrimSpace(*body.Position)
}
@@ -3220,15 +3403,27 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
p.TeamID = *body.TeamID
}
if body.JerseyNumber != nil {
if *body.JerseyNumber < 0 || *body.JerseyNumber > 99 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Číslo dresu musí být v rozmezí 099"})
return
}
p.JerseyNumber = *body.JerseyNumber
}
if body.Nationality != nil {
p.Nationality = strings.TrimSpace(*body.Nationality)
}
if body.Height != nil {
if *body.Height < 50 || *body.Height > 250 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Výška musí být v rozmezí 50250 cm"})
return
}
p.Height = *body.Height
}
if body.Weight != nil {
if *body.Weight < 30 || *body.Weight > 200 {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Váha musí být v rozmezí 30200 kg"})
return
}
p.Weight = *body.Weight
}
if body.IsActive != nil {
@@ -3238,7 +3433,7 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
p.Email = strings.TrimSpace(*body.Email)
}
if body.Phone != nil {
p.Phone = strings.TrimSpace(*body.Phone)
p.Phone = normalizePhone(*body.Phone, "")
}
if body.ImageURL != nil {
p.ImageURL = strings.TrimSpace(*body.ImageURL)
@@ -4067,7 +4262,7 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
@@ -4336,7 +4531,12 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
s.ContactCountry = strings.TrimSpace(*body.ContactCountry)
}
if body.ContactPhone != nil {
s.ContactPhone = strings.TrimSpace(*body.ContactPhone)
v := strings.TrimSpace(*body.ContactPhone)
country := s.ContactCountry
if body.ContactCountry != nil {
country = strings.TrimSpace(*body.ContactCountry)
}
s.ContactPhone = normalizePhone(v, country)
}
if body.ContactEmail != nil {
s.ContactEmail = strings.TrimSpace(*body.ContactEmail)
+90 -10
View File
@@ -1,6 +1,7 @@
package controllers
import (
"crypto/rand"
"fmt"
"net/http"
"net/url"
@@ -26,6 +27,32 @@ type ContactController struct {
emailService email.EmailService
}
// GetNewsletterTokenForUser returns a short-lived newsletter preferences token for the authenticated user's email
// GET /api/v1/newsletter/token/me (auth required)
func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
u, ok := c.Get("user")
if !ok || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
user := u.(*models.User)
email := strings.TrimSpace(strings.ToLower(user.Email))
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "User email not available"})
return
}
// Generate a 24h token for managing newsletter preferences
token, err := utils.GenerateSubscriberToken(email, 60*24)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
// SendNewsletterDigest builds and sends a digest newsletter based on a template type (admin only)
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
@@ -921,18 +948,71 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
return
}
// Generate a subscriber token to include in follow-up emails (preferences links)
token, _ := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// Auto-create fan user account if not exists
var existingUser models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&existingUser).Error; err == gorm.ErrRecordNotFound {
// Generate a strong random password (16 chars, mixed set)
genPassword := func(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz"
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
const digits = "0123456789"
const symbols = "!@#$%^&*()-_=+[]{}?"
pool := letters + upper + digits + symbols
b := make([]byte, n)
for i := 0; i < n; i++ {
var rb [1]byte
if _, err := rand.Read(rb[:]); err != nil {
b[i] = pool[(time.Now().UnixNano()+int64(i))%int64(len(pool))]
continue
}
b[i] = pool[int(rb[0])%len(pool)]
}
return string(b)
}
plain := genPassword(16)
hashed, hErr := utils.HashPassword(plain)
if hErr == nil {
u := models.User{
Email: strings.TrimSpace(strings.ToLower(subscription.Email)),
Password: hashed,
FirstName: "",
LastName: "",
Role: "fan",
IsActive: true,
}
if err := cc.DB.Create(&u).Error; err != nil {
logger.Error("Failed to auto-create fan user for newsletter: %v", err)
} else {
// Send credentials email
data := map[string]interface{}{
"Email": subscription.Email,
"Password": plain,
"LoginURL": baseFE + "/login",
"ResetURL": baseFE + "/forgot-password",
"ManageURL": setupURL,
"UnsubscribeURL": unsubscribeURL,
}
credEmail := &email.EmailData{
Subject: "Váš fan účet byl vytvořen",
To: []string{subscription.Email},
Template: "fan_account_created",
Data: data,
}
if err := cc.emailService.SendEmail(credEmail); err != nil {
logger.Error("Failed to send fan account created email: %v", err)
}
}
}
}
// Send setup email (link with token) AND welcome introduction email in goroutines
go func() {
// Generate token and build setup + unsubscribe URLs
token, tErr := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
if tErr != nil {
logger.Error("Failed to generate subscriber token: %v", tErr)
return
}
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// 1) Setup email
setupEmail := &email.EmailData{
Subject: "Nastavte svůj newsletter",
+10 -2
View File
@@ -357,14 +357,14 @@ func (pc *PollController) DeletePoll(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Poll deleted successfully"})
}
// Vote handles vote submission
func (pc *PollController) Vote(c *gin.Context) {
id := c.Param("id")
var input struct {
OptionIDs []uint `json:"option_ids" binding:"required,min=1"`
SessionToken string `json:"session_token"`
VoterName string `json:"voter_name"`
VoterEmail string `json:"voter_email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
@@ -412,6 +412,12 @@ func (pc *PollController) Vote(c *gin.Context) {
return
}
// If not authenticated, don't persist personal info even if provided
if !hasUser {
input.VoterName = ""
input.VoterEmail = ""
}
// Check if already voted
ipHash := pc.hashIP(c.ClientIP())
sessionToken := input.SessionToken
@@ -462,6 +468,8 @@ func (pc *PollController) Vote(c *gin.Context) {
IPHash: ipHash,
UserAgent: userAgent,
SessionToken: sessionToken,
VoterName: input.VoterName,
VoterEmail: input.VoterEmail,
}
if hasUser {
+1 -1
View File
@@ -14,7 +14,7 @@ type User struct {
Password string `gorm:"not null" json:"-"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `gorm:"default:editor" json:"role"` // admin, editor
Role string `gorm:"default:fan" json:"role"` // admin, editor, fan
IsActive bool `gorm:"default:true"`
LastLogin *time.Time `json:"last_login,omitempty"`
}
+2
View File
@@ -66,6 +66,8 @@ type PollVote struct {
IPHash string `gorm:"size:64;index" json:"ip_hash"` // Hashed IP for duplicate prevention
UserAgent string `gorm:"size:500" json:"user_agent"`
SessionToken string `gorm:"size:100;index" json:"session_token"` // For guest vote tracking
VoterName string `gorm:"size:150" json:"voter_name"`
VoterEmail string `gorm:"size:200" json:"voter_email"`
CreatedAt time.Time `json:"created_at"`
}
+12
View File
@@ -121,11 +121,18 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
protected := api.Group("")
protected.Use(middleware.JWTAuth(db))
{
// Newsletter preferences token for current user
protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser)
// User profile update
protected.PUT("/me", authController.UpdateCurrentUser)
// AI endpoints (protected)
ai := protected.Group("/ai")
{
ai.POST("/blog/generate", aiController.GenerateBlog)
ai.POST("/about/generate", aiController.GenerateAboutPage)
ai.POST("/css/generate", aiController.GenerateCSS)
}
// User profile
@@ -137,6 +144,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Events (protected)
protectedEvents := protected.Group("/events")
protectedEvents.Use(middleware.RoleAuth("editor"))
{
protectedEvents.POST("", eventController.CreateEvent)
protectedEvents.PUT("/:id", eventController.UpdateEvent)
@@ -145,6 +153,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Articles (protected - accessible by editors and admins)
articles := protected.Group("/articles")
articles.Use(middleware.RoleAuth("editor"))
{
articles.POST("", articleController.CreateArticle)
articles.PUT("/:id", baseController.UpdateArticle)
@@ -156,6 +165,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Teams (protected)
teams := protected.Group("/teams")
teams.Use(middleware.RoleAuth("admin"))
{
teams.POST("", baseController.CreateTeam)
teams.PUT("/:id", baseController.UpdateTeam)
@@ -164,6 +174,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Players (protected)
players := protected.Group("/players")
players.Use(middleware.RoleAuth("admin"))
{
players.POST("", baseController.CreatePlayer)
players.PUT("/:id", baseController.UpdatePlayer)
@@ -172,6 +183,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Sponsors (protected CRUD)
sponsors := protected.Group("/sponsors")
sponsors.Use(middleware.RoleAuth("admin"))
{
sponsors.POST("", baseController.CreateSponsor)
sponsors.PUT("/:id", baseController.UpdateSponsor)
+3
View File
@@ -136,6 +136,9 @@ func MigrateDB(db *gorm.DB) error {
&models.CompetitionAlias{},
&models.EmailLog{},
&models.EmailEvent{},
&models.NewsletterSentLog{},
&models.MatchNotification{},
&models.BlogNotification{},
&models.PasswordReset{},
&models.AboutPage{},
// Add event tables so public endpoints don't fail before any writes occur
+32
View File
@@ -0,0 +1,32 @@
{{define "content"}}
<div style="max-width:600px;margin:0 auto;font-family:Arial, Helvetica, sans-serif;color:#2d3748;background:#ffffff;">
<div style="background: linear-gradient(135deg, {{.PrimaryColor}} 0%, {{.AccentColor}} 100%); padding: 24px 20px; text-align:center; border-radius: 8px 8px 0 0;">
<h1 style="color:#fff; margin:0; font-size:24px; font-weight:700;">Váš fan účet byl vytvořen</h1>
</div>
<div style="padding:24px; border:1px solid #e2e8f0; border-top:none; border-radius: 0 0 8px 8px;">
<p style="margin:0 0 16px 0; line-height:1.6;">
Děkujeme za přihlášení k odběru novinek. Současně jsme pro vás vytvořili fanouškovský účet, se kterým budete mít v budoucnu více možností nastavení.
</p>
<div style="background:#f7fafc; border-left:4px solid {{.AccentColor}}; padding:12px 16px; border-radius:0 6px 6px 0; margin:16px 0;">
<p style="margin:0 0 6px 0;">Přihlašovací údaje:</p>
<p style="margin:0;">
<strong>Email:</strong> {{.Email}}<br/>
<strong>Heslo:</strong> {{.Password}}
</p>
</div>
<p style="margin:0 0 16px 0;">Po přihlášení si můžete heslo kdykoliv změnit a upravit osobní údaje.</p>
<div style="text-align:center; margin:20px 0;">
<a href="{{.LoginURL}}" class="btn" style="display:inline-block; padding:12px 24px; background: linear-gradient(135deg, {{.AccentColor}} 0%, {{.PrimaryColor}} 100%); color:#fff !important; text-decoration:none; border-radius:6px; font-weight:600;">Přihlásit se</a>
<a href="{{.ResetURL}}" class="btn btn-secondary" style="display:inline-block; padding:12px 24px; margin-left:8px; text-decoration:none; border-radius:6px; border:1px solid #e2e8f0; color:#2d3748;">Změnit heslo</a>
</div>
<hr style="border:none; border-top:1px solid #e2e8f0; margin:20px 0;"/>
<p style="margin:0 0 10px 0;">Správa newsletteru:</p>
<div style="text-align:center;">
<a href="{{.ManageURL}}" class="btn" style="display:inline-block; padding:10px 20px; background: linear-gradient(135deg, {{.PrimaryColor}} 0%, {{.AccentColor}} 100%); color:#fff !important; text-decoration:none; border-radius:6px; font-weight:600;">Upravit předvolby</a>
{{if .UnsubscribeURL}}
<div style="margin-top:8px; font-size:13px;"><a href="{{.UnsubscribeURL}}" style="color:#2b6cb0;">Odhlásit odběr</a></div>
{{end}}
</div>
</div>
</div>
{{end}}