mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #69
This commit is contained in:
Vendored
+39
@@ -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
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||||
Vendored
+1
-1
@@ -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
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:45Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:13Z","last_modified":""}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:50Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:16Z","last_modified":""}
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"lastUpdated":"2025-10-21T12:50:50Z"}
|
{"lastUpdated":"2025-10-23T20:00:16Z"}
|
||||||
Vendored
+7
-7
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"baseURL": "http://127.0.0.1:8080/api/v1",
|
"baseURL": "http://127.0.0.1:8080/api/v1",
|
||||||
"duration_ms": 7283,
|
"duration_ms": 5571,
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"path": "/public/team-logo-overrides",
|
||||||
|
"file": "team_logo_overrides.json",
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "/competition-aliases",
|
"path": "/competition-aliases",
|
||||||
"file": "competition_aliases.json",
|
"file": "competition_aliases.json",
|
||||||
@@ -27,11 +32,6 @@
|
|||||||
"file": "events_upcoming.json",
|
"file": "events_upcoming.json",
|
||||||
"ok": true
|
"ok": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "/public/team-logo-overrides",
|
|
||||||
"file": "team_logo_overrides.json",
|
|
||||||
"ok": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
|
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
|
||||||
"file": "facr_club_info.json",
|
"file": "facr_club_info.json",
|
||||||
@@ -43,5 +43,5 @@
|
|||||||
"ok": true
|
"ok": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2025-10-21T12:50:50Z"
|
"lastUpdated": "2025-10-23T20:00:16Z"
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -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"}
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"etag":"","fetched_at":"2025-10-21T12:50:43Z","last_modified":""}
|
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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"}
|
||||||
Vendored
+20
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+10
-10
@@ -7,7 +7,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "",
|
"id": "",
|
||||||
@@ -97,6 +97,6 @@
|
|||||||
"photos_count": 0,
|
"photos_count": 0,
|
||||||
"views_count": 0,
|
"views_count": 0,
|
||||||
"photos": null,
|
"photos": null,
|
||||||
"fetched_at": "2025-10-21T12:51:05Z"
|
"fetched_at": "2025-10-23T14:00:46Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"fetched_at": "2025-10-21T12:51:05Z",
|
"fetched_at": "2025-10-23T14:00:46Z",
|
||||||
"link": ""
|
"link": ""
|
||||||
}
|
}
|
||||||
Vendored
+225
-225
@@ -103,7 +103,7 @@
|
|||||||
"photos_count": 75,
|
"photos_count": 75,
|
||||||
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
|
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
|
||||||
"views_count": 24
|
"views_count": 50
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "12. 10. 2025",
|
"date": "12. 10. 2025",
|
||||||
@@ -208,112 +208,7 @@
|
|||||||
"photos_count": 112,
|
"photos_count": 112,
|
||||||
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
|
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
|
||||||
"views_count": 101
|
"views_count": 113
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "11. 10. 2025",
|
"date": "11. 10. 2025",
|
||||||
@@ -418,7 +313,112 @@
|
|||||||
"photos_count": 19,
|
"photos_count": 19,
|
||||||
"title": "Kategorie U14 Havířov 6:3 FK Krnov",
|
"title": "Kategorie U14 Havířov 6:3 FK Krnov",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14006754",
|
"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",
|
"date": "4. 10. 2025",
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
"photos_count": 79,
|
"photos_count": 79,
|
||||||
"title": "Kategorie U15 FK Krnov 0:1 Hlučín",
|
"title": "Kategorie U15 FK Krnov 0:1 Hlučín",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967265",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967265",
|
||||||
"views_count": 119
|
"views_count": 126
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "4. 10. 2025",
|
"date": "4. 10. 2025",
|
||||||
@@ -618,7 +618,7 @@
|
|||||||
"photos_count": 50,
|
"photos_count": 50,
|
||||||
"title": "Kategorie U14 FK Krnov 0:6 Hlučín",
|
"title": "Kategorie U14 FK Krnov 0:6 Hlučín",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967247",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967247",
|
||||||
"views_count": 141
|
"views_count": 149
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "28. 9. 2025",
|
"date": "28. 9. 2025",
|
||||||
@@ -728,7 +728,117 @@
|
|||||||
"photos_count": 65,
|
"photos_count": 65,
|
||||||
"title": "Kategorie muži FK Krnov 2:3 TJ Sokol Háj ve Slezsku",
|
"title": "Kategorie muži FK Krnov 2:3 TJ Sokol Háj ve Slezsku",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13939668",
|
"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",
|
"date": "20. 9. 2025",
|
||||||
@@ -843,117 +953,7 @@
|
|||||||
"photos_count": 55,
|
"photos_count": 55,
|
||||||
"title": "Kategorie U14 FK Krnov 1:12 Nový Jičín",
|
"title": "Kategorie U14 FK Krnov 1:12 Nový Jičín",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903599",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903599",
|
||||||
"views_count": 126
|
"views_count": 133
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "17. 9. 2025",
|
"date": "17. 9. 2025",
|
||||||
@@ -1063,9 +1063,9 @@
|
|||||||
"photos_count": 55,
|
"photos_count": 55,
|
||||||
"title": "Kategorie U15 Třinec 1:4 FK Krnov",
|
"title": "Kategorie U15 Třinec 1:4 FK Krnov",
|
||||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13883373",
|
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13883373",
|
||||||
"views_count": 116
|
"views_count": 120
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fetched_at": "2025-10-21T12:51:05Z",
|
"fetched_at": "2025-10-23T14:00:46Z",
|
||||||
"input_link": "https://eu.zonerama.com/FKKofolaKrnov"
|
"input_link": "https://eu.zonerama.com/FKKofolaKrnov/1470757"
|
||||||
}
|
}
|
||||||
@@ -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
@@ -5,6 +5,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-
|
|||||||
import './styles/custom-scrollbar.css';
|
import './styles/custom-scrollbar.css';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import AuthPage from './pages/AuthPage';
|
import AuthPage from './pages/AuthPage';
|
||||||
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import ArticlesListPage from './pages/ArticlesListPage';
|
import ArticlesListPage from './pages/ArticlesListPage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
@@ -50,6 +51,7 @@ import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
|
|||||||
import FilesAdminPage from './pages/admin/FilesAdminPage';
|
import FilesAdminPage from './pages/admin/FilesAdminPage';
|
||||||
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
|
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
|
||||||
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
|
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
|
||||||
|
import SemiAdminPage from './pages/SemiAdminPage';
|
||||||
import PollsAdminPage from './pages/admin/PollsAdminPage';
|
import PollsAdminPage from './pages/admin/PollsAdminPage';
|
||||||
// Admin pages render their own AdminLayout internally
|
// Admin pages render their own AdminLayout internally
|
||||||
import SetupPage from './pages/SetupPage';
|
import SetupPage from './pages/SetupPage';
|
||||||
@@ -261,7 +263,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Public Route component - redirects to admin if already authenticated
|
// Public Route component - redirects to admin if already authenticated
|
||||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading, user } = useAuth();
|
||||||
const [checkingSetup, setCheckingSetup] = useState(true);
|
const [checkingSetup, setCheckingSetup] = useState(true);
|
||||||
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
|
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -285,6 +287,10 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
const role = user?.role;
|
||||||
|
if (role === 'fan') {
|
||||||
|
return <Navigate to="/semiadmin" replace />;
|
||||||
|
}
|
||||||
return <Navigate to="/admin" replace />;
|
return <Navigate to="/admin" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,10 +380,26 @@ const App: React.FC = () => {
|
|||||||
</PublicRoute>
|
</PublicRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/register"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<RegisterPage />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
|
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
|
||||||
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
|
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/semiadmin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SemiAdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/403" element={<ForbiddenPage />} />
|
<Route path="/403" element={<ForbiddenPage />} />
|
||||||
|
|
||||||
{/* Admin area (pages include AdminLayout themselves) */}
|
{/* Admin area (pages include AdminLayout themselves) */}
|
||||||
@@ -388,15 +410,14 @@ const App: React.FC = () => {
|
|||||||
}>
|
}>
|
||||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
<Route path="/admin" element={<AdminDashboardPage />} />
|
||||||
<Route path="/admin/docs" element={<AdminDocsPage />} />
|
<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/o-klubu" element={<AboutAdminPage />} />
|
||||||
<Route path="/admin/videa" element={<AdminVideosPage />} />
|
<Route path="/admin/videa" element={<AdminVideosPage />} />
|
||||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||||
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
||||||
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
|
|
||||||
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
||||||
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
|
<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/zapasy" element={<MatchesAdminPage />} />
|
||||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
||||||
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
||||||
@@ -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 */}
|
{/* Not found route */}
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
InputGroup,
|
InputGroup,
|
||||||
InputLeftElement,
|
InputLeftElement,
|
||||||
Input,
|
Input,
|
||||||
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||||
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
|
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 { getArticles } from '../services/articles';
|
||||||
import { getCachedYouTube } from '../services/youtube';
|
import { getCachedYouTube } from '../services/youtube';
|
||||||
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
|
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
|
||||||
|
import { getMyNewsletterToken } from '../services/public/newsletter';
|
||||||
|
|
||||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
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>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||||
const { colorMode, toggleColorMode } = useColorMode();
|
const { colorMode, toggleColorMode } = useColorMode();
|
||||||
const { isAuthenticated, logout, user } = useAuth();
|
const { isAuthenticated, logout, user } = useAuth();
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
@@ -246,12 +248,14 @@ const Navbar = () => {
|
|||||||
const theme = useClubTheme();
|
const theme = useClubTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
const menuBg = useColorModeValue('white', '#0f1115');
|
const menuBg = useColorModeValue('white', '#0f1115');
|
||||||
const dividerColor = useColorModeValue('gray.600', 'gray.300');
|
const dividerColor = useColorModeValue('gray.600', 'gray.300');
|
||||||
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
|
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
|
||||||
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
|
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
|
||||||
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
|
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
|
||||||
const navTextColor = useColorModeValue('gray.700', 'gray.200');
|
const navTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||||
|
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [hasTables, setHasTables] = useState<boolean | null>(null);
|
const [hasTables, setHasTables] = useState<boolean | null>(null);
|
||||||
const [hasActivities, setHasActivities] = 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 [hasGallery, setHasGallery] = useState<boolean | null>(null);
|
||||||
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
|
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
|
||||||
const [navLoading, setNavLoading] = useState(true);
|
const [navLoading, setNavLoading] = useState(true);
|
||||||
|
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
|
||||||
|
|
||||||
// Search modal state
|
// Search modal state
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -279,6 +284,21 @@ const Navbar = () => {
|
|||||||
return () => window.removeEventListener('scroll', onScroll as any);
|
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 e‑mailové preference. Zkuste to prosím znovu.',
|
||||||
|
status: 'error',
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Also set document title to club name ASAP (SEO component will refine further)
|
// Also set document title to club name ASAP (SEO component will refine further)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const name = settings?.club_name || theme.name;
|
const name = settings?.club_name || theme.name;
|
||||||
@@ -607,8 +627,8 @@ const Navbar = () => {
|
|||||||
<Box position="sticky" top={0} zIndex={1000}>
|
<Box position="sticky" top={0} zIndex={1000}>
|
||||||
{/* Top bar with socials and quick external links */}
|
{/* Top bar with socials and quick external links */}
|
||||||
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
|
{(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}>
|
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
|
||||||
<Container maxW="7xl">
|
<Container maxW={containerMaxW}>
|
||||||
<Flex align="center" justify="space-between" gap={2}>
|
<Flex align="center" justify="space-between" gap={2}>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
{settings?.shop_url && (
|
{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"
|
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} />
|
<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">
|
<Flex h={16} alignItems="center" justifyContent="space-between">
|
||||||
<HStack spacing={4} alignItems="center">
|
<HStack spacing={4} alignItems="center">
|
||||||
{/* Club Logo only */}
|
{/* Club Logo only */}
|
||||||
@@ -768,6 +788,8 @@ const Navbar = () => {
|
|||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList>
|
<MenuList>
|
||||||
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
|
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
|
||||||
|
<MenuItem onClick={openMyNewsletterPrefs}>E‑mailové preference</MenuItem>
|
||||||
|
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
|
||||||
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
|
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
|
||||||
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
|
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
|
||||||
</MenuList>
|
</MenuList>
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
|||||||
>
|
>
|
||||||
<option value="single">Jedna odpověď</option>
|
<option value="single">Jedna odpověď</option>
|
||||||
<option value="multiple">Více odpovědí</option>
|
<option value="multiple">Více odpovědí</option>
|
||||||
|
<option value="rating">Hodnocení</option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Image as ChakraImage } from '@chakra-ui/react';
|
import { Image as ChakraImage } from '@chakra-ui/react';
|
||||||
import { cropAndUpload, quickEditImage } from '../../services/imageProcessing';
|
import { cropAndUpload, quickEditImage } from '../../services/imageProcessing';
|
||||||
|
import { assetUrl } from '../../utils/url';
|
||||||
|
|
||||||
interface ImageFilters {
|
interface ImageFilters {
|
||||||
brightness: number;
|
brightness: number;
|
||||||
@@ -245,28 +246,36 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate crop data in pixels
|
// Calculate crop data in natural image pixels (backend expects absolute pixels)
|
||||||
const img = imgRef.current;
|
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;
|
let cropData = undefined;
|
||||||
if (crop.width && crop.height && crop.width > 0 && crop.height > 0) {
|
if (crop.width && crop.height && crop.width > 0 && crop.height > 0) {
|
||||||
const cropPx = {
|
// Convert selection from displayed coordinates to natural pixel coordinates
|
||||||
x: Math.round(Math.max(0, percToPx(crop.x || 0, img.width))),
|
const dispX = Math.max(0, toDisplayPx(crop.x || 0, displayW));
|
||||||
y: Math.round(Math.max(0, percToPx(crop.y || 0, img.height))),
|
const dispY = Math.max(0, toDisplayPx(crop.y || 0, displayH));
|
||||||
width: Math.round(Math.min(img.width, percToPx(crop.width || img.width, img.width))),
|
const dispW = Math.min(displayW, toDisplayPx(crop.width || displayW, displayW));
|
||||||
height: Math.round(Math.min(img.height, percToPx(crop.height || img.height, img.height))),
|
const dispH = Math.min(displayH, toDisplayPx(crop.height || displayH, displayH));
|
||||||
};
|
|
||||||
|
let natX = Math.round(dispX * scaleX);
|
||||||
// Adjust crop to fit image bounds
|
let natY = Math.round(dispY * scaleY);
|
||||||
if (cropPx.x + cropPx.width > img.width) {
|
let natW = Math.round(dispW * scaleX);
|
||||||
cropPx.width = img.width - cropPx.x;
|
let natH = Math.round(dispH * scaleY);
|
||||||
}
|
|
||||||
if (cropPx.y + cropPx.height > img.height) {
|
// Clamp within natural bounds
|
||||||
cropPx.height = img.height - cropPx.y;
|
if (natX + natW > naturalW) natW = naturalW - natX;
|
||||||
}
|
if (natY + natH > naturalH) natH = naturalH - natY;
|
||||||
|
natW = Math.max(1, natW);
|
||||||
cropData = cropPx;
|
natH = Math.max(1, natH);
|
||||||
|
|
||||||
|
cropData = { x: natX, y: natY, width: natW, height: natH };
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({ title: 'Zpracování obrázku...', status: 'info', duration: 2000 });
|
toast({ title: 'Zpracování obrázku...', status: 'info', duration: 2000 });
|
||||||
@@ -290,16 +299,25 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const range = quill.getSelection();
|
const range = quill.getSelection();
|
||||||
const index = range ? range.index : quill.getLength();
|
const index = range ? range.index : quill.getLength();
|
||||||
|
|
||||||
// Insert the image
|
const absoluteUrl = assetUrl(res.url) || res.url;
|
||||||
quill.insertEmbed(index, 'image', res.url, 'api');
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
// Move cursor after the image
|
try {
|
||||||
quill.setSelection(index + 1, 0, 'api');
|
quill.insertEmbed(index, 'image', absoluteUrl, 'api');
|
||||||
|
// Move cursor after the image
|
||||||
// Force content change to trigger re-render
|
quill.setSelection(index + 1, 0, 'api');
|
||||||
onChangeRef.current(quill.root.innerHTML);
|
// Force content change to trigger re-render
|
||||||
|
onChangeRef.current(quill.root.innerHTML);
|
||||||
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
|
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) {
|
} catch (embedError) {
|
||||||
console.error('Error inserting image:', embedError);
|
console.error('Error inserting image:', embedError);
|
||||||
toast({ title: 'Chyba při vkládání obrázku', description: String(embedError), status: 'error' });
|
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
|
// Delete selected image on Delete key
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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')) {
|
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedImage.remove();
|
selectedImage.remove();
|
||||||
@@ -754,6 +778,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
editor.root.addEventListener('scroll', handleScroll);
|
editor.root.addEventListener('scroll', handleScroll);
|
||||||
editor.root.addEventListener('dragstart', handleDragStart);
|
editor.root.addEventListener('dragstart', handleDragStart);
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
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 () => {
|
return () => {
|
||||||
editor.root.removeEventListener('click', handleImageClick);
|
editor.root.removeEventListener('click', handleImageClick);
|
||||||
@@ -761,10 +788,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
editor.root.removeEventListener('scroll', handleScroll);
|
editor.root.removeEventListener('scroll', handleScroll);
|
||||||
editor.root.removeEventListener('dragstart', handleDragStart);
|
editor.root.removeEventListener('dragstart', handleDragStart);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('resize', handleScroll);
|
||||||
|
document.removeEventListener('scroll', handleScroll, true);
|
||||||
removeResizeHandle();
|
removeResizeHandle();
|
||||||
deselectImage();
|
deselectImage();
|
||||||
};
|
};
|
||||||
}, [readOnly, toast]);
|
}, [readOnly, toast, isMounted]);
|
||||||
|
|
||||||
// Apply filters to selected image
|
// Apply filters to selected image
|
||||||
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
|
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
|
||||||
@@ -850,7 +879,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Replace image src
|
// 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
|
// Reset filters to default since they're now baked into the image
|
||||||
setImageFilters({
|
setImageFilters({
|
||||||
@@ -873,6 +903,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const editor = quillRef.current?.getEditor();
|
const editor = quillRef.current?.getEditor();
|
||||||
if (editor) {
|
if (editor) {
|
||||||
onChangeRef.current(editor.root.innerHTML);
|
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 });
|
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
|
||||||
@@ -904,6 +936,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const editor = quillRef.current?.getEditor();
|
const editor = quillRef.current?.getEditor();
|
||||||
if (editor) {
|
if (editor) {
|
||||||
onChangeRef.current(editor.root.innerHTML);
|
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 });
|
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());
|
setManualWidth(finalWidth.toString());
|
||||||
if (editor) {
|
if (editor) {
|
||||||
onChangeRef.current(editor.root.innerHTML);
|
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 });
|
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
|
||||||
} else {
|
} else {
|
||||||
@@ -1218,9 +1254,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
maxH="80vh"
|
maxH="80vh"
|
||||||
overflowY="auto"
|
overflowY="auto"
|
||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
onClick={(e) => { e.stopPropagation(); }}
|
||||||
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
onMouseDown={(e) => { e.stopPropagation(); }}
|
||||||
onMouseUp={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
onMouseUp={(e) => { e.stopPropagation(); }}
|
||||||
css={{
|
css={{
|
||||||
'&::-webkit-scrollbar': {
|
'&::-webkit-scrollbar': {
|
||||||
width: '6px',
|
width: '6px',
|
||||||
@@ -1302,7 +1338,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
type="number"
|
type="number"
|
||||||
value={manualWidth}
|
value={manualWidth}
|
||||||
onChange={(e) => setManualWidth(e.target.value)}
|
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"
|
placeholder="Šířka v px"
|
||||||
min={50}
|
min={50}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
Link as ChakraLink,
|
Link as ChakraLink,
|
||||||
Modal,
|
|
||||||
ModalOverlay,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalFooter,
|
|
||||||
Text,
|
Text,
|
||||||
useDisclosure,
|
|
||||||
Image,
|
|
||||||
VStack,
|
VStack,
|
||||||
Badge,
|
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
AspectRatio,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
FiDownload,
|
FiExternalLink,
|
||||||
FiEye,
|
|
||||||
FiFile,
|
FiFile,
|
||||||
FiFileText,
|
FiFileText,
|
||||||
FiImage,
|
FiImage,
|
||||||
@@ -44,10 +31,7 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
|||||||
name,
|
name,
|
||||||
mimeType = '',
|
mimeType = '',
|
||||||
size,
|
size,
|
||||||
showInline = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
||||||
const [imageError, setImageError] = useState(false);
|
|
||||||
|
|
||||||
const fullUrl = assetUrl(url) || url;
|
const fullUrl = assetUrl(url) || url;
|
||||||
const fileName = name || url.split('/').pop() || 'file';
|
const fileName = name || url.split('/').pop() || 'file';
|
||||||
@@ -56,7 +40,6 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
|||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const mutedText = useColorModeValue('gray.600', 'gray.300');
|
const mutedText = useColorModeValue('gray.600', 'gray.300');
|
||||||
const linkColor = useColorModeValue('blue.600', 'blue.300');
|
|
||||||
|
|
||||||
// Determine file type and icon
|
// Determine file type and icon
|
||||||
const getFileInfo = () => {
|
const getFileInfo = () => {
|
||||||
@@ -89,297 +72,42 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
|||||||
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
|
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
|
||||||
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
|
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
|
||||||
|
|
||||||
// Render preview content based on file type
|
// Simplified preview: only provide an "Open in new window" action
|
||||||
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
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HStack
|
||||||
<HStack
|
justify="space-between"
|
||||||
justify="space-between"
|
p={3}
|
||||||
p={3}
|
borderWidth="1px"
|
||||||
borderWidth="1px"
|
borderColor={borderColor}
|
||||||
borderColor={borderColor}
|
borderRadius="md"
|
||||||
borderRadius="md"
|
bg={cardBg}
|
||||||
bg={cardBg}
|
>
|
||||||
>
|
<HStack flex={1} minW={0}>
|
||||||
<HStack flex={1} minW={0}>
|
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
|
||||||
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
|
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||||
<VStack align="start" spacing={0} flex={1} minW={0}>
|
<Text
|
||||||
<ChakraLink
|
fontWeight="medium"
|
||||||
href={fullUrl}
|
isTruncated
|
||||||
isExternal
|
maxW="100%"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Stáhnout
|
{fileName}
|
||||||
</Button>
|
</Text>
|
||||||
</HStack>
|
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
|
||||||
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
<HStack spacing={2} flexShrink={0}>
|
||||||
{/* Preview Modal */}
|
<Button
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
as={ChakraLink}
|
||||||
<ModalOverlay bg="blackAlpha.800" />
|
href={fullUrl}
|
||||||
<ModalContent maxW="90vw" maxH="90vh">
|
isExternal
|
||||||
<ModalHeader>
|
size="sm"
|
||||||
<HStack justify="space-between">
|
leftIcon={<FiExternalLink />}
|
||||||
<VStack align="start" spacing={0}>
|
colorScheme="blue"
|
||||||
<Text>{fileName}</Text>
|
>
|
||||||
{sizeStr && <Text fontSize="sm" fontWeight="normal" color={mutedText}>{sizeStr}</Text>}
|
Otevřít v novém okně
|
||||||
</VStack>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</ModalHeader>
|
</HStack>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
|
|||||||
};
|
};
|
||||||
|
|
||||||
return links[element] || [
|
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>
|
<HStack>
|
||||||
<Icon as={FiExternalLink} color="blue.500" />
|
<Icon as={FiExternalLink} color="blue.500" />
|
||||||
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
|
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
|
||||||
Quick Admin Links
|
Rychlé odkazy administrace
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text fontSize="xs" color="gray.500" textAlign="center">
|
<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>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -18,23 +18,32 @@ import {
|
|||||||
AlertIcon,
|
AlertIcon,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
Divider,
|
Divider,
|
||||||
|
Spinner,
|
||||||
} from '@chakra-ui/react';
|
} 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 {
|
interface CustomCSSEditorProps {
|
||||||
elementName: string;
|
elementName: string;
|
||||||
onCSSChange: (css: string) => void;
|
onCSSChange: (css: string) => void;
|
||||||
currentCSS?: string;
|
currentCSS?: string;
|
||||||
|
currentStyles?: Record<string, any>;
|
||||||
|
theme?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
|
const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
|
||||||
elementName,
|
elementName,
|
||||||
onCSSChange,
|
onCSSChange,
|
||||||
currentCSS = '',
|
currentCSS = '',
|
||||||
|
currentStyles = {},
|
||||||
|
theme = {},
|
||||||
}) => {
|
}) => {
|
||||||
const [css, setCSS] = useState(currentCSS);
|
const [css, setCSS] = useState(currentCSS);
|
||||||
const [isValid, setIsValid] = useState(true);
|
const [isValid, setIsValid] = useState(true);
|
||||||
const [preview, setPreview] = useState(false);
|
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 toast = useToast();
|
||||||
const bgColor = useColorModeValue('white', 'gray.800');
|
const bgColor = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
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) => {
|
const handleCSSChange = (value: string) => {
|
||||||
setCSS(value);
|
setCSS(value);
|
||||||
const valid = validateCSS(value);
|
const valid = validateCSS(value);
|
||||||
@@ -79,11 +166,29 @@ const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
|
|||||||
// Create new style element
|
// Create new style element
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = `custom-css-${elementName}`;
|
style.id = `custom-css-${elementName}`;
|
||||||
style.textContent = `
|
// If the CSS contains braces or at-rules, assume full CSS block already scoped
|
||||||
[data-element="${elementName}"] {
|
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(cssString);
|
||||||
${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);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -166,6 +271,12 @@ overflow: hidden;`,
|
|||||||
<Text>Examples</Text>
|
<Text>Examples</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<FiZap />
|
||||||
|
<Text>AI (beta)</Text>
|
||||||
|
</HStack>
|
||||||
|
</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
@@ -288,6 +399,88 @@ border-radius: 10px;`}
|
|||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</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>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -150,11 +150,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
const [viewport] = useState<'desktop'>('desktop');
|
const [viewport] = useState<'desktop'>('desktop');
|
||||||
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
|
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
|
||||||
const [showStylePanel, setShowStylePanel] = useState(true);
|
const [showStylePanel, setShowStylePanel] = useState(true);
|
||||||
|
const [stylePanelRight, setStylePanelRight] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
const [showHelpHint, setShowHelpHint] = useState(true);
|
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 overlayRef = useRef<HTMLDivElement>(null);
|
||||||
const isReorderingRef = useRef(false);
|
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
|
// Draggable panel states
|
||||||
const [panelPositions, setPanelPositions] = useState({
|
const [panelPositions, setPanelPositions] = useState({
|
||||||
@@ -322,6 +329,83 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Auto-dismiss help hint after 5 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing && showHelpHint) {
|
if (isEditing && showHelpHint) {
|
||||||
@@ -342,6 +426,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
...cfg,
|
...cfg,
|
||||||
variant: normalizeVariant(cfg.element_name, cfg.variant)
|
variant: normalizeVariant(cfg.element_name, cfg.variant)
|
||||||
}));
|
}));
|
||||||
|
// Load saved custom CSS from settings
|
||||||
|
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);
|
setConfigs(sanitizedConfigs);
|
||||||
const changes: Record<string, string> = {};
|
const changes: Record<string, string> = {};
|
||||||
const visible = new Set<string>();
|
const visible = new Set<string>();
|
||||||
@@ -358,10 +448,45 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
setLocalChanges(changes);
|
setLocalChanges(changes);
|
||||||
setVisibleElements(visible);
|
setVisibleElements(visible);
|
||||||
setElementOrder(order);
|
setElementOrder(order);
|
||||||
|
// Prime style state with saved custom CSS
|
||||||
|
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) {
|
if (data.length === 0) {
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
|
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load page element configs:', error);
|
console.error('Failed to load page element configs:', error);
|
||||||
@@ -385,12 +510,48 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
setVisibleElements(visible);
|
setVisibleElements(visible);
|
||||||
setElementOrder(order);
|
setElementOrder(order);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
|
// Treat fallback like unsaved defaults so counter encourages save
|
||||||
|
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
}, [pageType, normalizeVariant]);
|
}, [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
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) return;
|
if (!isEditing) return;
|
||||||
@@ -433,6 +594,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
}, [isEditing, selectedElement, showElementPicker, showLayersPanel, hasChanges]);
|
}, [isEditing, selectedElement, showElementPicker, showLayersPanel, hasChanges]);
|
||||||
|
|
||||||
// Add element highlighting and click handlers when editing
|
// Add element highlighting and click handlers when editing
|
||||||
|
// Also re-run when order/visibility changes so overlays are added for newly shown elements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
// Clean up overlays when exiting edit mode
|
// Clean up overlays when exiting edit mode
|
||||||
@@ -466,6 +628,8 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
cursor: move;
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const badge = document.createElement('div');
|
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.onmouseover = () => deleteBtn.style.transform = 'scale(1.1)';
|
||||||
deleteBtn.onmouseout = () => deleteBtn.style.transform = 'scale(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
|
// Use safeDOM to build overlay structure
|
||||||
safeDOM.appendChild(actionsBar, editBtn);
|
safeDOM.appendChild(actionsBar, editBtn);
|
||||||
safeDOM.appendChild(actionsBar, moveUpBtn);
|
safeDOM.appendChild(actionsBar, moveUpBtn);
|
||||||
safeDOM.appendChild(actionsBar, moveDownBtn);
|
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(actionsBar, deleteBtn);
|
||||||
|
|
||||||
safeDOM.appendChild(overlay, badge);
|
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
|
// Make overlay draggable
|
||||||
overlay.draggable = true;
|
overlay.draggable = true;
|
||||||
overlay.addEventListener('dragstart', (e) => {
|
overlay.addEventListener('dragstart', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
try { (e as DragEvent).dataTransfer?.setData('text/plain', elementName); } catch {}
|
||||||
setDraggedElement(elementName);
|
setDraggedElement(elementName);
|
||||||
overlay.style.opacity = '0.5';
|
overlay.style.opacity = '0.5';
|
||||||
});
|
});
|
||||||
@@ -669,6 +912,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
overlay.addEventListener('dragover', (e) => {
|
overlay.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
try { (e as DragEvent).dataTransfer!.dropEffect = 'move'; } catch {}
|
||||||
if (draggedElement && draggedElement !== elementName) {
|
if (draggedElement && draggedElement !== elementName) {
|
||||||
overlay.style.border = `3px solid ${secondaryColor}`;
|
overlay.style.border = `3px solid ${secondaryColor}`;
|
||||||
setDragOverElement(elementName);
|
setDragOverElement(elementName);
|
||||||
@@ -688,13 +932,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (draggedElement && draggedElement !== elementName) {
|
if (draggedElement && draggedElement !== elementName) {
|
||||||
// Reorder elements
|
// Reorder elements
|
||||||
const newOrder = [...elementOrder];
|
const newOrder = [...elementOrderRef.current];
|
||||||
const draggedIndex = newOrder.indexOf(draggedElement);
|
const draggedIndex = newOrder.indexOf(draggedElement as string);
|
||||||
const targetIndex = newOrder.indexOf(elementName);
|
const targetIndex = newOrder.indexOf(elementName);
|
||||||
|
|
||||||
if (draggedIndex !== -1 && targetIndex !== -1) {
|
if (draggedIndex !== -1 && targetIndex !== -1) {
|
||||||
newOrder.splice(draggedIndex, 1);
|
newOrder.splice(draggedIndex, 1);
|
||||||
newOrder.splice(targetIndex, 0, draggedElement);
|
newOrder.splice(targetIndex, 0, draggedElement as string);
|
||||||
setElementOrder(newOrder);
|
setElementOrder(newOrder);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
applyVisualReorder(newOrder);
|
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
|
// Add overlays for all present [data-element] nodes in DOM (dynamic)
|
||||||
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
|
try {
|
||||||
implementedElements.forEach((elementName) => {
|
const nodes = Array.from(safeDOM.querySelectorAll('[data-element]')) as HTMLElement[];
|
||||||
if (ELEMENT_VARIANTS[elementName]) {
|
const names = Array.from(new Set(nodes
|
||||||
addOverlay(elementName);
|
.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
|
// Close panel on escape
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
@@ -753,7 +1007,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
clearTimeout(debounceTimerRef.current);
|
clearTimeout(debounceTimerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isEditing, selectedElement, pageType]);
|
}, [isEditing, selectedElement, pageType, elementOrder, visibleElements]);
|
||||||
|
|
||||||
// Update selected element overlay styling
|
// Update selected element overlay styling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -848,6 +1102,71 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
applyChange();
|
applyChange();
|
||||||
}, [localChanges, visibleElements, isEditing, toast, getAvailableVariants, normalizeVariant]);
|
}, [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
|
// Debounce style changes to prevent lag
|
||||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
@@ -873,20 +1192,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
}, 100); // 100ms debounce
|
}, 100); // 100ms debounce
|
||||||
}, [isEditing]);
|
}, [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);
|
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 existingVariant = localChanges[elementName];
|
||||||
const defaultVariant = normalizeVariant(elementName, element.defaultVariant);
|
const defaultVariant = normalizeVariant(elementName, element?.defaultVariant || 'default');
|
||||||
const variantToUse = normalizeVariant(elementName, existingVariant || defaultVariant);
|
const variantToUse = normalizeVariant(elementName, existingVariant || defaultVariant);
|
||||||
if (!localChanges[elementName]) {
|
if (!localChanges[elementName]) {
|
||||||
setLocalChanges(prev => ({ ...prev, [elementName]: variantToUse }));
|
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 => {
|
setConfigs(prev => {
|
||||||
const index = prev.findIndex(cfg => cfg.element_name === elementName);
|
const index = prev.findIndex(cfg => cfg.element_name === elementName);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -894,27 +1227,104 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
updated[index] = { ...updated[index], variant: variantToUse, visible: true };
|
updated[index] = { ...updated[index], variant: variantToUse, visible: true };
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
return [...prev, {
|
return [
|
||||||
page_type: pageType,
|
...prev,
|
||||||
element_name: elementName,
|
{
|
||||||
variant: variantToUse,
|
page_type: pageType,
|
||||||
visible: true,
|
element_name: elementName,
|
||||||
display_order: prev.length,
|
variant: variantToUse,
|
||||||
}];
|
visible: true,
|
||||||
|
display_order: prev.length,
|
||||||
|
}
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close picker UI
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
setShowElementPicker(false);
|
setShowElementPicker(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedCategory('all');
|
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) {
|
if (isEditing) {
|
||||||
window.dispatchEvent(new CustomEvent('myuibrix-change', {
|
window.dispatchEvent(new CustomEvent('myuibrix-change', {
|
||||||
detail: { elementName, variant: variantToUse, visible: true, previewMode: true }
|
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) => {
|
const handleRemoveElement = useCallback((elementName: string) => {
|
||||||
// Update state - React will handle DOM removal
|
// Update state - React will handle DOM removal
|
||||||
@@ -940,50 +1350,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [visibleElements, localChanges, isEditing]);
|
}, [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 handleMoveUp = useCallback((elementName: string) => {
|
||||||
const currentIndex = elementOrder.indexOf(elementName);
|
const currentIndex = elementOrder.indexOf(elementName);
|
||||||
@@ -1087,6 +1454,57 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
}
|
}
|
||||||
}, [draggedElement, elementOrder, isEditing, applyVisualReorder]);
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const configsToSave: PageElementConfig[] = elementOrder.map((elementName, index) => ({
|
const configsToSave: PageElementConfig[] = elementOrder.map((elementName, index) => ({
|
||||||
@@ -1095,6 +1513,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
variant: localChanges[elementName] || 'default',
|
variant: localChanges[elementName] || 'default',
|
||||||
visible: visibleElements.has(elementName),
|
visible: visibleElements.has(elementName),
|
||||||
display_order: index,
|
display_order: index,
|
||||||
|
settings: {
|
||||||
|
...(configs.find(c => c.element_name === elementName)?.settings || {}),
|
||||||
|
customCSS: (elementStyles[elementName]?.customCSS || ''),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await batchUpdatePageElementConfigs(configsToSave);
|
await batchUpdatePageElementConfigs(configsToSave);
|
||||||
@@ -1107,6 +1529,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
isClosable: true,
|
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);
|
setHasChanges(false);
|
||||||
|
|
||||||
// Reload the page to apply changes in production view
|
// Reload the page to apply changes in production view
|
||||||
@@ -1438,7 +1867,27 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
|
|
||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<HStack spacing={2}>
|
<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
|
<Badge
|
||||||
bg="yellow.400"
|
bg="yellow.400"
|
||||||
color="gray.900"
|
color="gray.900"
|
||||||
@@ -1457,7 +1906,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
animation: 'bounce 1s infinite'
|
animation: 'bounce 1s infinite'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Object.keys(localChanges).length} neuložených změn
|
{unsavedCount} neuložených změn
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -1492,18 +1941,17 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Left Visual Style Panel */}
|
{/* Visual Style Panel (anchored, non-movable) */}
|
||||||
{showStylePanel && selectedElement && (
|
{showStylePanel && (
|
||||||
<Box
|
<Box
|
||||||
className="myuibrix-panel"
|
className="myuibrix-panel"
|
||||||
position="fixed"
|
position="fixed"
|
||||||
left={`${panelPositions.visualStylePanel.x}px`}
|
left={stylePanelRight ? undefined : 4}
|
||||||
top={`${panelPositions.visualStylePanel.y}px`}
|
right={stylePanelRight ? 4 : undefined}
|
||||||
width={`${panelPositions.visualStylePanel.width}px`}
|
top={64}
|
||||||
height={`${panelPositions.visualStylePanel.height}px`}
|
bottom={4}
|
||||||
|
width="380px"
|
||||||
zIndex={9998}
|
zIndex={9998}
|
||||||
onMouseDown={(e) => handlePanelMouseDown('visualStylePanel', e)}
|
|
||||||
cursor={draggingPanel === 'visualStylePanel' ? 'grabbing' : 'default'}
|
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
display="flex"
|
display="flex"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -1526,14 +1974,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
bgGradient={`linear(135deg, ${primaryColor}, ${primaryColor}dd)`}
|
bgGradient={`linear(135deg, ${primaryColor}, ${primaryColor}dd)`}
|
||||||
color="white"
|
color="white"
|
||||||
p={3}
|
p={3}
|
||||||
cursor="move"
|
cursor="default"
|
||||||
display="flex"
|
display="flex"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
borderTopRadius="2xl"
|
borderTopRadius="2xl"
|
||||||
borderBottom="1px solid rgba(255,255,255,0.2)"
|
|
||||||
boxShadow="0 2px 8px rgba(0,0,0,0.1)"
|
boxShadow="0 2px 8px rgba(0,0,0,0.1)"
|
||||||
|
borderBottom="1px solid rgba(255,255,255,0.2)"
|
||||||
>
|
>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FaPaintBrush} />
|
<Icon as={FaPaintBrush} />
|
||||||
@@ -1549,29 +1997,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flex="1" overflow="auto">
|
<Box flex="1" overflow="auto">
|
||||||
<VisualStylePanel
|
{selectedElement ? (
|
||||||
elementName={selectedElement}
|
<VisualStylePanel
|
||||||
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
|
elementName={selectedElement}
|
||||||
currentStyles={elementStyles[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>
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -1579,6 +2017,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
|
|
||||||
{/* Floating Control Panel - Minimalist */}
|
{/* Floating Control Panel - Minimalist */}
|
||||||
<Box
|
<Box
|
||||||
|
className="myuibrix-toolbar"
|
||||||
position="fixed"
|
position="fixed"
|
||||||
left={4}
|
left={4}
|
||||||
bottom={4}
|
bottom={4}
|
||||||
@@ -2016,6 +2455,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
</Box>
|
</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 */}
|
{/* Layers Panel - Visual element list with drag-drop */}
|
||||||
{isEditing && showLayersPanel && (
|
{isEditing && showLayersPanel && (
|
||||||
<Box
|
<Box
|
||||||
@@ -2023,10 +2490,11 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
position="fixed"
|
position="fixed"
|
||||||
left={panelPositions.layersPanel.x === 0 ? undefined : `${panelPositions.layersPanel.x}px`}
|
left={panelPositions.layersPanel.x === 0 ? undefined : `${panelPositions.layersPanel.x}px`}
|
||||||
right={panelPositions.layersPanel.x === 0 ? 4 : undefined}
|
right={panelPositions.layersPanel.x === 0 ? 4 : undefined}
|
||||||
top={panelPositions.layersPanel.y === 0 ? "50%" : `${panelPositions.layersPanel.y}px`}
|
top={panelPositions.layersPanel.y === 0 ? 4 : `${panelPositions.layersPanel.y}px`}
|
||||||
transform={panelPositions.layersPanel.y === 0 ? "translateY(-50%)" : undefined}
|
bottom={panelPositions.layersPanel.y === 0 ? 4 : undefined}
|
||||||
|
transform={undefined}
|
||||||
width={`${panelPositions.layersPanel.width}px`}
|
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)"
|
bg="rgba(255, 255, 255, 0.97)"
|
||||||
backdropFilter="blur(16px) saturate(180%)"
|
backdropFilter="blur(16px) saturate(180%)"
|
||||||
borderRadius="2xl"
|
borderRadius="2xl"
|
||||||
@@ -2038,10 +2506,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
onMouseDown={(e) => handlePanelMouseDown('layersPanel', e)}
|
onMouseDown={(e) => handlePanelMouseDown('layersPanel', e)}
|
||||||
cursor={draggingPanel === 'layersPanel' ? 'grabbing' : 'default'}
|
cursor={draggingPanel === 'layersPanel' ? 'grabbing' : 'default'}
|
||||||
fontFamily="var(--chakra-fonts-body)"
|
fontFamily="var(--chakra-fonts-body)"
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
sx={{
|
sx={{
|
||||||
'@keyframes slideInRight': {
|
'@keyframes slideInRight': {
|
||||||
from: { opacity: 0, transform: panelPositions.layersPanel.y === 0 ? 'translate(40px, -50%)' : 'translateX(40px)' },
|
from: { opacity: 0, transform: 'translateX(40px)' },
|
||||||
to: { opacity: 1, transform: panelPositions.layersPanel.y === 0 ? 'translateY(-50%)' : 'translateX(0)' }
|
to: { opacity: 1, transform: 'translateX(0)' }
|
||||||
},
|
},
|
||||||
animation: 'slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
animation: 'slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||||
}}
|
}}
|
||||||
@@ -2073,8 +2543,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</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 */}
|
{/* 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) => {
|
{elementOrder.map((elementName, index) => {
|
||||||
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
|
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
|
||||||
const isVisible = visibleElements.has(elementName);
|
const isVisible = visibleElements.has(elementName);
|
||||||
@@ -2192,6 +2672,20 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
|||||||
newVisible.add(elementName);
|
newVisible.add(elementName);
|
||||||
setVisibleElements(newVisible);
|
setVisibleElements(newVisible);
|
||||||
setHasChanges(true);
|
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 => {
|
const availableElements = elements.filter(e => {
|
||||||
if (visibleElements.has(e.name)) return false;
|
// Filter by search query only; allow duplicates (we'll warn and move existing)
|
||||||
// Filter by search query
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return e.label.toLowerCase().includes(query) ||
|
return e.label.toLowerCase().includes(query) ||
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
VStack,
|
VStack,
|
||||||
@@ -34,6 +34,7 @@ import CustomCSSEditor from './CustomCSSEditor';
|
|||||||
import ColumnLayoutManager from './ColumnLayoutManager';
|
import ColumnLayoutManager from './ColumnLayoutManager';
|
||||||
import ContextualAdminLinks from './ContextualAdminLinks';
|
import ContextualAdminLinks from './ContextualAdminLinks';
|
||||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||||
|
import { FONT_PAIRINGS, loadGoogleFont } from '../../config/fonts';
|
||||||
|
|
||||||
interface VisualStylePanelProps {
|
interface VisualStylePanelProps {
|
||||||
elementName: string;
|
elementName: string;
|
||||||
@@ -94,12 +95,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
...currentStyles,
|
...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 updateStyle = (key: string, value: any) => {
|
||||||
const newStyles = { ...styles, [key]: value };
|
const newStyles = { ...styles, [key]: value };
|
||||||
setStyles(newStyles);
|
setStyles(newStyles);
|
||||||
onStyleChange(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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
width="280px"
|
width="280px"
|
||||||
@@ -112,24 +171,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<Tabs size="sm" colorScheme="blue">
|
<Tabs size="sm" colorScheme="blue">
|
||||||
<TabList px={2} flexWrap="wrap">
|
<TabList px={2} flexWrap="wrap">
|
||||||
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
|
<Tab><FiType /> <Text ml={1}>Obsah</Text></Tab>
|
||||||
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
|
<Tab><FiLayout /> <Text ml={1}>Styl</Text></Tab>
|
||||||
<Tab><FiColumns /> <Text ml={1}>Layout</Text></Tab>
|
<Tab><FiColumns /> <Text ml={1}>Rozvržení</Text></Tab>
|
||||||
<Tab><FiCode /> <Text ml={1}>CSS</Text></Tab>
|
<Tab><FiCode /> <Text ml={1}>CSS</Text></Tab>
|
||||||
<Tab><FiExternalLink /> <Text ml={1}>Admin</Text></Tab>
|
<Tab><FiExternalLink /> <Text ml={1}>Admin</Text></Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
{/* Content Tab */}
|
{/* Záložka: Obsah */}
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<VStack align="stretch" spacing={4}>
|
<VStack align="stretch" spacing={4}>
|
||||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||||
Typography
|
Typografie
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Font Family */}
|
{/* Font pairing from Setup (curated) */}
|
||||||
<FormControl>
|
<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
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
value={styles.fontFamily}
|
value={styles.fontFamily}
|
||||||
@@ -146,9 +251,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Font Size */}
|
{/* Velikost písma */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Size (px)</FormLabel>
|
<FormLabel fontSize="xs">Velikost (px)</FormLabel>
|
||||||
<HStack>
|
<HStack>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -167,9 +272,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Font Weight */}
|
{/* Tloušťka písma */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Weight</FormLabel>
|
<FormLabel fontSize="xs">Tloušťka</FormLabel>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Slider
|
<Slider
|
||||||
value={styles.fontWeight}
|
value={styles.fontWeight}
|
||||||
@@ -188,9 +293,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Line Height */}
|
{/* Řádkování */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Line Height</FormLabel>
|
<FormLabel fontSize="xs">Řádkování</FormLabel>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Slider
|
<Slider
|
||||||
value={styles.lineHeight}
|
value={styles.lineHeight}
|
||||||
@@ -209,9 +314,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Letter Spacing */}
|
{/* Mezery mezi písmeny */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Letter Spacing (px)</FormLabel>
|
<FormLabel fontSize="xs">Mezera mezi písmeny (px)</FormLabel>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Slider
|
<Slider
|
||||||
value={styles.letterSpacing}
|
value={styles.letterSpacing}
|
||||||
@@ -230,33 +335,33 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Text Transform */}
|
{/* Transformace textu */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Transform</FormLabel>
|
<FormLabel fontSize="xs">Transformace</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
value={styles.textTransform}
|
value={styles.textTransform}
|
||||||
onChange={(e) => updateStyle('textTransform', e.target.value)}
|
onChange={(e) => updateStyle('textTransform', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="none">None</option>
|
<option value="none">Žádné</option>
|
||||||
<option value="uppercase">UPPERCASE</option>
|
<option value="uppercase">VELKÁ PÍSMENA</option>
|
||||||
<option value="lowercase">lowercase</option>
|
<option value="lowercase">malá písmena</option>
|
||||||
<option value="capitalize">Capitalize</option>
|
<option value="capitalize">První písmena velká</option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Style Tab */}
|
{/* Záložka: Styl */}
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<VStack align="stretch" spacing={4}>
|
<VStack align="stretch" spacing={4}>
|
||||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||||
Colors
|
Barvy
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Text Color */}
|
{/* Barva textu */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Text Color</FormLabel>
|
<FormLabel fontSize="xs">Barva textu</FormLabel>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Input
|
<Input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -275,9 +380,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Background Color */}
|
{/* Barva pozadí */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Background Color</FormLabel>
|
<FormLabel fontSize="xs">Barva pozadí</FormLabel>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Input
|
<Input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -299,15 +404,15 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
<Divider my={2} />
|
<Divider my={2} />
|
||||||
|
|
||||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||||
Spacing
|
Odsazení a okraje
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Padding */}
|
{/* Vnitřní odsazení (padding) */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Padding (px)</FormLabel>
|
<FormLabel fontSize="xs">Vnitřní odsazení (px)</FormLabel>
|
||||||
<VStack spacing={2}>
|
<VStack spacing={2}>
|
||||||
<HStack width="100%">
|
<HStack width="100%">
|
||||||
<Text fontSize="xs" minW="20px">T</Text>
|
<Text fontSize="xs" minW="20px">N</Text>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="xs"
|
size="xs"
|
||||||
value={styles.paddingTop}
|
value={styles.paddingTop}
|
||||||
@@ -319,7 +424,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</NumberInput>
|
</NumberInput>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack width="100%">
|
<HStack width="100%">
|
||||||
<Text fontSize="xs" minW="20px">R</Text>
|
<Text fontSize="xs" minW="20px">P</Text>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="xs"
|
size="xs"
|
||||||
value={styles.paddingRight}
|
value={styles.paddingRight}
|
||||||
@@ -331,7 +436,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</NumberInput>
|
</NumberInput>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack width="100%">
|
<HStack width="100%">
|
||||||
<Text fontSize="xs" minW="20px">B</Text>
|
<Text fontSize="xs" minW="20px">D</Text>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="xs"
|
size="xs"
|
||||||
value={styles.paddingBottom}
|
value={styles.paddingBottom}
|
||||||
@@ -357,12 +462,12 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Margin */}
|
{/* Vnější okraj (margin) */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Margin (px)</FormLabel>
|
<FormLabel fontSize="xs">Vnější okraj (px)</FormLabel>
|
||||||
<VStack spacing={2}>
|
<VStack spacing={2}>
|
||||||
<HStack width="100%">
|
<HStack width="100%">
|
||||||
<Text fontSize="xs" minW="20px">T</Text>
|
<Text fontSize="xs" minW="20px">N</Text>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="xs"
|
size="xs"
|
||||||
value={styles.marginTop}
|
value={styles.marginTop}
|
||||||
@@ -373,7 +478,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</NumberInput>
|
</NumberInput>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack width="100%">
|
<HStack width="100%">
|
||||||
<Text fontSize="xs" minW="20px">R</Text>
|
<Text fontSize="xs" minW="20px">P</Text>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="xs"
|
size="xs"
|
||||||
value={styles.marginRight}
|
value={styles.marginRight}
|
||||||
@@ -384,7 +489,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</NumberInput>
|
</NumberInput>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack width="100%">
|
<HStack width="100%">
|
||||||
<Text fontSize="xs" minW="20px">B</Text>
|
<Text fontSize="xs" minW="20px">D</Text>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="xs"
|
size="xs"
|
||||||
value={styles.marginBottom}
|
value={styles.marginBottom}
|
||||||
@@ -410,16 +515,16 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Layout Tab (was Grid Tab) */}
|
{/* Záložka: Rozvržení (mřížka) */}
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<VStack align="stretch" spacing={4}>
|
<VStack align="stretch" spacing={4}>
|
||||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||||
Grid Layout
|
Mřížkové rozložení
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Enable Grid */}
|
{/* Povolit mřížkové rozložení */}
|
||||||
<FormControl display="flex" alignItems="center">
|
<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
|
<Switch
|
||||||
size="sm"
|
size="sm"
|
||||||
isChecked={styles.display === 'grid'}
|
isChecked={styles.display === 'grid'}
|
||||||
@@ -437,9 +542,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Quick Templates */}
|
{/* Rychlé šablony */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs" fontWeight="bold">Quick Templates</FormLabel>
|
<FormLabel fontSize="xs" fontWeight="bold">Rychlé šablony</FormLabel>
|
||||||
<VStack spacing={2}>
|
<VStack spacing={2}>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -450,7 +555,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FiSmartphone />
|
<FiSmartphone />
|
||||||
<Text>Single Column</Text>
|
<Text>Jeden sloupec</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -462,7 +567,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FaColumns />
|
<FaColumns />
|
||||||
<Text>Two Equal (50% / 50%)</Text>
|
<Text>Dva stejné (50 % / 50 %)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -474,7 +579,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FiBarChart2 />
|
<FiBarChart2 />
|
||||||
<Text>Left Larger (66% / 33%)</Text>
|
<Text>Vlevo větší (66 % / 33 %)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -486,7 +591,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
|
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
|
||||||
<Text>Right Larger (33% / 66%)</Text>
|
<Text>Vpravo větší (33 % / 66 %)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -498,7 +603,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FiGrid />
|
<FiGrid />
|
||||||
<Text>Three Equal (33% / 33% / 33%)</Text>
|
<Text>Tři stejné (33 % / 33 % / 33 %)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -510,7 +615,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FaRegNewspaper />
|
<FaRegNewspaper />
|
||||||
<Text>Featured + Two (50% / 25% / 25%)</Text>
|
<Text>Zvýrazněný + dva (50 % / 25 % / 25 %)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -522,7 +627,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FaRegSquare />
|
<FaRegSquare />
|
||||||
<Text>Four Equal (25% each)</Text>
|
<Text>Čtyři stejné (25 % každá)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -534,7 +639,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<FiSidebar />
|
<FiSidebar />
|
||||||
<Text>Main + Sidebar (75% / 25%)</Text>
|
<Text>Hlavní + postranní (75 % / 25 %)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Button>
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -542,38 +647,38 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Custom Columns */}
|
{/* Vlastní sloupce */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Grid Template Columns</FormLabel>
|
<FormLabel fontSize="xs">Sloupce mřížky</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
size="sm"
|
||||||
value={styles.gridTemplateColumns}
|
value={styles.gridTemplateColumns}
|
||||||
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
|
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
|
||||||
placeholder="e.g. 1fr 2fr or 300px 1fr"
|
placeholder="např. 1fr 2fr nebo 300px 1fr"
|
||||||
fontFamily="monospace"
|
fontFamily="monospace"
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
/>
|
/>
|
||||||
<Text fontSize="10px" color="gray.500" mt={1}>
|
<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>
|
</Text>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Grid Template Rows */}
|
{/* Řádky mřížky */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Grid Template Rows</FormLabel>
|
<FormLabel fontSize="xs">Řádky mřížky</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
size="sm"
|
||||||
value={styles.gridTemplateRows}
|
value={styles.gridTemplateRows}
|
||||||
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
|
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
|
||||||
placeholder="auto or 200px 1fr"
|
placeholder="auto nebo 200px 1fr"
|
||||||
fontFamily="monospace"
|
fontFamily="monospace"
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Column Gap */}
|
{/* Mezera mezi sloupci */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Column Gap (px)</FormLabel>
|
<FormLabel fontSize="xs">Mezera mezi sloupci (px)</FormLabel>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Slider
|
<Slider
|
||||||
value={styles.gridColumnGap}
|
value={styles.gridColumnGap}
|
||||||
@@ -592,9 +697,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Row Gap */}
|
{/* Mezera mezi řádky */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Row Gap (px)</FormLabel>
|
<FormLabel fontSize="xs">Mezera mezi řádky (px)</FormLabel>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Slider
|
<Slider
|
||||||
value={styles.gridRowGap}
|
value={styles.gridRowGap}
|
||||||
@@ -615,49 +720,49 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Grid Auto Flow */}
|
{/* Automatické rozmístění */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Auto Flow</FormLabel>
|
<FormLabel fontSize="xs">Automatické rozmístění</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
value={styles.gridAutoFlow}
|
value={styles.gridAutoFlow}
|
||||||
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
|
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="row">Row (horizontal)</option>
|
<option value="row">Řádek (vodorovně)</option>
|
||||||
<option value="column">Column (vertical)</option>
|
<option value="column">Sloupec (svisle)</option>
|
||||||
<option value="row dense">Row Dense</option>
|
<option value="row dense">Řádek (zahuštěný)</option>
|
||||||
<option value="column dense">Column Dense</option>
|
<option value="column dense">Sloupec (zahuštěný)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Align Items */}
|
{/* Zarovnání (vertikálně) */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Align Items (vertical)</FormLabel>
|
<FormLabel fontSize="xs">Zarovnání prvků (vertikálně)</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
value={styles.alignItems}
|
value={styles.alignItems}
|
||||||
onChange={(e) => updateStyle('alignItems', e.target.value)}
|
onChange={(e) => updateStyle('alignItems', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="stretch">Stretch</option>
|
<option value="stretch">Roztáhnout</option>
|
||||||
<option value="start">Start</option>
|
<option value="start">Začátek</option>
|
||||||
<option value="center">Center</option>
|
<option value="center">Střed</option>
|
||||||
<option value="end">End</option>
|
<option value="end">Konec</option>
|
||||||
<option value="baseline">Baseline</option>
|
<option value="baseline">Základní řádek</option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Justify Items */}
|
{/* Zarovnání (horizontálně) */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel fontSize="xs">Justify Items (horizontal)</FormLabel>
|
<FormLabel fontSize="xs">Zarovnání prvků (horizontálně)</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
value={styles.justifyItems}
|
value={styles.justifyItems}
|
||||||
onChange={(e) => updateStyle('justifyItems', e.target.value)}
|
onChange={(e) => updateStyle('justifyItems', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="stretch">Stretch</option>
|
<option value="stretch">Roztáhnout</option>
|
||||||
<option value="start">Start</option>
|
<option value="start">Začátek</option>
|
||||||
<option value="center">Center</option>
|
<option value="center">Střed</option>
|
||||||
<option value="end">End</option>
|
<option value="end">Konec</option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</>
|
</>
|
||||||
@@ -665,16 +770,18 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Custom CSS Tab */}
|
{/* Záložka: Vlastní CSS */}
|
||||||
<TabPanel p={0}>
|
<TabPanel p={0}>
|
||||||
<CustomCSSEditor
|
<CustomCSSEditor
|
||||||
elementName={elementName}
|
elementName={elementName}
|
||||||
onCSSChange={(css) => updateStyle('customCSS', css)}
|
onCSSChange={(css) => updateStyle('customCSS', css)}
|
||||||
currentCSS={styles.customCSS || ''}
|
currentCSS={styles.customCSS || ''}
|
||||||
|
currentStyles={styles}
|
||||||
|
theme={{ primary: clubTheme.primary, secondary: clubTheme.secondary, accent: (clubTheme as any).accent }}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Admin Links Tab */}
|
{/* Záložka: Admin odkazy */}
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<ContextualAdminLinks elementName={elementName} />
|
<ContextualAdminLinks elementName={elementName} />
|
||||||
</TabPanel>
|
</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;
|
||||||
@@ -237,7 +237,7 @@ const Footer: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* MyClub Watermark - Clean White Branding */}
|
{/* 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">
|
<Container maxW="container.xl">
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ base: 'column', md: 'row' }}
|
direction={{ base: 'column', md: 'row' }}
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { ReactNode, useEffect, useState } from 'react';
|
|||||||
import { FiChevronUp } from 'react-icons/fi';
|
import { FiChevronUp } from 'react-icons/fi';
|
||||||
import Navbar from '../Navbar';
|
import Navbar from '../Navbar';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
|
import { useAllPageElementConfigs } from '../../hooks/usePageElementConfig';
|
||||||
|
import SpartaNavbar from '../elements/SpartaNavbar';
|
||||||
|
|
||||||
interface MainLayoutProps {
|
interface MainLayoutProps {
|
||||||
children: ReactNode;
|
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 [showTop, setShowTop] = useState(false);
|
||||||
|
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
|
||||||
|
const headerVariant = getVariant('header', 'unified');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -33,11 +38,39 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg="bg.app" overflowX="hidden">
|
<Box minH="100vh" bg="bg.app" overflowX="hidden">
|
||||||
<Box id="top" position="absolute" top={0} left={0} />
|
<Box id="top" position="absolute" top={0} left={0} />
|
||||||
<Navbar />
|
{headerInsideContainer ? (
|
||||||
<Container maxW="container.xl" py={8}>
|
<>
|
||||||
{children}
|
<Container maxW="container.xl" py={8}>
|
||||||
</Container>
|
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
|
||||||
<Footer />
|
{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 && (
|
{showTop && (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Zpět nahoru"
|
aria-label="Zpět nahoru"
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export default function NewsletterSubscribe() {
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Přihlášení k odběru proběhlo úspěšně',
|
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 e‑mail s heslem a odkazy pro správu newsletteru.',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
duration: 5000,
|
duration: 7000,
|
||||||
isClosable: true,
|
isClosable: true,
|
||||||
});
|
});
|
||||||
reset();
|
reset();
|
||||||
@@ -77,7 +77,7 @@ export default function NewsletterSubscribe() {
|
|||||||
Přihlaste se k odběru novinek
|
Přihlaste se k odběru novinek
|
||||||
</Text>
|
</Text>
|
||||||
<Text textAlign="center" color={textColor} mb={2}>
|
<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 e‑mailem.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
@@ -118,8 +118,7 @@ export default function NewsletterSubscribe() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
|
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
|
||||||
Odesláním formuláře souhlasíte se zpracováním osobních údajů.
|
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 e‑mailu. Heslo lze změnit přes stránku pro obnovení hesla.
|
||||||
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek.
|
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Heading,
|
Heading,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
|
Input,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Link,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { CheckIcon } from '@chakra-ui/icons';
|
import { CheckIcon, StarIcon } from '@chakra-ui/icons';
|
||||||
import {
|
import {
|
||||||
Poll,
|
Poll,
|
||||||
PollOption,
|
PollOption,
|
||||||
@@ -25,6 +29,9 @@ import {
|
|||||||
getPollResults,
|
getPollResults,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
} from '../../services/polls';
|
} from '../../services/polls';
|
||||||
|
import { useUmami } from '../../hooks/useUmami';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
interface PollCardProps {
|
interface PollCardProps {
|
||||||
poll: Poll;
|
poll: Poll;
|
||||||
@@ -43,11 +50,32 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { trackEvent } = useUmami();
|
||||||
|
const { isAuthenticated, user } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
|
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
|
||||||
const [hasVoted, setHasVoted] = useState(initialHasVoted);
|
const [hasVoted, setHasVoted] = useState(initialHasVoted);
|
||||||
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
|
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
|
||||||
const [results, setResults] = useState<any[]>([]);
|
const [results, setResults] = useState<any[]>([]);
|
||||||
const [showingResults, setShowingResults] = useState(initialCanShowResults);
|
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 bgCard = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
@@ -60,6 +88,8 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
return votePoll(poll.id, {
|
return votePoll(poll.id, {
|
||||||
option_ids: selectedOptions,
|
option_ids: selectedOptions,
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
|
voter_name: isAuthenticated ? (voterName || (user as any)?.name || undefined) : undefined,
|
||||||
|
voter_email: isAuthenticated ? (voterEmail || user?.email || undefined) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -85,6 +115,19 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
duration: 3000,
|
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) {
|
if (onVoteSuccess) {
|
||||||
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 () => {
|
const loadResults = async () => {
|
||||||
try {
|
try {
|
||||||
const resultsData = await getPollResults(poll.id);
|
const resultsData = await getPollResults(poll.id);
|
||||||
@@ -263,7 +328,38 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
|
|
||||||
{isActive && (
|
{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
|
<CheckboxGroup
|
||||||
value={selectedOptions.map(String)}
|
value={selectedOptions.map(String)}
|
||||||
onChange={handleMultipleChoice}
|
onChange={handleMultipleChoice}
|
||||||
@@ -280,8 +376,17 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: hoverBg }}
|
_hover={{ bg: hoverBg }}
|
||||||
cursor="pointer"
|
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}>
|
<VStack align="start" spacing={1}>
|
||||||
<Text>{option.text}</Text>
|
<Text>{option.text}</Text>
|
||||||
{option.description && (
|
{option.description && (
|
||||||
@@ -309,8 +414,17 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: hoverBg }}
|
_hover={{ bg: hoverBg }}
|
||||||
cursor="pointer"
|
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}>
|
<VStack align="start" spacing={1}>
|
||||||
<Text>{option.text}</Text>
|
<Text>{option.text}</Text>
|
||||||
{option.description && (
|
{option.description && (
|
||||||
@@ -342,6 +456,36 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
</RadioGroup>
|
</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
|
<Button
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
onClick={handleVote}
|
onClick={handleVote}
|
||||||
|
|||||||
@@ -6,20 +6,31 @@ import { PageElementConfig } from '../services/pageElements';
|
|||||||
// Elements that are actually implemented on HomePage
|
// Elements that are actually implemented on HomePage
|
||||||
// Only these should be available in the editor
|
// Only these should be available in the editor
|
||||||
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
||||||
|
'header', // Site navigation/header
|
||||||
'hero', // Hero section with news cards (grid/scroller/swiper variants)
|
'hero', // Hero section with news cards (grid/scroller/swiper variants)
|
||||||
'news', // Featured news articles
|
'news', // Featured news articles
|
||||||
'matches', // Upcoming/recent matches
|
'matches', // Upcoming/recent matches
|
||||||
|
'matches-slider', // Matches slider by competition
|
||||||
'table', // League standings table
|
'table', // League standings table
|
||||||
'team', // Players scroller
|
'team', // Players scroller
|
||||||
'gallery', // Photo gallery albums from Zonerama
|
'gallery', // Photo gallery albums from Zonerama
|
||||||
'videos', // Videos section
|
'videos', // Videos section
|
||||||
'merch', // Merchandise/fanshop
|
'merch', // Merchandise/fanshop
|
||||||
'newsletter',// Newsletter subscription
|
'newsletter',// Newsletter subscription
|
||||||
|
'poll', // Polls / voting widget
|
||||||
'sponsors', // Sponsors/partners
|
'sponsors', // Sponsors/partners
|
||||||
'banner', // Advertisement banners (various placements)
|
'banner', // Advertisement banners (various placements)
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
||||||
|
{
|
||||||
|
page_type: 'homepage',
|
||||||
|
element_name: 'header',
|
||||||
|
variant: 'unified',
|
||||||
|
visible: true,
|
||||||
|
display_order: 0,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page_type: 'homepage',
|
page_type: 'homepage',
|
||||||
element_name: 'hero',
|
element_name: 'hero',
|
||||||
@@ -33,7 +44,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'news',
|
element_name: 'news',
|
||||||
variant: 'grid',
|
variant: 'grid',
|
||||||
visible: true,
|
visible: true,
|
||||||
display_order: 2,
|
display_order: 11,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,7 +52,15 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'matches',
|
element_name: 'matches',
|
||||||
variant: 'compact',
|
variant: 'compact',
|
||||||
visible: true,
|
visible: true,
|
||||||
display_order: 3,
|
display_order: 2,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_type: 'homepage',
|
||||||
|
element_name: 'matches-slider',
|
||||||
|
variant: 'carousel',
|
||||||
|
visible: true,
|
||||||
|
display_order: 4,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,7 +68,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'sponsors',
|
element_name: 'sponsors',
|
||||||
variant: 'grid',
|
variant: 'grid',
|
||||||
visible: true,
|
visible: true,
|
||||||
display_order: 4,
|
display_order: 9,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -65,7 +84,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'videos',
|
element_name: 'videos',
|
||||||
variant: 'grid',
|
variant: 'grid',
|
||||||
visible: false,
|
visible: false,
|
||||||
display_order: 6,
|
display_order: 7,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,7 +92,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'team',
|
element_name: 'team',
|
||||||
variant: 'grid',
|
variant: 'grid',
|
||||||
visible: false,
|
visible: false,
|
||||||
display_order: 7,
|
display_order: 6,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,7 +100,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'merch',
|
element_name: 'merch',
|
||||||
variant: 'grid',
|
variant: 'grid',
|
||||||
visible: true,
|
visible: true,
|
||||||
display_order: 7,
|
display_order: 8,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,7 +108,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'table',
|
element_name: 'table',
|
||||||
variant: 'split_news',
|
variant: 'split_news',
|
||||||
visible: true,
|
visible: true,
|
||||||
display_order: 8,
|
display_order: 3,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,7 +124,15 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
element_name: 'newsletter',
|
element_name: 'newsletter',
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
visible: false,
|
visible: false,
|
||||||
display_order: 9,
|
display_order: 4,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page_type: 'homepage',
|
||||||
|
element_name: 'poll',
|
||||||
|
variant: 'vertical',
|
||||||
|
visible: false,
|
||||||
|
display_order: 12,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -91,8 +91,18 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
|||||||
setVisibility(visMap);
|
setVisibility(visMap);
|
||||||
setElementOrder(order);
|
setElementOrder(order);
|
||||||
|
|
||||||
// Apply initial order to DOM if elements exist
|
// Apply initial order to DOM only in editor/preview mode
|
||||||
if (order.length > 0) {
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
applyDOMOrder(order);
|
applyDOMOrder(order);
|
||||||
});
|
});
|
||||||
@@ -137,7 +147,14 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
|||||||
const handleMyUIbrixReorder = ((event: CustomEvent) => {
|
const handleMyUIbrixReorder = ((event: CustomEvent) => {
|
||||||
const { order } = event.detail;
|
const { order } = event.detail;
|
||||||
setElementOrder(order);
|
setElementOrder(order);
|
||||||
applyDOMOrder(order);
|
try {
|
||||||
|
const inEdit = document.body?.classList?.contains('myuibrix-edit-mode') || false;
|
||||||
|
if (inEdit) {
|
||||||
|
applyDOMOrder(order);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
}) as EventListener;
|
}) as EventListener;
|
||||||
|
|
||||||
// Listen for style changes from VisualStylePanel
|
// Listen for style changes from VisualStylePanel
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MainLayout from '../components/layout/MainLayout';
|
|||||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||||
import { getEvent } from '../services/eventService';
|
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 { 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 DOMPurify from 'dompurify';
|
||||||
import { assetUrl } from '../utils/url';
|
import { assetUrl } from '../utils/url';
|
||||||
import EventLocationMap from '../components/events/EventLocationMap';
|
import EventLocationMap from '../components/events/EventLocationMap';
|
||||||
@@ -90,61 +90,33 @@ const ActivityDetailPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{!loading && !error && data && (
|
{!loading && !error && data && (
|
||||||
<VStack align="stretch" spacing={5}>
|
<VStack align="stretch" spacing={5}>
|
||||||
{/* Hero image */}
|
|
||||||
{data.image_url && (
|
{data.image_url && (
|
||||||
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
|
<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" />
|
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Title and meta */}
|
|
||||||
<VStack align="stretch" spacing={1}>
|
<VStack align="stretch" spacing={1}>
|
||||||
<HStack justify="space-between" align="start">
|
<HStack justify="space-between" align="start">
|
||||||
<Heading as="h1" size="lg" lineHeight={1.2}>{data.title}</Heading>
|
<Heading as="h1" size="lg" lineHeight={1.2}>{data.title}</Heading>
|
||||||
<Badge colorScheme={typeColor(data.type)}>{typeLabel(data.type)}</Badge>
|
<Badge colorScheme={typeColor(data.type)}>{typeLabel(data.type)}</Badge>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack spacing={4} color={mutedText} fontSize="sm">
|
<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 && (
|
{data.location && (
|
||||||
<HStack>
|
<HStack>
|
||||||
<Icon as={FiMapPin} />
|
<Icon as={FiMapPin} />
|
||||||
<Text>{data.location}</Text>
|
<Text>{data.location}</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FiClock} />
|
||||||
|
<Text>
|
||||||
|
{new Date(data.start_time).toLocaleString()} {data.end_time ? `– ${new Date(data.end_time).toLocaleString()}` : ''}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</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 && (
|
{data.description && (
|
||||||
<Box
|
<Box
|
||||||
bg={cardBg}
|
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) && (
|
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
|
||||||
<VStack align="stretch" spacing={3}>
|
<VStack align="stretch" spacing={3}>
|
||||||
<Heading as="h3" size="sm">Přílohy</Heading>
|
<Heading as="h3" size="sm">Přílohy</Heading>
|
||||||
@@ -183,7 +182,6 @@ const ActivityDetailPage: React.FC = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Legacy single file_url */}
|
|
||||||
{data.file_url && (
|
{data.file_url && (
|
||||||
<HStack>
|
<HStack>
|
||||||
<Button as={ChakraLink} href={assetUrl(data.file_url) || data.file_url} isExternal variant="outline" leftIcon={<FiDownload />}>
|
<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>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Back links */}
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<HStack>
|
<HStack>
|
||||||
<Button as={RouterLink} to="/aktivity" variant="outline">Zpět na aktivity</Button>
|
<Button as={RouterLink} to="/aktivity" variant="outline">Zpět na aktivity</Button>
|
||||||
@@ -203,8 +200,6 @@ const ActivityDetailPage: React.FC = () => {
|
|||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Embedded Poll - shows polls related to this event */}
|
|
||||||
{data?.id && <EmbeddedPoll eventId={data.id} />}
|
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const AuthPage: React.FC = () => {
|
|||||||
return <Navigate to="/admin" replace />;
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+109
-148
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|||||||
import MainLayout from '../components/layout/MainLayout';
|
import MainLayout from '../components/layout/MainLayout';
|
||||||
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||||
import '../styles/theme.css';
|
import '../styles/theme.css';
|
||||||
|
import '../styles/sparta-styles.css';
|
||||||
import './styles/UnifiedHome.css';
|
import './styles/UnifiedHome.css';
|
||||||
import { getPublicSettings } from '../services/settings';
|
import { getPublicSettings } from '../services/settings';
|
||||||
import { assetUrl, sanitizeClubName } from '../utils/url';
|
import { assetUrl, sanitizeClubName } from '../utils/url';
|
||||||
@@ -11,9 +12,11 @@ import BlogCardsScroller from '../components/home/BlogCardsScroller';
|
|||||||
import BlogSwiper from '../components/home/BlogSwiper';
|
import BlogSwiper from '../components/home/BlogSwiper';
|
||||||
import VideosSection from '../components/home/VideosSection';
|
import VideosSection from '../components/home/VideosSection';
|
||||||
import MerchSection from '../components/home/MerchSection';
|
import MerchSection from '../components/home/MerchSection';
|
||||||
|
import PollsWidget from '../components/home/PollsWidget';
|
||||||
import GallerySection from '../components/home/GallerySection';
|
import GallerySection from '../components/home/GallerySection';
|
||||||
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
|
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
|
||||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||||
|
import { getUpcomingEvents } from '../services/eventService';
|
||||||
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
|
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
|
||||||
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
|
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
|
||||||
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
|
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 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 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 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 [players, setPlayers] = useState<UiPlayer[]>([]);
|
||||||
const [sponsors, setSponsors] = useState<UiSponsor[]>([]);
|
const [sponsors, setSponsors] = useState<UiSponsor[]>([]);
|
||||||
const [banners, setBanners] = useState<UiBanner[]>([]);
|
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 [videosRich, setVideosRich] = useState<Array<{ url:string; title?:string; length?:string; uploaded_at?:string; thumbnail_url?:string }>>([]);
|
||||||
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
|
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
|
||||||
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
|
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
|
||||||
|
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
|
||||||
// Aliases
|
// Aliases
|
||||||
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
|
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
|
||||||
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
|
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
|
||||||
@@ -540,76 +545,7 @@ const HomePage: React.FC = () => {
|
|||||||
setTimeout(run, 0);
|
setTimeout(run, 0);
|
||||||
}
|
}
|
||||||
}, [facrCompetitions, matchesTab, closestIndexByComp]);
|
}, [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
|
// MyUIbrix events are handled by useAllPageElementConfigs hook
|
||||||
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
|
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
|
||||||
|
|
||||||
@@ -656,6 +592,26 @@ const HomePage: React.FC = () => {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [matches, facrCompetitions, matchesTab]);
|
}, [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: Edge auto-cycle
|
||||||
|
|
||||||
// Removed: Aurora layout
|
// Removed: Aurora layout
|
||||||
@@ -1381,8 +1337,8 @@ const HomePage: React.FC = () => {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout headerInsideContainer>
|
||||||
<div className="container">
|
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
|
||||||
{/* Header: logo + club name */}
|
{/* Header: logo + club name */}
|
||||||
<div className="home-header">
|
<div className="home-header">
|
||||||
<img src={assetUrl(clubLogo) || '/images/club-logo.png'} alt="Klub" />
|
<img src={assetUrl(clubLogo) || '/images/club-logo.png'} alt="Klub" />
|
||||||
@@ -1553,7 +1509,7 @@ const HomePage: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Matches slider with scores by competition */}
|
{/* Matches slider with scores by competition (moved after news+tables) */}
|
||||||
{facrCompetitions.length > 0 && (
|
{facrCompetitions.length > 0 && (
|
||||||
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
|
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
|
||||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||||
@@ -1612,54 +1568,54 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* Competition tables moved into right column below */}
|
|
||||||
|
|
||||||
{/* Standings: tabs per competition (only FACR), clicking row opens ClubModal */}
|
{/* News + Tables: split into two independent sections */}
|
||||||
{isVisible('table', true) && (() => {
|
{(() => {
|
||||||
// Match standings to current competition by name instead of assuming same index
|
// Compute matching standings for the selected competition
|
||||||
const currentCompetition = facrCompetitions[matchesTab];
|
const currentCompetition = facrCompetitions[matchesTab];
|
||||||
const currentCompetitionName = currentCompetition?.name || '';
|
const currentCompetitionName = currentCompetition?.name || '';
|
||||||
const matchingStanding = standings.find((s: any) => s.name === currentCompetitionName);
|
const matchingStanding = standings.find((s: any) => s.name === currentCompetitionName);
|
||||||
|
const hasStandingsForCurrentTab = !!matchingStanding && (
|
||||||
const hasStandingsForCurrentTab = matchingStanding && (
|
|
||||||
(matchingStanding.table && matchingStanding.table.length > 0) ||
|
(matchingStanding.table && matchingStanding.table.length > 0) ||
|
||||||
(matchingStanding.rows && matchingStanding.rows.length > 0)
|
(matchingStanding.rows && matchingStanding.rows.length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<>
|
||||||
data-element="table"
|
{isVisible('news', true) && (
|
||||||
className="standings"
|
<section data-element="news" className="news-list" style={{ marginTop: 32, ...getStyles('news') }}>
|
||||||
data-variant={hasStandingsForCurrentTab ? undefined : 'standard'}
|
<div className="section-head" style={{ marginTop: 0 }}>
|
||||||
style={{ marginTop: 32, ...getStyles('table') }}
|
<h3>Další aktuality</h3>
|
||||||
>
|
</div>
|
||||||
<div>
|
<div className="blog-list">
|
||||||
<div className="section-head" style={{ marginTop: 0 }}>
|
{news.length > 0 ? news.slice(0, 4).map((n) => (
|
||||||
<h3>Další aktuality</h3>
|
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
</div>
|
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||||
<div className="blog-list">
|
<div>
|
||||||
{news.length > 0 ? news.slice(0, 4).map((n) => (
|
<h4>{n.title}</h4>
|
||||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
|
||||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
</div>
|
||||||
<div>
|
</a>
|
||||||
<h4>{n.title}</h4>
|
)) : (
|
||||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
|
<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>
|
</div>
|
||||||
</a>
|
)}
|
||||||
)) : (
|
</div>
|
||||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
|
{news.length > 0 && (
|
||||||
<p>Zatím nejsou k dispozici žádné aktuality.</p>
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
{news.length > 0 && (
|
)}
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
|
{isVisible('table', true) && hasStandingsForCurrentTab && (
|
||||||
</div>
|
<section
|
||||||
)}
|
data-element="table"
|
||||||
</div>
|
className="standings"
|
||||||
{hasStandingsForCurrentTab && (
|
style={{ marginTop: 32, ...getStyles('table') }}
|
||||||
<div>
|
>
|
||||||
<div className="table-card">
|
<div className="table-card">
|
||||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||||
<h3>Tabulky</h3>
|
<h3>Tabulky</h3>
|
||||||
@@ -1709,12 +1665,10 @@ const HomePage: React.FC = () => {
|
|||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.transform = 'translateX(2px)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
||||||
e.currentTarget.style.borderColor = 'var(--primary)';
|
e.currentTarget.style.borderColor = 'var(--primary)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.transform = 'translateX(0)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
e.currentTarget.style.borderColor = 'var(--card-border)';
|
e.currentTarget.style.borderColor = 'var(--card-border)';
|
||||||
}}
|
}}
|
||||||
@@ -1745,13 +1699,39 @@ const HomePage: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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) && (
|
{players.length > 0 && isVisible('team', false) && (
|
||||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
@@ -1770,22 +1750,6 @@ const HomePage: React.FC = () => {
|
|||||||
</section>
|
</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 e‑shopu</a>
|
|
||||||
</div>
|
|
||||||
<div className="mockup" aria-hidden>
|
|
||||||
<div className="shirt" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gallery */}
|
{/* Gallery */}
|
||||||
{isVisible('gallery', false) && (
|
{isVisible('gallery', false) && (
|
||||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||||
@@ -1812,27 +1776,15 @@ const HomePage: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Newsletter subscription CTA */}
|
{/* Polls / Voting */}
|
||||||
{isVisible('newsletter', false) && (
|
{isVisible('poll', false) && (
|
||||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<NewsletterSubscribe />
|
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Banner: homepage_footer */}
|
||||||
{(banners || []).some(b => b.placement === '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') }}>
|
<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>
|
</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 */}
|
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */}
|
||||||
{isVisible('sponsors', true) && (
|
{isVisible('sponsors', true) && (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -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>E‑mail</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">
|
||||||
|
Už máte účet?{' '}
|
||||||
|
<Link color="blue.500" href="/login">
|
||||||
|
Přihlaste se
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
@@ -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;
|
||||||
@@ -19,6 +19,22 @@ import MapStyleSelector from '../components/admin/MapStyleSelector';
|
|||||||
import { MapCoordinates } from '../utils/mapUrlParser';
|
import { MapCoordinates } from '../utils/mapUrlParser';
|
||||||
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
|
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 SetupPage: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -126,6 +142,8 @@ const SetupPage: React.FC = () => {
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValidEmail = (val: string) => /^(?:[^\s@]+)@(?:[^\s@]+)\.(?:[^\s@]+)$/.test((val || '').trim());
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@@ -175,7 +193,7 @@ const SetupPage: React.FC = () => {
|
|||||||
|
|
||||||
// Auto-fill SMTP username from contact email
|
// Auto-fill SMTP username from contact email
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contactEmail && !smtpUser) {
|
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
|
||||||
setSmtpUser(contactEmail);
|
setSmtpUser(contactEmail);
|
||||||
}
|
}
|
||||||
}, [contactEmail, smtpUser]);
|
}, [contactEmail, smtpUser]);
|
||||||
@@ -285,7 +303,7 @@ const SetupPage: React.FC = () => {
|
|||||||
contact_city: contactCity || undefined,
|
contact_city: contactCity || undefined,
|
||||||
contact_zip: contactPostalCode || undefined,
|
contact_zip: contactPostalCode || undefined,
|
||||||
contact_country: contactCountry || undefined,
|
contact_country: contactCountry || undefined,
|
||||||
contact_phone: contactPhone || undefined,
|
contact_phone: normalizePhone(contactPhone, contactCountry) || undefined,
|
||||||
contact_email: contactEmail || undefined,
|
contact_email: contactEmail || undefined,
|
||||||
smtp: (smtpHost || smtpPort || smtpUser || smtpPass || smtpFromName) ? {
|
smtp: (smtpHost || smtpPort || smtpUser || smtpPass || smtpFromName) ? {
|
||||||
host: smtpHost || undefined,
|
host: smtpHost || undefined,
|
||||||
@@ -352,6 +370,12 @@ const SetupPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
if (logoApiRes.ok) {
|
if (logoApiRes.ok) {
|
||||||
toast({ title: 'Logo nahráno', description: 'Logo bylo nahráno na logoapi i lokálně', status: 'success', duration: 3000 });
|
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) {
|
} catch (logoApiErr) {
|
||||||
console.warn('Failed to upload to logoapi:', logoApiErr);
|
console.warn('Failed to upload to logoapi:', logoApiErr);
|
||||||
@@ -726,7 +750,11 @@ const SetupPage: React.FC = () => {
|
|||||||
setGpsLat(coords.latitude);
|
setGpsLat(coords.latitude);
|
||||||
setGpsLng(coords.longitude);
|
setGpsLng(coords.longitude);
|
||||||
// Auto-fill address fields if available from geocoding
|
// 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.city) setContactCity(coords.city);
|
||||||
if (coords.zip) setContactPostalCode(coords.zip);
|
if (coords.zip) setContactPostalCode(coords.zip);
|
||||||
if (coords.country) setContactCountry(coords.country);
|
if (coords.country) setContactCountry(coords.country);
|
||||||
@@ -768,7 +796,7 @@ const SetupPage: React.FC = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>E-mail</FormLabel>
|
<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>
|
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|||||||
@@ -40,13 +40,7 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
|||||||
retry: false,
|
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;
|
const mid = (linkQ.data as any)?.external_match_id;
|
||||||
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
|
|
||||||
|
|
||||||
const facrQ = useQuery({
|
const facrQ = useQuery({
|
||||||
queryKey: ['facr-cached-match', mid],
|
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
|
// Guard against errors
|
||||||
if (facrQ.isError || linkQ.isError) {
|
if (facrQ.isError || linkQ.isError) {
|
||||||
return <Badge colorScheme="red">Chyba načítání</Badge>;
|
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 (
|
return (
|
||||||
<AdminLayout requireAdmin={false}>
|
<AdminLayout requireAdmin={false}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -1500,9 +1507,9 @@ const ArticlesAdminPage = () => {
|
|||||||
borderWidth="2px"
|
borderWidth="2px"
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
borderColor={isSelected ? 'blue.500' : 'gray.200'}
|
borderColor={isSelected ? 'blue.500' : 'gray.200'}
|
||||||
bg={isSelected ? useColorModeValue('blue.50', 'blue.900') : useColorModeValue('white', 'gray.700')}
|
bg={isSelected ? matchBgSelected : matchBgDefault}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
_hover={{ borderColor: 'blue.300', bg: useColorModeValue('blue.50', 'gray.600') }}
|
_hover={{ borderColor: 'blue.300', bg: matchHoverBg }}
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const val = matchId;
|
const val = matchId;
|
||||||
@@ -1605,7 +1612,7 @@ const ArticlesAdminPage = () => {
|
|||||||
placeholder="https://eu.zonerama.com/…"
|
placeholder="https://eu.zonerama.com/…"
|
||||||
value={zAlbumLink}
|
value={zAlbumLink}
|
||||||
onChange={(e) => setZAlbumLink(e.target.value)}
|
onChange={(e) => setZAlbumLink(e.target.value)}
|
||||||
bg={zAlbumPhotos.length > 0 ? useColorModeValue('green.50', 'green.900') : undefined}
|
bg={zAlbumPhotos.length > 0 ? albumLinkHasPhotosBg : undefined}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<FormHelperText fontSize="xs">
|
<FormHelperText fontSize="xs">
|
||||||
@@ -2111,7 +2118,7 @@ const ArticlesAdminPage = () => {
|
|||||||
{!galleryLoading && cachedAlbums.length > 0 && (
|
{!galleryLoading && cachedAlbums.length > 0 && (
|
||||||
<VStack align="stretch" spacing={6}>
|
<VStack align="stretch" spacing={6}>
|
||||||
{cachedAlbums.map((album) => (
|
{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}>
|
<HStack justify="space-between" mb={3}>
|
||||||
<VStack align="start" spacing={0}>
|
<VStack align="start" spacing={0}>
|
||||||
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
||||||
@@ -2200,7 +2207,7 @@ const ArticlesAdminPage = () => {
|
|||||||
{!galleryLoading && cachedAlbums.length > 0 && (
|
{!galleryLoading && cachedAlbums.length > 0 && (
|
||||||
<VStack align="stretch" spacing={6}>
|
<VStack align="stretch" spacing={6}>
|
||||||
{cachedAlbums.map((album) => (
|
{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}>
|
<HStack justify="space-between" mb={3}>
|
||||||
<VStack align="start" spacing={0}>
|
<VStack align="start" spacing={0}>
|
||||||
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ import {
|
|||||||
Select
|
Select
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { TeamLogo } from '../../components/common/TeamLogo';
|
|
||||||
import AdminLayout from '../../layouts/AdminLayout';
|
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 { getPublicSettings } from '../../services/settings';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { parse } from 'date-fns';
|
import { parse } from 'date-fns';
|
||||||
import { assetUrl } from '../../utils/url';
|
import { assetUrl } from '../../utils/url';
|
||||||
|
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||||
|
|
||||||
const MatchesAdminPage = () => {
|
const MatchesAdminPage = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -63,6 +63,60 @@ const MatchesAdminPage = () => {
|
|||||||
notes: '',
|
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
|
// External logo upload helpers/state
|
||||||
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
|
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
|
||||||
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
|
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
|
||||||
@@ -137,7 +191,24 @@ 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
|
// Filters
|
||||||
const [teamFilter, setTeamFilter] = useState('');
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
|
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
|
||||||
@@ -870,12 +941,11 @@ const MatchesAdminPage = () => {
|
|||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<TeamLogo
|
<Image
|
||||||
teamId={m.home_id}
|
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
|
||||||
teamName={m.home || m.home_team || ''}
|
alt={m.home || m.home_team || ''}
|
||||||
facrLogo={m.home_logo_url}
|
|
||||||
size="custom"
|
|
||||||
boxSize="24px"
|
boxSize="24px"
|
||||||
|
objectFit="contain"
|
||||||
/>
|
/>
|
||||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
|
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
|
||||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||||
@@ -888,12 +958,11 @@ const MatchesAdminPage = () => {
|
|||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<TeamLogo
|
<Image
|
||||||
teamId={m.away_id}
|
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
|
||||||
teamName={m.away || m.away_team || ''}
|
alt={m.away || m.away_team || ''}
|
||||||
facrLogo={m.away_logo_url}
|
|
||||||
size="custom"
|
|
||||||
boxSize="24px"
|
boxSize="24px"
|
||||||
|
objectFit="contain"
|
||||||
/>
|
/>
|
||||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
|
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
|
||||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
FormErrorMessage,
|
||||||
Heading,
|
Heading,
|
||||||
HStack,
|
HStack,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -229,6 +230,13 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
const [editing, setEditing] = useState<Editing | null>(null);
|
const [editing, setEditing] = useState<Editing | null>(null);
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
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
|
// 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: '' });
|
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 () => {
|
const onSubmit = async () => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
const fn = (editing.first_name || '').trim();
|
let fn = (editing.first_name || '').trim();
|
||||||
const ln = (editing.last_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) {
|
if (!fn || !ln) {
|
||||||
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
|
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
|
||||||
return;
|
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
|
// Build payload by including only present values to satisfy backend validation
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
first_name: fn,
|
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.date_of_birth) payload.date_of_birth = editing.date_of_birth;
|
||||||
if (editing.position) payload.position = editing.position;
|
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 (editing.nationality) payload.nationality = editing.nationality;
|
||||||
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
|
if (typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > 0) {
|
||||||
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
|
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 (editing.image_url) payload.image_url = editing.image_url;
|
||||||
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
|
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
|
||||||
const email = ((editing as any).email || '').trim();
|
const email = ((editing as any).email || '').trim();
|
||||||
@@ -373,7 +420,7 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
<SimpleGrid columns={[1, 2]} spacing={4}>
|
<SimpleGrid columns={[1, 2]} spacing={4}>
|
||||||
<FormControl isRequired>
|
<FormControl isRequired>
|
||||||
<FormLabel>Jméno</FormLabel>
|
<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>
|
||||||
<FormControl isRequired>
|
<FormControl isRequired>
|
||||||
<FormLabel>Příjmení</FormLabel>
|
<FormLabel>Příjmení</FormLabel>
|
||||||
@@ -414,11 +461,12 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
|
||||||
<FormLabel>Číslo dresu</FormLabel>
|
<FormLabel>Číslo dresu</FormLabel>
|
||||||
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
|
<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 />
|
<NumberInputField inputMode="numeric" />
|
||||||
</NumberInput>
|
</NumberInput>
|
||||||
|
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -466,17 +514,19 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl isInvalid={typeof editing?.height === 'number' && (editing?.height as number) > HEIGHT_MAX}>
|
||||||
<FormLabel>Výška (cm)</FormLabel>
|
<FormLabel>Výška (cm)</FormLabel>
|
||||||
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
|
<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 />
|
<NumberInputField inputMode="numeric" />
|
||||||
</NumberInput>
|
</NumberInput>
|
||||||
|
<FormErrorMessage>Maximální výška je {HEIGHT_MAX} cm.</FormErrorMessage>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl isInvalid={typeof editing?.weight === 'number' && (editing?.weight as number) > WEIGHT_MAX}>
|
||||||
<FormLabel>Váha (kg)</FormLabel>
|
<FormLabel>Váha (kg)</FormLabel>
|
||||||
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
|
<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 />
|
<NumberInputField inputMode="numeric" />
|
||||||
</NumberInput>
|
</NumberInput>
|
||||||
|
<FormErrorMessage>Maximální váha je {WEIGHT_MAX} kg.</FormErrorMessage>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{/* Optional contact info (not shown publicly) */}
|
{/* Optional contact info (not shown publicly) */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -211,6 +211,65 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
onOpen();
|
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 (1–10)',
|
||||||
|
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) => {
|
const handleOpenEdit = (poll: Poll) => {
|
||||||
setEditingPoll(poll);
|
setEditingPoll(poll);
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -362,9 +421,21 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<Heading size="lg">Správa anket</Heading>
|
<Heading size="lg">Správa anket</Heading>
|
||||||
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
|
<HStack>
|
||||||
Nová anketa
|
<Menu>
|
||||||
</Button>
|
<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 (1–10)</MenuItem>
|
||||||
|
<MenuItem onClick={() => applyPreset('attendance')}>Dorazíš na schůzku?</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
|
||||||
|
Nová anketa
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Alert status="info">
|
<Alert status="info">
|
||||||
@@ -818,7 +889,7 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
Výsledky
|
Výsledky
|
||||||
</Heading>
|
</Heading>
|
||||||
<VStack spacing={2} align="stretch">
|
<VStack spacing={2} align="stretch">
|
||||||
{statsData.poll.options.map((option) => {
|
{(statsData.poll.options || []).map((option) => {
|
||||||
const percentage =
|
const percentage =
|
||||||
statsData.poll.total_votes > 0
|
statsData.poll.total_votes > 0
|
||||||
? (option.vote_count / statsData.poll.total_votes) * 100
|
? (option.vote_count / statsData.poll.total_votes) * 100
|
||||||
@@ -851,13 +922,13 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{statsData.votes_by_day.length > 0 && (
|
{(statsData.votes_by_day?.length ?? 0) > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Heading size="sm" mb={4}>
|
<Heading size="sm" mb={4}>
|
||||||
Hlasy podle dnů
|
Hlasy podle dnů
|
||||||
</Heading>
|
</Heading>
|
||||||
<VStack spacing={2} align="stretch">
|
<VStack spacing={2} align="stretch">
|
||||||
{statsData.votes_by_day.map((day) => (
|
{(statsData.votes_by_day || []).map((day) => (
|
||||||
<HStack key={day.date} justify="space-between">
|
<HStack key={day.date} justify="space-between">
|
||||||
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
|
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
|
||||||
<Badge>{day.count} hlasů</Badge>
|
<Badge>{day.count} hlasů</Badge>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides,
|
|||||||
import { getFacrTablesCache } from '../../services/facr/cache';
|
import { getFacrTablesCache } from '../../services/facr/cache';
|
||||||
import { assetUrl } from '../../utils/url';
|
import { assetUrl } from '../../utils/url';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { TeamLogo } from '../../components/common/TeamLogo';
|
|
||||||
|
|
||||||
type TableRow = {
|
type TableRow = {
|
||||||
rank?: string;
|
rank?: string;
|
||||||
@@ -291,38 +291,26 @@ const TeamsAdminPage = () => {
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
// Save override for each variant name so editing one updates all duplicates
|
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
|
||||||
await Promise.all(
|
|
||||||
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
|
|
||||||
if (logoUrl) {
|
if (logoUrl) {
|
||||||
setExternalUploadStatus('uploading');
|
setExternalUploadStatus('uploading');
|
||||||
setExternalUploadError(null);
|
setExternalUploadError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
let logoFileToUpload: File | Blob | null = uploadedFile;
|
||||||
|
|
||||||
// If no file was uploaded but we have a logo URL, fetch it as blob
|
|
||||||
if (!logoFileToUpload && logoUrl) {
|
if (!logoFileToUpload && logoUrl) {
|
||||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logoFileToUpload) {
|
if (logoFileToUpload) {
|
||||||
// Upload to the logo service (loga.sportcreative.eu)
|
|
||||||
const logaResult = await uploadToLogaSportcreative(
|
const logaResult = await uploadToLogaSportcreative(
|
||||||
form.external_team_id,
|
form.external_team_id,
|
||||||
logoFileToUpload,
|
logoFileToUpload,
|
||||||
{
|
{
|
||||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (logaResult.success) {
|
if (logaResult.success) {
|
||||||
setExternalUploadStatus('success');
|
setExternalUploadStatus('success');
|
||||||
// Use the URL from loga.sportcreative.eu
|
|
||||||
if (logaResult.url) {
|
if (logaResult.url) {
|
||||||
logoUrl = logaResult.url;
|
logoUrl = logaResult.url;
|
||||||
}
|
}
|
||||||
@@ -339,7 +327,12 @@ const TeamsAdminPage = () => {
|
|||||||
setExternalUploadError(error?.message || 'Upload failed');
|
setExternalUploadError(error?.message || 'Upload failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
return true;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -495,12 +488,10 @@ const TeamsAdminPage = () => {
|
|||||||
<Td py={1.5} fontSize="xs">{r.rank}</Td>
|
<Td py={1.5} fontSize="xs">{r.rank}</Td>
|
||||||
<Td py={1.5}>
|
<Td py={1.5}>
|
||||||
<HStack spacing={2} align="center">
|
<HStack spacing={2} align="center">
|
||||||
<TeamLogo
|
<Image
|
||||||
teamId={(r as any).team_id}
|
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
|
||||||
teamName={r.team}
|
|
||||||
facrLogo={r.team_logo_url}
|
|
||||||
size="small"
|
|
||||||
alt={r.team}
|
alt={r.team}
|
||||||
|
boxSize="24px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
/>
|
/>
|
||||||
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
|
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: 'admin' | 'editor';
|
role: 'admin' | 'editor' | 'fan';
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ const UsersAdminPage = () => {
|
|||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
role: 'editor' as 'admin' | 'editor',
|
role: 'editor' as 'admin' | 'editor' | 'fan',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -254,8 +254,8 @@ const UsersAdminPage = () => {
|
|||||||
<Td>{user.name}</Td>
|
<Td>{user.name}</Td>
|
||||||
<Td>{user.email}</Td>
|
<Td>{user.email}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
|
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
|
||||||
{user.role === 'admin' ? 'Admin' : 'Editor'}
|
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
@@ -385,6 +385,7 @@ const UsersAdminPage = () => {
|
|||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
>
|
>
|
||||||
|
<option value="fan">Fan</option>
|
||||||
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
|
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGene
|
|||||||
return parsedData;
|
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 {
|
export interface AIGenerateAboutReq {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
club_name?: string;
|
club_name?: string;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
|
|||||||
// Content - Obsah
|
// Content - Obsah
|
||||||
{ name: 'news', label: 'Novinky', description: 'Nejnovější články a zprávy', icon: FaNewspaper, category: 'content', defaultVariant: 'grid' },
|
{ 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', 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: '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: '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' },
|
{ 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: '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: '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: '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: [
|
hero: [
|
||||||
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
|
{ 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: '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' },
|
{ 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: [
|
sponsors: [
|
||||||
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
|
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
|
||||||
{ value: 'slider', label: 'Posuvník', description: 'Animovaný posuvník' },
|
{ value: 'slider', label: 'Posuvník', description: 'Animovaný posuvník' },
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface PollOption {
|
|||||||
export interface PollVoteRequest {
|
export interface PollVoteRequest {
|
||||||
option_ids: number[];
|
option_ids: number[];
|
||||||
session_token?: string;
|
session_token?: string;
|
||||||
|
voter_name?: string;
|
||||||
|
voter_email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollResult {
|
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 });
|
const { data } = await api.post<{ message: string }>(`/newsletter/unsubscribe-token`, { token });
|
||||||
return data;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
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) {
|
module.exports = function(app) {
|
||||||
// Proxy /uploads requests to backend
|
// Proxy /uploads requests to backend
|
||||||
app.use(
|
app.use(
|
||||||
'/uploads',
|
'/uploads',
|
||||||
createProxyMiddleware({
|
createProxyMiddleware({
|
||||||
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
|
target: resolveBackendOrigin(),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
logLevel: 'debug',
|
logLevel: 'debug',
|
||||||
onError: (err, req, res) => {
|
onError: (err, req, res) => {
|
||||||
@@ -18,7 +29,7 @@ module.exports = function(app) {
|
|||||||
app.use(
|
app.use(
|
||||||
'/static',
|
'/static',
|
||||||
createProxyMiddleware({
|
createProxyMiddleware({
|
||||||
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
|
target: resolveBackendOrigin(),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
logLevel: 'debug',
|
logLevel: 'debug',
|
||||||
onError: (err, req, res) => {
|
onError: (err, req, res) => {
|
||||||
@@ -31,7 +42,7 @@ module.exports = function(app) {
|
|||||||
app.use(
|
app.use(
|
||||||
'/cache',
|
'/cache',
|
||||||
createProxyMiddleware({
|
createProxyMiddleware({
|
||||||
target: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080',
|
target: resolveBackendOrigin(),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
logLevel: 'debug',
|
logLevel: 'debug',
|
||||||
onError: (err, req, res) => {
|
onError: (err, req, res) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface MapCoordinates {
|
|||||||
source: 'mapy.cz' | 'google-maps' | 'unknown';
|
source: 'mapy.cz' | 'google-maps' | 'unknown';
|
||||||
// Detailed address components from reverse geocoding
|
// Detailed address components from reverse geocoding
|
||||||
street?: string;
|
street?: string;
|
||||||
|
houseNumber?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
zip?: string;
|
zip?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
@@ -210,6 +211,7 @@ export async function reverseGeocode(lat: number, lng: number): Promise<Partial<
|
|||||||
return {
|
return {
|
||||||
address: data.display_name,
|
address: data.display_name,
|
||||||
street: addr.road || addr.street || addr.pedestrian || addr.footway,
|
street: addr.road || addr.street || addr.pedestrian || addr.footway,
|
||||||
|
houseNumber: addr.house_number,
|
||||||
city: addr.city || addr.town || addr.village || addr.municipality,
|
city: addr.city || addr.town || addr.village || addr.municipality,
|
||||||
zip: addr.postcode,
|
zip: addr.postcode,
|
||||||
country: addr.country || 'Česká republika',
|
country: addr.country || 'Česká republika',
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
|
|||||||
try {
|
try {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = await getCachedLogo(teamId);
|
const cached = await getCachedLogo(teamId);
|
||||||
if (cached?.url) {
|
if (cached?.url && !cached.url.startsWith('blob:')) {
|
||||||
await updateLastUsed(teamId);
|
await updateLastUsed(teamId);
|
||||||
return cached.url;
|
return cached.url;
|
||||||
}
|
}
|
||||||
@@ -230,15 +230,10 @@ export const fetchLogoFromLogoAPI = async (teamId: string, teamName?: string): P
|
|||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
|
|
||||||
const data = await res.json();
|
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;
|
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
|
// Cache the logo
|
||||||
await saveCachedLogo({
|
await saveCachedLogo({
|
||||||
id: teamId,
|
id: teamId,
|
||||||
|
|||||||
@@ -20,6 +20,96 @@ type AIController struct {
|
|||||||
DB *gorm.DB
|
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
|
// GenerateAboutPage creates about page content using the OpenRouter API
|
||||||
func (ac *AIController) GenerateAboutPage(c *gin.Context) {
|
func (ac *AIController) GenerateAboutPage(c *gin.Context) {
|
||||||
var req aiAboutRequest
|
var req aiAboutRequest
|
||||||
@@ -194,6 +284,20 @@ type aiAboutResponse struct {
|
|||||||
SEODescription string `json:"seo_description"`
|
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)
|
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
|
||||||
func (ac *AIController) GenerateBlog(c *gin.Context) {
|
func (ac *AIController) GenerateBlog(c *gin.Context) {
|
||||||
var req aiBlogRequest
|
var req aiBlogRequest
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func NewArticleController(db *gorm.DB) *ArticleController {
|
|||||||
// CreateArticleRequest represents the request body for creating an article
|
// CreateArticleRequest represents the request body for creating an article
|
||||||
type CreateArticleRequest struct {
|
type CreateArticleRequest struct {
|
||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content"`
|
||||||
CategoryID *uint `json:"category_id"`
|
CategoryID *uint `json:"category_id"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
@@ -138,6 +138,10 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
|
|||||||
if req.Published != nil {
|
if req.Published != nil {
|
||||||
published = *req.Published
|
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
|
var publishedAt *time.Time
|
||||||
if req.PublishedAt != nil && strings.TrimSpace(*req.PublishedAt) != "" {
|
if req.PublishedAt != nil && strings.TrimSpace(*req.PublishedAt) != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, *req.PublishedAt); err == nil {
|
if t, err := time.Parse(time.RFC3339, *req.PublishedAt); err == nil {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ func (ac *AuthController) Register(c *gin.Context) {
|
|||||||
// Check if this is the first user (admin)
|
// Check if this is the first user (admin)
|
||||||
var userCount int64
|
var userCount int64
|
||||||
ac.DB.Model(&models.User{}).Count(&userCount)
|
ac.DB.Model(&models.User{}).Count(&userCount)
|
||||||
role := "editor"
|
role := "fan"
|
||||||
isFirstUser := userCount == 0
|
isFirstUser := userCount == 0
|
||||||
if isFirstUser {
|
if isFirstUser {
|
||||||
role = "admin"
|
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))})
|
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
|
// AdminExists returns whether any admin user exists
|
||||||
func (ac *AuthController) AdminExists(c *gin.Context) {
|
func (ac *AuthController) AdminExists(c *gin.Context) {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -435,8 +468,8 @@ func (ac *AuthController) AdminCreateUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// role
|
// role
|
||||||
role := req.Role
|
role := strings.TrimSpace(req.Role)
|
||||||
if role != "admin" && role != "editor" {
|
if role != "admin" && role != "editor" && role != "fan" {
|
||||||
role = "editor"
|
role = "editor"
|
||||||
}
|
}
|
||||||
// active
|
// active
|
||||||
@@ -527,7 +560,7 @@ func (ac *AuthController) AdminUpdateUser(c *gin.Context) {
|
|||||||
user.Email = email
|
user.Email = email
|
||||||
}
|
}
|
||||||
if req.Role != "" {
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,31 @@ type BaseController struct {
|
|||||||
DB *gorm.DB
|
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)
|
// GetMatchesHistory returns cached past matches with overrides applied (public)
|
||||||
// Optional query: q= filters by home/away/venue/competition
|
// Optional query: q= filters by home/away/venue/competition
|
||||||
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
|
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
|
||||||
@@ -600,6 +625,24 @@ func (bc *BaseController) CreateCategory(c *gin.Context) {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Description: strings.TrimSpace(body.Description),
|
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 {
|
if err := bc.DB.Create(&cat).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
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"`
|
ClubName string `json:"club_name"`
|
||||||
ClubLogoURL string `json:"club_logo_url"`
|
ClubLogoURL string `json:"club_logo_url"`
|
||||||
ClubURL string `json:"club_url"`
|
ClubURL string `json:"club_url"`
|
||||||
|
|
||||||
// Social profiles (optional)
|
// Social profiles (optional)
|
||||||
FacebookURL string `json:"facebook_url"`
|
FacebookURL string `json:"facebook_url"`
|
||||||
InstagramURL string `json:"instagram_url"`
|
InstagramURL string `json:"instagram_url"`
|
||||||
YoutubeURL string `json:"youtube_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"`
|
PrimaryColor string `json:"primary_color"`
|
||||||
SecondaryColor string `json:"secondary_color"`
|
SecondaryColor string `json:"secondary_color"`
|
||||||
AccentColor string `json:"accent_color"`
|
AccentColor string `json:"accent_color"`
|
||||||
@@ -1726,7 +1790,9 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
|||||||
TextColor string `json:"text_color"`
|
TextColor string `json:"text_color"`
|
||||||
FontHeading string `json:"font_heading"`
|
FontHeading string `json:"font_heading"`
|
||||||
FontBody string `json:"font_body"`
|
FontBody string `json:"font_body"`
|
||||||
SMTP *struct {
|
|
||||||
|
// SMTP optional
|
||||||
|
SMTP *struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -1789,24 +1855,36 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
|||||||
s.YoutubeURL = v
|
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 body.SMTP != nil {
|
||||||
if host := strings.TrimSpace(body.SMTP.Host); host != "" {
|
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
|
||||||
s.SMTPHost = host
|
s.SMTPHost = v
|
||||||
}
|
}
|
||||||
if body.SMTP.Port > 0 {
|
if body.SMTP.Port > 0 {
|
||||||
s.SMTPPort = body.SMTP.Port
|
s.SMTPPort = body.SMTP.Port
|
||||||
}
|
}
|
||||||
if u := strings.TrimSpace(body.SMTP.Username); u != "" {
|
if v := strings.TrimSpace(body.SMTP.Username); v != "" {
|
||||||
s.SMTPUser = u
|
s.SMTPUser = v
|
||||||
s.SMTPAuth = true
|
s.SMTPAuth = true
|
||||||
}
|
}
|
||||||
if p := body.SMTP.Password; p != "" {
|
if v := body.SMTP.Password; v != "" {
|
||||||
s.SMTPPassword = p
|
s.SMTPPassword = v
|
||||||
}
|
}
|
||||||
if from := strings.TrimSpace(body.SMTP.From); from != "" {
|
if v := strings.TrimSpace(body.SMTP.From); v != "" {
|
||||||
s.SMTPFrom = from
|
s.SMTPFrom = v
|
||||||
}
|
}
|
||||||
|
// Default FromName if empty
|
||||||
if s.SMTPFromName == "" {
|
if s.SMTPFromName == "" {
|
||||||
s.SMTPFromName = "Fotbal Club"
|
s.SMTPFromName = "Fotbal Club"
|
||||||
}
|
}
|
||||||
@@ -1834,7 +1912,6 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger background prefetch and YouTube cache refresh when settings are updated post-setup
|
// Trigger background prefetch and YouTube cache refresh when settings are updated post-setup
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if c.Request.TLS != nil {
|
if c.Request.TLS != nil {
|
||||||
@@ -2035,7 +2112,7 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
|||||||
s.ContactCountry = v
|
s.ContactCountry = v
|
||||||
}
|
}
|
||||||
if v := strings.TrimSpace(body.ContactPhone); v != "" {
|
if v := strings.TrimSpace(body.ContactPhone); v != "" {
|
||||||
s.ContactPhone = v
|
s.ContactPhone = normalizePhone(v, body.ContactCountry)
|
||||||
}
|
}
|
||||||
if v := strings.TrimSpace(body.ContactEmail); v != "" {
|
if v := strings.TrimSpace(body.ContactEmail); v != "" {
|
||||||
s.ContactEmail = v
|
s.ContactEmail = v
|
||||||
@@ -2530,7 +2607,25 @@ func (bc *BaseController) CreateArticle(c *gin.Context) {
|
|||||||
var cat models.Category
|
var cat models.Category
|
||||||
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
|
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// Create category with unique slug
|
||||||
cat = models.Category{Name: name}
|
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 {
|
if err := bc.DB.Create(&cat).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
||||||
return
|
return
|
||||||
@@ -2685,6 +2780,8 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
|||||||
YouTubeVideoTitle *string `json:"youtube_video_title"`
|
YouTubeVideoTitle *string `json:"youtube_video_title"`
|
||||||
YouTubeVideoURL *string `json:"youtube_video_url"`
|
YouTubeVideoURL *string `json:"youtube_video_url"`
|
||||||
YouTubeVideoThumbnail *string `json:"youtube_video_thumbnail"`
|
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
|
var body reqBody
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
@@ -2729,7 +2826,25 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
|||||||
var cat models.Category
|
var cat models.Category
|
||||||
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
|
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// Create category with unique slug
|
||||||
cat = models.Category{Name: name}
|
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 {
|
if err := bc.DB.Create(&cat).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
||||||
return
|
return
|
||||||
@@ -2758,8 +2873,25 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
|||||||
art.Featured = *body.Featured
|
art.Featured = *body.Featured
|
||||||
}
|
}
|
||||||
if body.Slug != nil {
|
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 {
|
if body.SeoTitle != nil {
|
||||||
art.SEOTitle = strings.TrimSpace(*body.SeoTitle)
|
art.SEOTitle = strings.TrimSpace(*body.SeoTitle)
|
||||||
}
|
}
|
||||||
@@ -2791,6 +2923,12 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
|||||||
if body.YouTubeVideoThumbnail != nil {
|
if body.YouTubeVideoThumbnail != nil {
|
||||||
art.YouTubeVideoThumbnail = strings.TrimSpace(*body.YouTubeVideoThumbnail)
|
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
|
// Auto-fill SEO if still empty after updates
|
||||||
if strings.TrimSpace(art.SEOTitle) == "" && strings.TrimSpace(art.Title) != "" {
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
if art.ImageURL == "" {
|
if art.ImageURL == "" {
|
||||||
@@ -3108,13 +3246,46 @@ func (bc *BaseController) CreatePlayer(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
|
||||||
return
|
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í 0–99"})
|
||||||
|
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í 50–250 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í 30–200 kg"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
p := models.Player{
|
p := models.Player{
|
||||||
FirstName: strings.TrimSpace(body.FirstName),
|
FirstName: first,
|
||||||
LastName: strings.TrimSpace(body.LastName),
|
LastName: last,
|
||||||
Position: strings.TrimSpace(body.Position),
|
Position: strings.TrimSpace(body.Position),
|
||||||
Nationality: strings.TrimSpace(body.Nationality),
|
Nationality: strings.TrimSpace(body.Nationality),
|
||||||
Email: strings.TrimSpace(body.Email),
|
Email: strings.TrimSpace(body.Email),
|
||||||
Phone: strings.TrimSpace(body.Phone),
|
Phone: normalizePhone(body.Phone, ""),
|
||||||
ImageURL: strings.TrimSpace(body.ImageURL),
|
ImageURL: strings.TrimSpace(body.ImageURL),
|
||||||
}
|
}
|
||||||
if body.TeamID != nil {
|
if body.TeamID != nil {
|
||||||
@@ -3204,6 +3375,18 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
|
|||||||
if body.LastName != nil {
|
if body.LastName != nil {
|
||||||
p.LastName = strings.TrimSpace(*body.LastName)
|
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 {
|
if body.Position != nil {
|
||||||
p.Position = strings.TrimSpace(*body.Position)
|
p.Position = strings.TrimSpace(*body.Position)
|
||||||
}
|
}
|
||||||
@@ -3220,15 +3403,27 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
|
|||||||
p.TeamID = *body.TeamID
|
p.TeamID = *body.TeamID
|
||||||
}
|
}
|
||||||
if body.JerseyNumber != nil {
|
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í 0–99"})
|
||||||
|
return
|
||||||
|
}
|
||||||
p.JerseyNumber = *body.JerseyNumber
|
p.JerseyNumber = *body.JerseyNumber
|
||||||
}
|
}
|
||||||
if body.Nationality != nil {
|
if body.Nationality != nil {
|
||||||
p.Nationality = strings.TrimSpace(*body.Nationality)
|
p.Nationality = strings.TrimSpace(*body.Nationality)
|
||||||
}
|
}
|
||||||
if body.Height != nil {
|
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í 50–250 cm"})
|
||||||
|
return
|
||||||
|
}
|
||||||
p.Height = *body.Height
|
p.Height = *body.Height
|
||||||
}
|
}
|
||||||
if body.Weight != nil {
|
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í 30–200 kg"})
|
||||||
|
return
|
||||||
|
}
|
||||||
p.Weight = *body.Weight
|
p.Weight = *body.Weight
|
||||||
}
|
}
|
||||||
if body.IsActive != nil {
|
if body.IsActive != nil {
|
||||||
@@ -3238,7 +3433,7 @@ func (bc *BaseController) UpdatePlayer(c *gin.Context) {
|
|||||||
p.Email = strings.TrimSpace(*body.Email)
|
p.Email = strings.TrimSpace(*body.Email)
|
||||||
}
|
}
|
||||||
if body.Phone != nil {
|
if body.Phone != nil {
|
||||||
p.Phone = strings.TrimSpace(*body.Phone)
|
p.Phone = normalizePhone(*body.Phone, "")
|
||||||
}
|
}
|
||||||
if body.ImageURL != nil {
|
if body.ImageURL != nil {
|
||||||
p.ImageURL = strings.TrimSpace(*body.ImageURL)
|
p.ImageURL = strings.TrimSpace(*body.ImageURL)
|
||||||
@@ -4067,7 +4262,7 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var body reqBody
|
var body reqBody
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4336,7 +4531,12 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
|
|||||||
s.ContactCountry = strings.TrimSpace(*body.ContactCountry)
|
s.ContactCountry = strings.TrimSpace(*body.ContactCountry)
|
||||||
}
|
}
|
||||||
if body.ContactPhone != nil {
|
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 {
|
if body.ContactEmail != nil {
|
||||||
s.ContactEmail = strings.TrimSpace(*body.ContactEmail)
|
s.ContactEmail = strings.TrimSpace(*body.ContactEmail)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -26,6 +27,32 @@ type ContactController struct {
|
|||||||
emailService email.EmailService
|
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)
|
// 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" }
|
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
|
||||||
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||||
@@ -921,18 +948,71 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
|
|||||||
return
|
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
|
// Send setup email (link with token) AND welcome introduction email in goroutines
|
||||||
go func() {
|
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
|
// 1) Setup email
|
||||||
setupEmail := &email.EmailData{
|
setupEmail := &email.EmailData{
|
||||||
Subject: "Nastavte svůj newsletter",
|
Subject: "Nastavte svůj newsletter",
|
||||||
|
|||||||
@@ -357,14 +357,14 @@ func (pc *PollController) DeletePoll(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Poll deleted successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Poll deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vote handles vote submission
|
|
||||||
func (pc *PollController) Vote(c *gin.Context) {
|
func (pc *PollController) Vote(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
var input struct {
|
var input struct {
|
||||||
OptionIDs []uint `json:"option_ids" binding:"required,min=1"`
|
OptionIDs []uint `json:"option_ids" binding:"required,min=1"`
|
||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
|
VoterName string `json:"voter_name"`
|
||||||
|
VoterEmail string `json:"voter_email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
@@ -412,6 +412,12 @@ func (pc *PollController) Vote(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not authenticated, don't persist personal info even if provided
|
||||||
|
if !hasUser {
|
||||||
|
input.VoterName = ""
|
||||||
|
input.VoterEmail = ""
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already voted
|
// Check if already voted
|
||||||
ipHash := pc.hashIP(c.ClientIP())
|
ipHash := pc.hashIP(c.ClientIP())
|
||||||
sessionToken := input.SessionToken
|
sessionToken := input.SessionToken
|
||||||
@@ -462,6 +468,8 @@ func (pc *PollController) Vote(c *gin.Context) {
|
|||||||
IPHash: ipHash,
|
IPHash: ipHash,
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
SessionToken: sessionToken,
|
SessionToken: sessionToken,
|
||||||
|
VoterName: input.VoterName,
|
||||||
|
VoterEmail: input.VoterEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasUser {
|
if hasUser {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type User struct {
|
|||||||
Password string `gorm:"not null" json:"-"`
|
Password string `gorm:"not null" json:"-"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_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"`
|
IsActive bool `gorm:"default:true"`
|
||||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type PollVote struct {
|
|||||||
IPHash string `gorm:"size:64;index" json:"ip_hash"` // Hashed IP for duplicate prevention
|
IPHash string `gorm:"size:64;index" json:"ip_hash"` // Hashed IP for duplicate prevention
|
||||||
UserAgent string `gorm:"size:500" json:"user_agent"`
|
UserAgent string `gorm:"size:500" json:"user_agent"`
|
||||||
SessionToken string `gorm:"size:100;index" json:"session_token"` // For guest vote tracking
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,11 +121,18 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
protected.Use(middleware.JWTAuth(db))
|
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 endpoints (protected)
|
||||||
ai := protected.Group("/ai")
|
ai := protected.Group("/ai")
|
||||||
{
|
{
|
||||||
ai.POST("/blog/generate", aiController.GenerateBlog)
|
ai.POST("/blog/generate", aiController.GenerateBlog)
|
||||||
ai.POST("/about/generate", aiController.GenerateAboutPage)
|
ai.POST("/about/generate", aiController.GenerateAboutPage)
|
||||||
|
ai.POST("/css/generate", aiController.GenerateCSS)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User profile
|
// User profile
|
||||||
@@ -137,6 +144,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
|
|
||||||
// Events (protected)
|
// Events (protected)
|
||||||
protectedEvents := protected.Group("/events")
|
protectedEvents := protected.Group("/events")
|
||||||
|
protectedEvents.Use(middleware.RoleAuth("editor"))
|
||||||
{
|
{
|
||||||
protectedEvents.POST("", eventController.CreateEvent)
|
protectedEvents.POST("", eventController.CreateEvent)
|
||||||
protectedEvents.PUT("/:id", eventController.UpdateEvent)
|
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 - accessible by editors and admins)
|
||||||
articles := protected.Group("/articles")
|
articles := protected.Group("/articles")
|
||||||
|
articles.Use(middleware.RoleAuth("editor"))
|
||||||
{
|
{
|
||||||
articles.POST("", articleController.CreateArticle)
|
articles.POST("", articleController.CreateArticle)
|
||||||
articles.PUT("/:id", baseController.UpdateArticle)
|
articles.PUT("/:id", baseController.UpdateArticle)
|
||||||
@@ -156,6 +165,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
|
|
||||||
// Teams (protected)
|
// Teams (protected)
|
||||||
teams := protected.Group("/teams")
|
teams := protected.Group("/teams")
|
||||||
|
teams.Use(middleware.RoleAuth("admin"))
|
||||||
{
|
{
|
||||||
teams.POST("", baseController.CreateTeam)
|
teams.POST("", baseController.CreateTeam)
|
||||||
teams.PUT("/:id", baseController.UpdateTeam)
|
teams.PUT("/:id", baseController.UpdateTeam)
|
||||||
@@ -164,6 +174,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
|
|
||||||
// Players (protected)
|
// Players (protected)
|
||||||
players := protected.Group("/players")
|
players := protected.Group("/players")
|
||||||
|
players.Use(middleware.RoleAuth("admin"))
|
||||||
{
|
{
|
||||||
players.POST("", baseController.CreatePlayer)
|
players.POST("", baseController.CreatePlayer)
|
||||||
players.PUT("/:id", baseController.UpdatePlayer)
|
players.PUT("/:id", baseController.UpdatePlayer)
|
||||||
@@ -172,6 +183,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
|
|
||||||
// Sponsors (protected CRUD)
|
// Sponsors (protected CRUD)
|
||||||
sponsors := protected.Group("/sponsors")
|
sponsors := protected.Group("/sponsors")
|
||||||
|
sponsors.Use(middleware.RoleAuth("admin"))
|
||||||
{
|
{
|
||||||
sponsors.POST("", baseController.CreateSponsor)
|
sponsors.POST("", baseController.CreateSponsor)
|
||||||
sponsors.PUT("/:id", baseController.UpdateSponsor)
|
sponsors.PUT("/:id", baseController.UpdateSponsor)
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ func MigrateDB(db *gorm.DB) error {
|
|||||||
&models.CompetitionAlias{},
|
&models.CompetitionAlias{},
|
||||||
&models.EmailLog{},
|
&models.EmailLog{},
|
||||||
&models.EmailEvent{},
|
&models.EmailEvent{},
|
||||||
|
&models.NewsletterSentLog{},
|
||||||
|
&models.MatchNotification{},
|
||||||
|
&models.BlogNotification{},
|
||||||
&models.PasswordReset{},
|
&models.PasswordReset{},
|
||||||
&models.AboutPage{},
|
&models.AboutPage{},
|
||||||
// Add event tables so public endpoints don't fail before any writes occur
|
// Add event tables so public endpoints don't fail before any writes occur
|
||||||
|
|||||||
@@ -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>E‑mail:</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}}
|
||||||
Reference in New Issue
Block a user