mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #70
This commit is contained in:
Vendored
+12
-12
@@ -2,27 +2,27 @@
|
||||
"items": [
|
||||
{
|
||||
"ID": 1,
|
||||
"CreatedAt": "2025-10-23T12:27:56.795628Z",
|
||||
"UpdatedAt": "2025-10-23T12:30:27.920031Z",
|
||||
"CreatedAt": "2025-10-24T09:14:28.069547Z",
|
||||
"UpdatedAt": "2025-10-24T09:22:33.786377Z",
|
||||
"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",
|
||||
"title": "U9 rozdrtila Žimrovice 30:2 a potvrdila skvělou formu",
|
||||
"content": "\u003ch2\u003eU9 rozdrtila Žimrovice 30:2 a potvrdila skvělou formu\u003c/h2\u003e\u003cp\u003eMladší přípravka U9 potvrdila svou skvělou formu a doma rozstřílela Žimrovice vysokým výsledkem 30:2. Od začátku utkání bylo jasné, že tým jde za vítězstvím, a to se mu nakonec i podařilo. Krnovští mladíci předvedli spoustu krásných akcí a potvrdili svou nadřazenost nad soupeřem.\u003c/p\u003e\u003ch3\u003eDominantní výkon od prvních minut\u003c/h3\u003e\u003cp\u003eVe čtvrtek 16. října 2025 odehrál tým U9 další mistrovské utkání proti Žimrovicím. Od začátku bylo vidět, že Krnovští hrají s velkou energií a chuťou k vítězství. Hráči předvedli spoustu krásných akcí, které byly zakončeny brankami. Již v první půli se tým ujal vedení 12:1, což jasně naznačovalo, jakým směrem půjde utkání.\u003c/p\u003e\u003ch3\u003eRespekt pro soupeře a pochvala pro tým\u003c/h3\u003e\u003cp\u003eTrénerský tým vyjádřil respekt soupeři za bojovnost a velkou pochvalu pro celý tým za výkon, energii a chuť hrát. Hráči dokázali udržet vysokou úroveň po celou hru a ukázali, že jsou v skvělém tvaru. Díky patří také fanouškům, kteří hráče podporovali po celou dobu utkání.\u003c/p\u003e\u003ch3\u003eDetaily utkání\u003c/h3\u003e\u003cul\u003e\u003cli\u003e\u003cstrong\u003eDatum:\u003c/strong\u003e 16. 10. 2025\u003c/li\u003e\u003cli\u003e\u003cstrong\u003eČas:\u003c/strong\u003e 16:30\u003c/li\u003e\u003cli\u003e\u003cstrong\u003eKolo:\u003c/strong\u003e 8. kolo, Mladší přípravka 1+4 sk. C\u003c/li\u003e\u003cli\u003e\u003cstrong\u003eVýsledek:\u003c/strong\u003e 30:2 (12:1)\u003c/li\u003e\u003cli\u003e\u003cstrong\u003eTrénovný tým:\u003c/strong\u003e Petr Kalinovský\u003c/li\u003e\u003c/ul\u003e\u003ch3\u003eJedeme dál a makáme!\u003c/h3\u003e\u003cp\u003eTým U9 pokračuje v mistrovské sezóně s velkou motivací a touhou po dalším vítězství. Hráči a trénovný tým jsou přesvědčeni, že dokážou udržet svou skvělou formu a bojovat o nejvyšší příčky v tabulce. Fanoušci mohou očekávat další zajímavá utkání a krásné fotbalové momenty.\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e",
|
||||
"author_id": 1,
|
||||
"category_id": 1,
|
||||
"image_url": "https://eu.zonerama.com/photos/571035614_1500x1000.jpg",
|
||||
"category_id": 2,
|
||||
"image_url": "https://eu.zonerama.com/photos/572667628_1500x1000.jpg",
|
||||
"published": true,
|
||||
"slug": "a-tymu-porazi-vitkovice-b",
|
||||
"slug": "u9-rozdrtela-zimrovice-30-2",
|
||||
"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",
|
||||
"seo_title": "U9 rozdrtila Žimrovice 30:2 a potvrdila skvělou formu | Fotbalový klub",
|
||||
"seo_description": "Přečtěte si více o u9 rozdrtila žimrovice 30:2 a potvrdila skvělou formu. Aktuální informace, novinky a zajímavosti z našeho fotbalového klubu.",
|
||||
"og_image_url": "https://eu.zonerama.com/photos/572667628_1500x1000.jpg",
|
||||
"external_link": "",
|
||||
"view_count": 0,
|
||||
"read_time": 3,
|
||||
"read_time": 2,
|
||||
"unique_views": 0,
|
||||
"category_name": "",
|
||||
"attachments": "[{\"mime_type\":\"application/pdf\",\"name\":\"pdf-test.pdf\",\"size\":20597,\"url\":\"/uploads/2025/10/20251023-122902-2a8621440a2749f6dcb7b684d9d05486.pdf\"}]",
|
||||
"attachments": "[{\"mime_type\":\"application/pdf\",\"name\":\"pdf-test.pdf\",\"size\":20597,\"url\":\"/uploads/2025/10/20251024-092039-c6c1f2f7ba1428b91c6050e3631685b2.pdf\"}]",
|
||||
"gallery_album_id": "",
|
||||
"gallery_album_url": "",
|
||||
"gallery_photo_ids": "",
|
||||
|
||||
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-23T20:00:11Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","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-23T20:00:11Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","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-23T20:00:13Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"etag":"","fetched_at":"2025-10-23T20:00:16Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"lastUpdated":"2025-10-23T20:00:16Z"}
|
||||
{"lastUpdated":"2025-10-24T12:52:10Z"}
|
||||
Vendored
+12
-12
@@ -1,17 +1,7 @@
|
||||
{
|
||||
"baseURL": "http://127.0.0.1:8080/api/v1",
|
||||
"duration_ms": 5571,
|
||||
"duration_ms": 25,
|
||||
"endpoints": [
|
||||
{
|
||||
"path": "/public/team-logo-overrides",
|
||||
"file": "team_logo_overrides.json",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"path": "/competition-aliases",
|
||||
"file": "competition_aliases.json",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"path": "/settings",
|
||||
"file": "settings.json",
|
||||
@@ -32,6 +22,16 @@
|
||||
"file": "events_upcoming.json",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"path": "/public/team-logo-overrides",
|
||||
"file": "team_logo_overrides.json",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"path": "/competition-aliases",
|
||||
"file": "competition_aliases.json",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
|
||||
"file": "facr_club_info.json",
|
||||
@@ -43,5 +43,5 @@
|
||||
"ok": true
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2025-10-23T20:00:16Z"
|
||||
"lastUpdated": "2025-10-24T12:52:10Z"
|
||||
}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"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"}
|
||||
{"about_html":"","accent_color":"#ffbb00","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":"#ffdd00","secondary_color":"#004cff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":[{"length":"","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","uploaded_at":"2025-10-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-03","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-24","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-24","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-23T20:00:11Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
|
||||
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
{"fetched_at":"2025-10-23T14:00:17Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH%2Fvideos"}
|
||||
{"fetched_at":"2025-10-24T12:52:21Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH%2Fvideos"}
|
||||
Vendored
+4
-13
@@ -1,20 +1,11 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
"id": "572667628",
|
||||
"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",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14045127/572667628",
|
||||
"image_url": "https://eu.zonerama.com/photos/572667628_1500x1000.jpg",
|
||||
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
|
||||
"picked_at": "2025-10-22T18:13:39Z"
|
||||
"picked_at": "2025-10-24T09:20:31Z"
|
||||
}
|
||||
]
|
||||
Vendored
+10
-10
@@ -7,7 +7,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -17,7 +17,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -27,7 +27,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -37,7 +37,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -47,7 +47,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -57,7 +57,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -67,7 +67,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -77,7 +77,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -87,7 +87,7 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
},
|
||||
{
|
||||
"id": "",
|
||||
@@ -97,6 +97,6 @@
|
||||
"photos_count": 0,
|
||||
"views_count": 0,
|
||||
"photos": null,
|
||||
"fetched_at": "2025-10-23T14:00:46Z"
|
||||
"fetched_at": "2025-10-24T12:36:59Z"
|
||||
}
|
||||
]
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"fetched_at": "2025-10-23T14:00:46Z",
|
||||
"fetched_at": "2025-10-24T12:36:59Z",
|
||||
"link": ""
|
||||
}
|
||||
Vendored
+115
-115
@@ -103,7 +103,7 @@
|
||||
"photos_count": 75,
|
||||
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
|
||||
"views_count": 50
|
||||
"views_count": 54
|
||||
},
|
||||
{
|
||||
"date": "12. 10. 2025",
|
||||
@@ -208,112 +208,7 @@
|
||||
"photos_count": 112,
|
||||
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
|
||||
"views_count": 113
|
||||
},
|
||||
{
|
||||
"date": "11. 10. 2025",
|
||||
"id": "14006754",
|
||||
"photos": [
|
||||
{
|
||||
"id": "570604783",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604783_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604783"
|
||||
},
|
||||
{
|
||||
"id": "570604781",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604781_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604781"
|
||||
},
|
||||
{
|
||||
"id": "570604780",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604780_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604780"
|
||||
},
|
||||
{
|
||||
"id": "570604776",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604776_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604776"
|
||||
},
|
||||
{
|
||||
"id": "570604778",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604778_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604778"
|
||||
},
|
||||
{
|
||||
"id": "570604777",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604777_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604777"
|
||||
},
|
||||
{
|
||||
"id": "570604770",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604770_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604770"
|
||||
},
|
||||
{
|
||||
"id": "570604760",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604760_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604760"
|
||||
},
|
||||
{
|
||||
"id": "570604743",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604743_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604743"
|
||||
},
|
||||
{
|
||||
"id": "570604762",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604762_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604762"
|
||||
},
|
||||
{
|
||||
"id": "570604749",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604749_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604749"
|
||||
},
|
||||
{
|
||||
"id": "570604751",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604751_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604751"
|
||||
},
|
||||
{
|
||||
"id": "570604756",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604756_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604756"
|
||||
},
|
||||
{
|
||||
"id": "570604726",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604726_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604726"
|
||||
},
|
||||
{
|
||||
"id": "570604723",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604723_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604723"
|
||||
},
|
||||
{
|
||||
"id": "570604728",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604728_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604728"
|
||||
},
|
||||
{
|
||||
"id": "570604725",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604725_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604725"
|
||||
},
|
||||
{
|
||||
"id": "570604720",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604720_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604720"
|
||||
},
|
||||
{
|
||||
"id": "570604721",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604721_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604721"
|
||||
}
|
||||
],
|
||||
"photos_count": 19,
|
||||
"title": "Kategorie U14 Havířov 6:3 FK Krnov",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14006754",
|
||||
"views_count": 100
|
||||
"views_count": 122
|
||||
},
|
||||
{
|
||||
"date": "11. 10. 2025",
|
||||
@@ -418,7 +313,112 @@
|
||||
"photos_count": 40,
|
||||
"title": "Kategorie U15 Havířov 3:4 FK Krnov",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14006762",
|
||||
"views_count": 96
|
||||
"views_count": 100
|
||||
},
|
||||
{
|
||||
"date": "11. 10. 2025",
|
||||
"id": "14006754",
|
||||
"photos": [
|
||||
{
|
||||
"id": "570604783",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604783_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604783"
|
||||
},
|
||||
{
|
||||
"id": "570604781",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604781_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604781"
|
||||
},
|
||||
{
|
||||
"id": "570604780",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604780_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604780"
|
||||
},
|
||||
{
|
||||
"id": "570604776",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604776_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604776"
|
||||
},
|
||||
{
|
||||
"id": "570604778",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604778_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604778"
|
||||
},
|
||||
{
|
||||
"id": "570604777",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604777_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604777"
|
||||
},
|
||||
{
|
||||
"id": "570604770",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604770_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604770"
|
||||
},
|
||||
{
|
||||
"id": "570604760",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604760_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604760"
|
||||
},
|
||||
{
|
||||
"id": "570604743",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604743_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604743"
|
||||
},
|
||||
{
|
||||
"id": "570604762",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604762_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604762"
|
||||
},
|
||||
{
|
||||
"id": "570604749",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604749_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604749"
|
||||
},
|
||||
{
|
||||
"id": "570604751",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604751_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604751"
|
||||
},
|
||||
{
|
||||
"id": "570604756",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604756_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604756"
|
||||
},
|
||||
{
|
||||
"id": "570604726",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604726_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604726"
|
||||
},
|
||||
{
|
||||
"id": "570604723",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604723_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604723"
|
||||
},
|
||||
{
|
||||
"id": "570604728",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604728_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604728"
|
||||
},
|
||||
{
|
||||
"id": "570604725",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604725_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604725"
|
||||
},
|
||||
{
|
||||
"id": "570604720",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604720_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604720"
|
||||
},
|
||||
{
|
||||
"id": "570604721",
|
||||
"image_1500": "https://eu.zonerama.com/photos/570604721_1500x1000.jpg",
|
||||
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14006754/570604721"
|
||||
}
|
||||
],
|
||||
"photos_count": 19,
|
||||
"title": "Kategorie U14 Havířov 6:3 FK Krnov",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14006754",
|
||||
"views_count": 106
|
||||
},
|
||||
{
|
||||
"date": "4. 10. 2025",
|
||||
@@ -523,7 +523,7 @@
|
||||
"photos_count": 79,
|
||||
"title": "Kategorie U15 FK Krnov 0:1 Hlučín",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967265",
|
||||
"views_count": 126
|
||||
"views_count": 130
|
||||
},
|
||||
{
|
||||
"date": "4. 10. 2025",
|
||||
@@ -618,7 +618,7 @@
|
||||
"photos_count": 50,
|
||||
"title": "Kategorie U14 FK Krnov 0:6 Hlučín",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13967247",
|
||||
"views_count": 149
|
||||
"views_count": 154
|
||||
},
|
||||
{
|
||||
"date": "28. 9. 2025",
|
||||
@@ -728,7 +728,7 @@
|
||||
"photos_count": 65,
|
||||
"title": "Kategorie muži FK Krnov 2:3 TJ Sokol Háj ve Slezsku",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13939668",
|
||||
"views_count": 177
|
||||
"views_count": 183
|
||||
},
|
||||
{
|
||||
"date": "20. 9. 2025",
|
||||
@@ -838,7 +838,7 @@
|
||||
"photos_count": 101,
|
||||
"title": "Kategorie U15 FK Krnov 2:5 Nový Jičín",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903610",
|
||||
"views_count": 155
|
||||
"views_count": 157
|
||||
},
|
||||
{
|
||||
"date": "20. 9. 2025",
|
||||
@@ -953,7 +953,7 @@
|
||||
"photos_count": 55,
|
||||
"title": "Kategorie U14 FK Krnov 1:12 Nový Jičín",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13903599",
|
||||
"views_count": 133
|
||||
"views_count": 139
|
||||
},
|
||||
{
|
||||
"date": "17. 9. 2025",
|
||||
@@ -1063,9 +1063,9 @@
|
||||
"photos_count": 55,
|
||||
"title": "Kategorie U15 Třinec 1:4 FK Krnov",
|
||||
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/13883373",
|
||||
"views_count": 120
|
||||
"views_count": 121
|
||||
}
|
||||
],
|
||||
"fetched_at": "2025-10-23T14:00:46Z",
|
||||
"fetched_at": "2025-10-24T12:36:59Z",
|
||||
"input_link": "https://eu.zonerama.com/FKKofolaKrnov/1470757"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add style column to polls
|
||||
ALTER TABLE polls ADD COLUMN IF NOT EXISTS style VARCHAR(50) NOT NULL DEFAULT 'auto';
|
||||
|
||||
-- Backfill empty strings to default (optional safety)
|
||||
UPDATE polls SET style = 'auto' WHERE style IS NULL OR style = '';
|
||||
@@ -287,11 +287,18 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
const role = user?.role;
|
||||
const role = String(user?.role || '').toLowerCase();
|
||||
if (role === 'admin') {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
if (role === 'editor') {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
if (role === 'fan') {
|
||||
return <Navigate to="/semiadmin" replace />;
|
||||
}
|
||||
return <Navigate to="/admin" replace />;
|
||||
// Default: regular users to frontpage
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// If setup is required, redirect to setup wizard unless already on setup
|
||||
|
||||
@@ -79,10 +79,11 @@ const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?:
|
||||
};
|
||||
|
||||
// Mobile menu component
|
||||
const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
|
||||
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
isAuthenticated: boolean;
|
||||
menuBg: string;
|
||||
dividerColor: string;
|
||||
settings?: any;
|
||||
@@ -232,6 +233,18 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<Divider my={2} borderColor={dividerColor} />
|
||||
<Button as={RouterLink} to="/login" colorScheme="blue" justifyContent="flex-start">
|
||||
Přihlásit se
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/register" variant="outline" justifyContent="flex-start">
|
||||
Registrovat se
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
@@ -662,7 +675,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
boxShadow={scrolled ? 'sm' : 'none'}
|
||||
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} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
|
||||
<Container maxW={containerMaxW}>
|
||||
<Flex h={16} alignItems="center" justifyContent="space-between">
|
||||
<HStack spacing={4} alignItems="center">
|
||||
@@ -774,6 +787,33 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
/>
|
||||
|
||||
{/* Auth buttons (desktop) */}
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/register"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
display={{ base: 'none', md: 'inline-flex' }}
|
||||
ml={2}
|
||||
mr={2}
|
||||
>
|
||||
Registrovat se
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/login"
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
display={{ base: 'none', md: 'inline-flex' }}
|
||||
mr={2}
|
||||
>
|
||||
Přihlásit se
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
@@ -845,6 +885,10 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
// HoverMenu component for desktop dropdown nav
|
||||
const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: string; to: string }[]; isActive?: boolean }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const menuColorActive = useColorModeValue('brand.primary', 'brand.accent');
|
||||
const menuColorInactive = useColorModeValue('gray.700', 'gray.200');
|
||||
const menuBgActive = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
|
||||
const menuHoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
|
||||
return (
|
||||
<Box onMouseEnter={onOpen} onMouseLeave={onClose}>
|
||||
<Menu isOpen={isOpen} placement="bottom-start" gutter={4}>
|
||||
@@ -855,9 +899,9 @@ const HoverMenu = ({ label, items, isActive }: { label: string; items: { label:
|
||||
size="sm"
|
||||
px={3}
|
||||
fontWeight={isActive ? '700' : '600'}
|
||||
color={useColorModeValue(isActive ? 'brand.primary' : 'gray.700', isActive ? 'brand.accent' : 'gray.200')}
|
||||
bg={isActive ? useColorModeValue('blackAlpha.50', 'whiteAlpha.100') : 'transparent'}
|
||||
_hover={{ bg: useColorModeValue('blackAlpha.100', 'whiteAlpha.200'), transform: 'translateY(-1px)' }}
|
||||
color={isActive ? menuColorActive : menuColorInactive}
|
||||
bg={isActive ? menuBgActive : 'transparent'}
|
||||
_hover={{ bg: menuHoverBg, transform: 'translateY(-1px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -53,6 +53,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'single',
|
||||
style: 'auto',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
@@ -199,6 +200,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'single',
|
||||
style: 'auto',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
@@ -447,6 +449,29 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Styl</FormLabel>
|
||||
<Select
|
||||
size="sm"
|
||||
value={(newPollData as any).style || 'auto'}
|
||||
onChange={(e) => setNewPollData(prev => ({ ...prev, style: e.target.value as any }))}
|
||||
>
|
||||
<option value="auto">Automaticky</option>
|
||||
{newPollData.type === 'rating' ? (
|
||||
<>
|
||||
<option value="rating-stars">Hvězdičky</option>
|
||||
<option value="rating-scale">Číselná stupnice</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="choices-list">Seznam</option>
|
||||
<option value="choices-chips">Štítky</option>
|
||||
<option value="choices-cards">Karty</option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Možnosti (min. 2)</FormLabel>
|
||||
<VStack spacing={2} align="stretch">
|
||||
@@ -478,6 +503,42 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
<HStack>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení zápasu',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-stars',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>⭐ 5</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení (1–10)',
|
||||
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-scale',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>1–10</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Docházka',
|
||||
description: 'Dej vědět, zda dorazíš.',
|
||||
type: 'single',
|
||||
style: 'choices-chips',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Ne', display_order: 1 },
|
||||
{ text: 'Možná', display_order: 2 },
|
||||
]
|
||||
}))}>Docházka</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [imageWidth, setImageWidth] = useState<number>(0);
|
||||
const [manualWidth, setManualWidth] = useState<string>('');
|
||||
const [widthPercent, setWidthPercent] = useState<number>(0);
|
||||
|
||||
// Define toolbar configurations
|
||||
const toolbarConfigs = {
|
||||
@@ -499,12 +500,17 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
img.style.width = `${newWidth}px`;
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.height = 'auto';
|
||||
try { img.setAttribute('width', String(Math.round(newWidth))); } catch {}
|
||||
setImageWidth(newWidth);
|
||||
setManualWidth(newWidth.toString());
|
||||
try {
|
||||
const editorWidth = editor.root.clientWidth || newWidth || 1;
|
||||
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
|
||||
} catch {}
|
||||
updateHandlePositions();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
const onMouseUp: (ev: MouseEvent) => void = () => {
|
||||
isResizing = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
@@ -557,6 +563,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
const currentWidth = img.offsetWidth || img.width;
|
||||
setImageWidth(currentWidth);
|
||||
setManualWidth(currentWidth.toString());
|
||||
try {
|
||||
const editorWidth = editor.root.clientWidth || currentWidth || 1;
|
||||
setWidthPercent(Math.max(1, Math.min(100, Math.round((currentWidth / editorWidth) * 100))));
|
||||
} catch {}
|
||||
|
||||
// Load saved filters
|
||||
const filtersData = img.getAttribute('data-filters');
|
||||
@@ -665,10 +675,59 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'IMG' && selectedImage === target) {
|
||||
// Only enable dragging if clicking directly on the image (not on resize handle)
|
||||
// Allow edge-drag fallback resize if overlay handle doesn't catch it
|
||||
const rect = target.getBoundingClientRect();
|
||||
const isNearEdge = (e.clientX > rect.right - 20 || e.clientY > rect.bottom - 20);
|
||||
if (isNearEdge) return; // Let resize handle take over
|
||||
const nearLeft = e.clientX < rect.left + 16;
|
||||
const nearRight = e.clientX > rect.right - 16;
|
||||
const nearTop = e.clientY < rect.top + 16;
|
||||
const nearBottom = e.clientY > rect.bottom - 16;
|
||||
if (nearLeft || nearRight || nearTop || nearBottom) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startWidth = (target as HTMLImageElement).offsetWidth;
|
||||
const startHeight = (target as HTMLImageElement).offsetHeight;
|
||||
const aspectRatio = startWidth / Math.max(1, startHeight);
|
||||
const edge = nearRight ? 'right' : nearLeft ? 'left' : nearBottom ? 'bottom' : 'top';
|
||||
|
||||
const onMouseMove: (ev: MouseEvent) => void = (ev: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
const deltaX = ev.clientX - startX;
|
||||
const deltaY = ev.clientY - startY;
|
||||
let newWidth = startWidth;
|
||||
if (edge === 'right') newWidth = startWidth + deltaX;
|
||||
else if (edge === 'left') newWidth = startWidth - deltaX;
|
||||
else if (edge === 'bottom') newWidth = startWidth + (deltaY * aspectRatio);
|
||||
else if (edge === 'top') newWidth = startWidth - (deltaY * aspectRatio);
|
||||
const maxW = editor.root.clientWidth - 40;
|
||||
newWidth = Math.max(50, Math.min(newWidth, maxW));
|
||||
const imgEl = target as HTMLImageElement;
|
||||
imgEl.style.width = `${newWidth}px`;
|
||||
imgEl.style.maxWidth = '100%';
|
||||
imgEl.style.height = 'auto';
|
||||
try { imgEl.setAttribute('width', String(Math.round(newWidth))); } catch {}
|
||||
setImageWidth(newWidth);
|
||||
setManualWidth(String(Math.round(newWidth)));
|
||||
try {
|
||||
const editorWidth = editor.root.clientWidth || newWidth || 1;
|
||||
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
|
||||
} catch {}
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isResizing = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
onChangeRef.current(editor.root.innerHTML);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -690,7 +749,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
// Already set in selectImage, but ensure it's off
|
||||
target.setAttribute('draggable', 'false');
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const onMouseMove: (e: MouseEvent) => void = (e: MouseEvent) => {
|
||||
if (!isDragging || !selectedImage) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
@@ -718,7 +777,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
const onMouseUp: (e: MouseEvent) => void = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
@@ -943,30 +1002,74 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
}
|
||||
}, [selectedImageElement, toast]);
|
||||
|
||||
const applyWidthPx = useCallback((px: number, opts?: { silent?: boolean }) => {
|
||||
if (!selectedImageElement) return;
|
||||
const editor = quillRef.current?.getEditor();
|
||||
const maxWidth = editor ? editor.root.clientWidth - 40 : 1200;
|
||||
const finalWidth = Math.min(Math.max(50, Math.round(px)), maxWidth);
|
||||
selectedImageElement.style.width = `${finalWidth}px`;
|
||||
selectedImageElement.style.height = 'auto';
|
||||
selectedImageElement.style.maxWidth = '100%';
|
||||
selectedImageElement.setAttribute('width', String(finalWidth));
|
||||
setImageWidth(finalWidth);
|
||||
setManualWidth(finalWidth.toString());
|
||||
if (editor) {
|
||||
onChangeRef.current(editor.root.innerHTML);
|
||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||
}
|
||||
if (!opts?.silent) {
|
||||
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
|
||||
}
|
||||
}, [selectedImageElement, toast]);
|
||||
|
||||
const resetWidth = useCallback(() => {
|
||||
if (!selectedImageElement) return;
|
||||
const editor = quillRef.current?.getEditor();
|
||||
selectedImageElement.style.width = '';
|
||||
selectedImageElement.style.height = '';
|
||||
selectedImageElement.style.maxWidth = '100%';
|
||||
selectedImageElement.removeAttribute('width');
|
||||
const currentWidth = selectedImageElement.offsetWidth || selectedImageElement.width || 0;
|
||||
setImageWidth(currentWidth);
|
||||
setManualWidth('');
|
||||
if (editor) {
|
||||
onChangeRef.current(editor.root.innerHTML);
|
||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||
}
|
||||
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
|
||||
}, [selectedImageElement, toast]);
|
||||
|
||||
const applyPercent = useCallback((percent: number, opts?: { silent?: boolean }) => {
|
||||
const clamped = Math.max(5, Math.min(100, Math.round(percent)));
|
||||
setWidthPercent(clamped);
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (editor && selectedImageElement) {
|
||||
const px = (editor.root.clientWidth * clamped) / 100;
|
||||
applyWidthPx(px, opts);
|
||||
}
|
||||
}, [applyWidthPx, selectedImageElement]);
|
||||
|
||||
// Set manual width
|
||||
const applyManualWidth = useCallback(() => {
|
||||
if (selectedImageElement && manualWidth) {
|
||||
const width = parseInt(manualWidth);
|
||||
if (!isNaN(width) && width > 0) {
|
||||
const raw = manualWidth.trim();
|
||||
if (raw.endsWith('%')) {
|
||||
const percent = parseFloat(raw.slice(0, -1));
|
||||
const editor = quillRef.current?.getEditor();
|
||||
const maxWidth = editor ? editor.root.clientWidth - 40 : 1200;
|
||||
const finalWidth = Math.min(Math.max(50, width), maxWidth);
|
||||
selectedImageElement.style.width = `${finalWidth}px`;
|
||||
selectedImageElement.style.height = 'auto';
|
||||
selectedImageElement.style.maxWidth = '100%';
|
||||
setImageWidth(finalWidth);
|
||||
setManualWidth(finalWidth.toString());
|
||||
if (editor) {
|
||||
onChangeRef.current(editor.root.innerHTML);
|
||||
// Force overlay reposition
|
||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||
if (editor && !isNaN(percent) && percent > 0) {
|
||||
const px = (editor.root.clientWidth * percent) / 100;
|
||||
applyWidthPx(px);
|
||||
return;
|
||||
}
|
||||
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
|
||||
}
|
||||
const width = parseInt(raw, 10);
|
||||
if (!isNaN(width) && width > 0) {
|
||||
applyWidthPx(width);
|
||||
} else {
|
||||
toast({ title: 'Neplatná šířka', description: 'Zadejte kladné číslo', status: 'warning', duration: 1500 });
|
||||
toast({ title: 'Neplatná šířka', description: 'Zadejte kladné číslo nebo procenta (např. 50%)', status: 'warning', duration: 1500 });
|
||||
}
|
||||
}
|
||||
}, [selectedImageElement, manualWidth, toast]);
|
||||
}, [selectedImageElement, manualWidth, toast, applyWidthPx]);
|
||||
|
||||
// Delete selected image
|
||||
const deleteSelectedImage = useCallback(() => {
|
||||
@@ -1329,7 +1432,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Width Control */}
|
||||
{/* Width Control */
|
||||
}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" fontWeight="semibold" color="gray.600">Šířka obrázku</Text>
|
||||
<HStack spacing={2}>
|
||||
@@ -1351,7 +1455,28 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
Nastavit
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">Aktuální: {imageWidth}px</Text>
|
||||
<Text fontSize="xs" color="gray.500">Aktuální: {imageWidth}px ({widthPercent || 0}%)</Text>
|
||||
<HStack spacing={2}>
|
||||
<Button size="xs" variant="outline" onClick={() => applyPercent(25, { silent: true })}>25%</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => applyPercent(50, { silent: true })}>50%</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => applyPercent(75, { silent: true })}>75%</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => applyPercent(100, { silent: true })}>100%</Button>
|
||||
<Button size="xs" colorScheme="gray" variant="ghost" onClick={resetWidth}>Reset</Button>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Šířka (%)</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{widthPercent || 0}%</Text>
|
||||
</HStack>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="100"
|
||||
value={widthPercent || 0}
|
||||
onChange={(e) => applyPercent(Number(e.target.value), { silent: true })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
|
||||
{/* Transform Buttons */}
|
||||
|
||||
@@ -66,6 +66,12 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
? Math.max(...ratingOptionsSorted.map(o => (o.display_order || 0))) || ratingOptionsSorted.length
|
||||
: 5;
|
||||
const [ratingValue, setRatingValue] = useState<number | null>(null);
|
||||
const selectedStyle = (poll as any).style || 'auto';
|
||||
const resolvedStyle = (() => {
|
||||
if (selectedStyle !== 'auto') return selectedStyle;
|
||||
if (isRating) return maxRating <= 5 ? 'rating-stars' : 'rating-scale';
|
||||
return 'choices-list';
|
||||
})();
|
||||
|
||||
const selectOptionForRating = (value: number) => {
|
||||
setRatingValue(value);
|
||||
@@ -328,132 +334,191 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
|
||||
{isActive && (
|
||||
<>
|
||||
{isRating ? (
|
||||
{resolvedStyle === 'rating-stars' && (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</VStack>
|
||||
) : poll.allow_multiple ? (
|
||||
<CheckboxGroup
|
||||
value={selectedOptions.map(String)}
|
||||
onChange={handleMultipleChoice}
|
||||
>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Vyberte až {poll.max_choices} možností
|
||||
</Text>
|
||||
{poll.options.map((option) => (
|
||||
)}
|
||||
{resolvedStyle === 'rating-scale' && (
|
||||
<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>
|
||||
)}
|
||||
{resolvedStyle === 'choices-chips' && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{poll.allow_multiple && (
|
||||
<Text fontSize="sm" color="gray.500">Vyberte až {poll.max_choices} možností</Text>
|
||||
)}
|
||||
<HStack flexWrap="wrap" spacing={2}>
|
||||
{poll.options.map((option) => {
|
||||
const isSelected = selectedOptions.includes(option.id);
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
variant={isSelected ? 'solid' : 'outline'}
|
||||
colorScheme="blue"
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
>
|
||||
{option.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
{resolvedStyle === 'choices-cards' && (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{poll.options.map((option) => {
|
||||
const isSelected = selectedOptions.includes(option.id);
|
||||
return (
|
||||
<Box
|
||||
key={option.id}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderWidth={isSelected ? '2px' : '1px'}
|
||||
borderColor={isSelected ? 'blue.400' : borderColor}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOptionClick(option.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox value={String(option.id)} onClick={(e) => e.stopPropagation()}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{option.text}</Text>
|
||||
<HStack align="start" spacing={3}>
|
||||
{option.image_url && (
|
||||
<Image src={option.image_url} alt={option.text} boxSize="48px" objectFit="cover" borderRadius="md" />
|
||||
)}
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Text fontWeight="medium">{option.text}</Text>
|
||||
{option.description && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">{option.description}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</CheckboxGroup>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedOptions[0]?.toString() || ''}
|
||||
onChange={handleSingleChoice}
|
||||
>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{poll.options.map((option) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOptionClick(option.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value={String(option.id)} onClick={(e) => e.stopPropagation()}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{option.text}</Text>
|
||||
{option.description && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
{option.player && (
|
||||
<HStack spacing={2}>
|
||||
{option.player.image_url && (
|
||||
<Image
|
||||
src={option.player.image_url}
|
||||
alt={`${option.player.first_name} ${option.player.last_name}`}
|
||||
boxSize="24px"
|
||||
borderRadius="full"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
{resolvedStyle === 'choices-list' && (
|
||||
<>
|
||||
{poll.allow_multiple ? (
|
||||
<CheckboxGroup
|
||||
value={selectedOptions.map(String)}
|
||||
onChange={handleMultipleChoice}
|
||||
>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Vyberte až {poll.max_choices} možností
|
||||
</Text>
|
||||
{poll.options.map((option) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOptionClick(option.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox value={String(option.id)} onClick={(e) => e.stopPropagation()}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{option.text}</Text>
|
||||
{option.description && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
#{option.player.jersey_number} {option.player.first_name}{' '}
|
||||
{option.player.last_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Radio>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</RadioGroup>
|
||||
</VStack>
|
||||
</Checkbox>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</CheckboxGroup>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedOptions[0]?.toString() || ''}
|
||||
onChange={handleSingleChoice}
|
||||
>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{poll.options.map((option) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOptionClick(option.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value={String(option.id)} onClick={(e) => e.stopPropagation()}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{option.text}</Text>
|
||||
{option.description && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
{option.player && (
|
||||
<HStack spacing={2}>
|
||||
{option.player.image_url && (
|
||||
<Image
|
||||
src={option.player.image_url}
|
||||
alt={`${option.player.first_name} ${option.player.last_name}`}
|
||||
boxSize="24px"
|
||||
borderRadius="full"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
#{option.player.jersey_number} {option.player.first_name}{' '}
|
||||
{option.player.last_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Radio>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAuthenticated ? (
|
||||
|
||||
@@ -4,86 +4,121 @@ import { ScoreboardState } from '@/services/scoreboard';
|
||||
|
||||
export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state }) => {
|
||||
const theme = state.theme || 'pill';
|
||||
const isFlipped = !!state.sidesFlipped;
|
||||
const left = {
|
||||
short: (isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(isFlipped ? state.awayName : state.homeName),
|
||||
logo: isFlipped ? state.awayLogo : state.homeLogo,
|
||||
color: (isFlipped ? state.secondaryColor : state.primaryColor) || '#1e3a8a',
|
||||
score: isFlipped ? state.awayScore : state.homeScore,
|
||||
fouls: Math.max(0, Math.min(5, isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
|
||||
name: isFlipped ? state.awayName : state.homeName,
|
||||
};
|
||||
const right = {
|
||||
short: (!isFlipped ? state.awayShort : state.homeShort) || deriveShortLocal(!isFlipped ? state.awayName : state.homeName),
|
||||
logo: !isFlipped ? state.awayLogo : state.homeLogo,
|
||||
color: (!isFlipped ? state.secondaryColor : state.primaryColor) || '#2563eb',
|
||||
score: !isFlipped ? state.awayScore : state.homeScore,
|
||||
fouls: Math.max(0, Math.min(5, !isFlipped ? (state.awayFouls || 0) : (state.homeFouls || 0))),
|
||||
name: !isFlipped ? state.awayName : state.homeName,
|
||||
};
|
||||
const timer = state.timer || '00:00';
|
||||
|
||||
switch (theme) {
|
||||
case 'pill':
|
||||
return (
|
||||
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
|
||||
<SegmentTeam colorA={state.primaryColor} left>
|
||||
{state.homeLogo ? <Image src={state.homeLogo} alt="home" boxSize="16px" objectFit="contain" /> : null}
|
||||
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
|
||||
</SegmentTeam>
|
||||
<SegmentScore>{state.homeScore} – {state.awayScore}</SegmentScore>
|
||||
<SegmentTeam colorA={state.secondaryColor} right>
|
||||
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
|
||||
{state.awayLogo ? <Image src={state.awayLogo} alt="away" boxSize="16px" objectFit="contain" /> : null}
|
||||
</SegmentTeam>
|
||||
</HStack>
|
||||
<Box>
|
||||
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
|
||||
<SegmentScore>{timer}</SegmentScore>
|
||||
<SegmentTeam colorA={left.color} left>
|
||||
{left.logo ? <Image src={left.logo} alt="home" boxSize="16px" objectFit="contain" /> : null}
|
||||
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{left.short}</Text>
|
||||
</SegmentTeam>
|
||||
<SegmentScore>{left.score} – {right.score}</SegmentScore>
|
||||
<SegmentTeam colorA={right.color} right>
|
||||
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{right.short}</Text>
|
||||
{right.logo ? <Image src={right.logo} alt="away" boxSize="16px" objectFit="contain" /> : null}
|
||||
</SegmentTeam>
|
||||
</HStack>
|
||||
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
|
||||
</Box>
|
||||
);
|
||||
case 'classic':
|
||||
case 'var1':
|
||||
return (
|
||||
<HStack spacing={3} bgGradient="linear(to-b, #c8d4dc, #a8b8c4)" px={5} py={3} borderRadius="lg" boxShadow="md" width="max-content">
|
||||
<Box bg="white" color="black" fontWeight="bold" px={3} py={1} borderRadius="md" fontSize="lg">{formatTimer(state.halfLength)}</Box>
|
||||
<Box bg={state.primaryColor || '#34495e'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
|
||||
<Text fontWeight="bold" color="black">{state.homeScore}-{state.awayScore}</Text>
|
||||
<Box bg={state.secondaryColor || '#2c3e50'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
|
||||
</HStack>
|
||||
<Box>
|
||||
<HStack spacing={3} bgGradient="linear(to-b, #c8d4dc, #a8b8c4)" px={5} py={3} borderRadius="lg" boxShadow="md" width="max-content">
|
||||
<Box bg="white" color="black" fontWeight="bold" px={3} py={1} borderRadius="md" fontSize="lg">{timer}</Box>
|
||||
<Box bg={left.color} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{left.short}</Box>
|
||||
<Text fontWeight="bold" color="black">{left.score}-{right.score}</Text>
|
||||
<Box bg={right.color} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{right.short}</Box>
|
||||
</HStack>
|
||||
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
|
||||
</Box>
|
||||
);
|
||||
case 'var2':
|
||||
return (
|
||||
<HStack spacing={0} borderRadius="md" overflow="hidden" boxShadow="md" width="max-content">
|
||||
<Box bgGradient="linear(135deg, #4a5568, #2d3748)" color="white" px={3} py={2} fontWeight="bold">{formatTimer(state.halfLength)}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={3} py={2} fontWeight="bold">{state.homeScore}-{state.awayScore}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
|
||||
</HStack>
|
||||
<Box>
|
||||
<HStack spacing={0} borderRadius="md" overflow="hidden" boxShadow="md" width="max-content">
|
||||
<Box bgGradient="linear(135deg, #4a5568, #2d3748)" color="white" px={3} py={2} fontWeight="bold">{timer}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{left.short}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={3} py={2} fontWeight="bold">{left.score}-{right.score}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{right.short}</Box>
|
||||
</HStack>
|
||||
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
|
||||
</Box>
|
||||
);
|
||||
case 'var3':
|
||||
return (
|
||||
<Box textAlign="center" fontFamily="Poppins, Arial, sans-serif">
|
||||
<HStack spacing={0} justify="center">
|
||||
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
|
||||
<Box position="absolute" left="-8px" top={0} w="6px" h="38px" bg={state.primaryColor || '#ea2212'} />
|
||||
<Text>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
|
||||
<Box position="absolute" left="-8px" top={0} w="6px" h="38px" bg={left.color} />
|
||||
<Text>{left.short}</Text>
|
||||
</Box>
|
||||
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" zIndex={2} boxShadow="0 3px 10px rgba(0,0,0,0.7)">
|
||||
<Text fontWeight="bold">{state.homeScore}-{state.awayScore}</Text>
|
||||
<Text fontWeight="bold">{left.score}-{right.score}</Text>
|
||||
</Box>
|
||||
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
|
||||
<Box position="absolute" right="-8px" top={0} w="6px" h="38px" bg={state.secondaryColor || '#ea2212'} />
|
||||
<Text>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
|
||||
<Box position="absolute" right="-8px" top={0} w="6px" h="38px" bg={right.color} />
|
||||
<Text>{right.short}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<Box mt={2} w="306px" mx="auto" bg="#F6F6F6">
|
||||
<Text>{formatTimer(state.halfLength)}</Text>
|
||||
<Text>{timer}</Text>
|
||||
</Box>
|
||||
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
|
||||
</Box>
|
||||
);
|
||||
case 'var4':
|
||||
return (
|
||||
<Box w="340px" borderWidth="1px" borderRadius="xl" boxShadow="xl" p={4} bg="white" color="gray.900">
|
||||
<HStack>
|
||||
<Text fontWeight="bold">{state.homeName}</Text>
|
||||
<Text ml="auto" fontWeight="extrabold">{state.homeScore}</Text>
|
||||
</HStack>
|
||||
<Box textAlign="center" fontWeight="extrabold" py={1}>VS</Box>
|
||||
<HStack>
|
||||
<Text fontWeight="bold">{state.awayName}</Text>
|
||||
<Text ml="auto" fontWeight="extrabold">{state.awayScore}</Text>
|
||||
</HStack>
|
||||
<HStack justify="flex-end" fontSize="sm" opacity={0.8} pt={2}>
|
||||
<Text>{formatTimer(state.halfLength)}</Text>
|
||||
</HStack>
|
||||
<Box>
|
||||
<Box w="340px" borderWidth="1px" borderRadius="xl" boxShadow="xl" p={4} bg="white" color="gray.900">
|
||||
<HStack>
|
||||
<Text fontWeight="bold">{left.name}</Text>
|
||||
<Text ml="auto" fontWeight="extrabold">{left.score}</Text>
|
||||
</HStack>
|
||||
<Box textAlign="center" fontWeight="extrabold" py={1}>VS</Box>
|
||||
<HStack>
|
||||
<Text fontWeight="bold">{right.name}</Text>
|
||||
<Text ml="auto" fontWeight="extrabold">{right.score}</Text>
|
||||
</HStack>
|
||||
<HStack justify="flex-end" fontSize="sm" opacity={0.8} pt={2}>
|
||||
<Text>{timer}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<HStack spacing={3} bg="gray.900" color="white" px={4} py={3} borderRadius="lg" boxShadow="lg" width="max-content">
|
||||
<Text fontWeight="bold">{state.homeName}</Text>
|
||||
<Text fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
|
||||
<Text fontWeight="bold">{state.awayName}</Text>
|
||||
</HStack>
|
||||
<Box>
|
||||
<HStack spacing={3} bg="gray.900" color="white" px={4} py={3} borderRadius="lg" boxShadow="lg" width="max-content">
|
||||
<Text fontWeight="bold">{left.name}</Text>
|
||||
<Text fontWeight="black">{left.score} : {right.score}</Text>
|
||||
<Text fontWeight="bold">{right.name}</Text>
|
||||
</HStack>
|
||||
<FoulsBar leftCount={left.fouls} rightCount={right.fouls} leftColor={left.color} rightColor={right.color} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -116,8 +151,23 @@ const SegmentScore: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
);
|
||||
};
|
||||
|
||||
const FoulsBar: React.FC<{ leftCount: number; rightCount: number; leftColor: string; rightColor: string }> = ({ leftCount, rightCount, leftColor, rightColor }) => {
|
||||
const Dot: React.FC<{ active: boolean; color: string }> = ({ active, color }) => (
|
||||
<Box w="8px" h="8px" borderRadius="full" bg={active ? color : 'gray.200'} borderWidth={active ? 0 : 1} borderColor="gray.300" />
|
||||
);
|
||||
return (
|
||||
<HStack spacing={6} justify="center" mt={2} width="100%">
|
||||
<HStack spacing={1}>
|
||||
{Array.from({ length: 5 }).map((_, i) => <Dot key={i} active={i < leftCount} color={leftColor} />)}
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
{Array.from({ length: 5 }).map((_, i) => <Dot key={i} active={i < rightCount} color={rightColor} />)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
function formatTimer(halfLength: number): string {
|
||||
// Simple static mm:ss display using half length as baseline; real timer would come from backend
|
||||
const min = Math.max(0, Math.min(halfLength, 99));
|
||||
return `${String(min).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
@@ -76,8 +76,18 @@ const AuthPage: React.FC = () => {
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
// Redirect to original destination or dashboard
|
||||
navigate(from, { replace: true });
|
||||
// Role-based redirect after login
|
||||
const role = String(user?.role || '').toLowerCase();
|
||||
if (role === 'admin') {
|
||||
navigate('/admin', { replace: true });
|
||||
} else if (role === 'editor') {
|
||||
navigate('/admin', { replace: true });
|
||||
} else if (role === 'user') {
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
// Fallback for unknown roles (e.g., fan): go to frontpage
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Přihlášení selhalo',
|
||||
|
||||
@@ -1580,10 +1580,19 @@ const HomePage: React.FC = () => {
|
||||
(matchingStanding.rows && matchingStanding.rows.length > 0)
|
||||
);
|
||||
|
||||
const showNews = isVisible('news', true);
|
||||
const showTable = isVisible('table', true) && hasStandingsForCurrentTab;
|
||||
const variant = showNews && showTable ? undefined : 'standard';
|
||||
if (!showNews && !showTable) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible('news', true) && (
|
||||
<section data-element="news" className="news-list" style={{ marginTop: 32, ...getStyles('news') }}>
|
||||
<section
|
||||
className="standings"
|
||||
data-variant={variant}
|
||||
style={{ marginTop: 32 }}
|
||||
>
|
||||
{showNews && (
|
||||
<section data-element="news" className="news-list" style={{ ...getStyles('news') }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Další aktuality</h3>
|
||||
</div>
|
||||
@@ -1610,12 +1619,8 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isVisible('table', true) && hasStandingsForCurrentTab && (
|
||||
<section
|
||||
data-element="table"
|
||||
className="standings"
|
||||
style={{ marginTop: 32, ...getStyles('table') }}
|
||||
>
|
||||
{showTable && (
|
||||
<div data-element="table" style={{ ...getStyles('table') }}>
|
||||
<div className="table-card">
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
@@ -1699,9 +1704,9 @@ const HomePage: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const OverlayScoreboardPage: React.FC = () => {
|
||||
const { data, isLoading } = useQuery<ScoreboardState>({
|
||||
queryKey: ['public-scoreboard'],
|
||||
queryFn: getPublicScoreboard,
|
||||
refetchInterval: 5000,
|
||||
refetchInterval: 1000,
|
||||
staleTime: 3000,
|
||||
});
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'single',
|
||||
style: 'auto',
|
||||
status: 'draft',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
@@ -191,6 +192,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'single',
|
||||
style: 'auto',
|
||||
status: 'draft',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
@@ -221,6 +223,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
title: 'Hodnocení zápasu',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-stars',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
@@ -239,6 +242,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
title: 'Hodnocení zápasu (1–10)',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 10 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-scale',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
@@ -253,6 +257,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
title: 'Dorazíš na schůzku?',
|
||||
description: 'Dej nám vědět, zda dorazíš.',
|
||||
type: 'single',
|
||||
style: 'choices-chips',
|
||||
status: 'active',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
@@ -276,6 +281,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
type: poll.type,
|
||||
style: (poll as any).style || 'auto',
|
||||
status: poll.status,
|
||||
start_date: poll.start_date,
|
||||
end_date: poll.end_date,
|
||||
@@ -566,7 +572,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<SimpleGrid columns={2} spacing={4} w="full">
|
||||
<SimpleGrid columns={3} spacing={4} w="full">
|
||||
<FormControl>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select
|
||||
@@ -595,6 +601,28 @@ const PollsAdminPage: React.FC = () => {
|
||||
<option value="archived">Archivovaná</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Styl</FormLabel>
|
||||
<Select
|
||||
value={(formData as any).style || 'auto'}
|
||||
onChange={(e) => setFormData({ ...formData, style: e.target.value as any })}
|
||||
>
|
||||
<option value="auto">Automaticky</option>
|
||||
{formData.type === 'rating' ? (
|
||||
<>
|
||||
<option value="rating-stars">Hvězdičky</option>
|
||||
<option value="rating-scale">Číselná stupnice</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="choices-list">Seznam</option>
|
||||
<option value="choices-chips">Štítky</option>
|
||||
<option value="choices-cards">Karty</option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid columns={2} spacing={4} w="full">
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resetTimer,
|
||||
swapSides,
|
||||
startSecondHalf,
|
||||
} from '@/services/scoreboard';
|
||||
import { useFacrApi } from '@/hooks/useFacrApi';
|
||||
import { SearchResult } from '@/services/facr/types';
|
||||
@@ -434,6 +436,18 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Fauly domácích</FormLabel>
|
||||
<NumberInput value={state.homeFouls || 0} min={0} max={5} onChange={async (_, n) => setPartial({ homeFouls: Math.max(0, Math.min(5, Number.isFinite(n) ? n : 0)) })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Fauly hostů</FormLabel>
|
||||
<NumberInput value={state.awayFouls || 0} min={0} max={5} onChange={async (_, n) => setPartial({ awayFouls: Math.max(0, Math.min(5, Number.isFinite(n) ? n : 0)) })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Délka poločasu (min)</FormLabel>
|
||||
<NumberInput value={state.halfLength} min={1} max={60} onChange={async (_, n) => setPartial({ halfLength: Number.isFinite(n) ? n : 45 })}>
|
||||
@@ -448,6 +462,17 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb={0}>Přehodit strany (vizuálně)</FormLabel>
|
||||
<Switch isChecked={!!state.sidesFlipped} onChange={async (e) => setPartial({ sidesFlipped: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Poločas</FormLabel>
|
||||
<Select value={String(state.half || 1)} onChange={async (e) => setPartial({ half: parseInt(e.target.value, 10) || 1 })}>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
@@ -462,6 +487,18 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
<FormLabel>Barva hostů</FormLabel>
|
||||
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>QR interval (minuty)</FormLabel>
|
||||
<NumberInput value={state.qrEvery || 5} min={1} max={120} onChange={async (_, n) => setPartial({ qrEvery: Math.max(1, Number.isFinite(n) ? n : 5) })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>QR délka zobrazení (sekundy)</FormLabel>
|
||||
<NumberInput value={state.qrDuration || 60} min={5} max={600} onChange={async (_, n) => setPartial({ qrDuration: Math.max(5, Number.isFinite(n) ? n : 60) })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my={4} />
|
||||
@@ -528,6 +565,16 @@ const ScoreboardAdminPage: React.FC = () => {
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Reset</Button>
|
||||
<Button onClick={async () => {
|
||||
await swapSides();
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Přehodit strany</Button>
|
||||
<Button colorScheme="purple" onClick={async () => {
|
||||
await startSecondHalf();
|
||||
const s = await getScoreboardState();
|
||||
setState(s);
|
||||
}}>Začít 2. poločas</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack mt={3} spacing={3} align="center">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import api from './api';
|
||||
|
||||
export type PollStyle = 'auto' | 'rating-stars' | 'rating-scale' | 'choices-list' | 'choices-chips' | 'choices-cards';
|
||||
|
||||
export interface Poll {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'single' | 'multiple' | 'rating';
|
||||
style: PollStyle;
|
||||
status: 'draft' | 'active' | 'closed' | 'archived';
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
@@ -101,6 +104,7 @@ export interface CreatePollRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: 'single' | 'multiple' | 'rating';
|
||||
style?: PollStyle;
|
||||
status?: 'draft' | 'active' | 'closed' | 'archived';
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
@@ -129,6 +133,7 @@ export interface UpdatePollRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: 'single' | 'multiple' | 'rating';
|
||||
style?: PollStyle;
|
||||
status?: 'draft' | 'active' | 'closed' | 'archived';
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
|
||||
@@ -14,12 +14,18 @@ export type ScoreboardState = {
|
||||
secondaryColor?: string; // away color
|
||||
homeScore: number;
|
||||
awayScore: number;
|
||||
homeFouls?: number;
|
||||
awayFouls?: number;
|
||||
halfLength: number; // minutes
|
||||
theme: ScoreboardTheme;
|
||||
externalMatchId?: string;
|
||||
active?: boolean;
|
||||
timer?: string; // MM:SS
|
||||
running?: boolean;
|
||||
sidesFlipped?: boolean;
|
||||
half?: number;
|
||||
qrEvery?: number;
|
||||
qrDuration?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: ScoreboardState = {
|
||||
@@ -33,12 +39,18 @@ const DEFAULT_STATE: ScoreboardState = {
|
||||
secondaryColor: '#2563eb',
|
||||
homeScore: 0,
|
||||
awayScore: 0,
|
||||
homeFouls: 0,
|
||||
awayFouls: 0,
|
||||
halfLength: 45,
|
||||
theme: 'pill',
|
||||
externalMatchId: '',
|
||||
active: false,
|
||||
timer: '00:00',
|
||||
running: false,
|
||||
sidesFlipped: false,
|
||||
half: 1,
|
||||
qrEvery: 5,
|
||||
qrDuration: 60,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'scoreboard_state_v1';
|
||||
@@ -121,6 +133,12 @@ export async function pauseTimer(): Promise<void> {
|
||||
export async function resetTimer(): Promise<void> {
|
||||
await api.post('/admin/scoreboard/timer/reset');
|
||||
}
|
||||
export async function swapSides(): Promise<void> {
|
||||
await api.post('/admin/scoreboard/swap-sides');
|
||||
}
|
||||
export async function startSecondHalf(): Promise<void> {
|
||||
await api.post('/admin/scoreboard/second-half');
|
||||
}
|
||||
|
||||
// Utilities
|
||||
export function deriveShort(name?: string): string {
|
||||
@@ -178,12 +196,18 @@ function normalizeFromApi(d: any): Partial<ScoreboardState> {
|
||||
secondaryColor: d.secondaryColor || d.secondary_color || d.SecondaryColor || undefined,
|
||||
homeScore: typeof d.homeScore === 'number' ? d.homeScore : (typeof d.home_score === 'number' ? d.home_score : 0),
|
||||
awayScore: typeof d.awayScore === 'number' ? d.awayScore : (typeof d.away_score === 'number' ? d.away_score : 0),
|
||||
homeFouls: typeof d.homeFouls === 'number' ? d.homeFouls : (typeof d.home_fouls === 'number' ? d.home_fouls : 0),
|
||||
awayFouls: typeof d.awayFouls === 'number' ? d.awayFouls : (typeof d.away_fouls === 'number' ? d.away_fouls : 0),
|
||||
halfLength: typeof d.halfLength === 'number' ? d.halfLength : (typeof d.half_length === 'number' ? d.half_length : 45),
|
||||
theme: (d.theme || 'pill') as any,
|
||||
externalMatchId: d.externalMatchId || d.external_match_id || d.ExternalMatchID || '',
|
||||
active: typeof d.active === 'boolean' ? d.active : undefined,
|
||||
timer: d.timer || d.Timer || '00:00',
|
||||
running: typeof d.running === 'boolean' ? d.running : undefined,
|
||||
sidesFlipped: typeof d.sidesFlipped === 'boolean' ? d.sidesFlipped : (typeof d.sides_flipped === 'boolean' ? d.sides_flipped : undefined),
|
||||
half: typeof d.half === 'number' ? d.half : undefined,
|
||||
qrEvery: typeof d.qrEvery === 'number' ? d.qrEvery : (typeof d.qr_show_every_minutes === 'number' ? d.qr_show_every_minutes : undefined),
|
||||
qrDuration: typeof d.qrDuration === 'number' ? d.qrDuration : (typeof d.qr_show_duration_seconds === 'number' ? d.qr_show_duration_seconds : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,10 +223,16 @@ function toApiPayload(p: Partial<ScoreboardState>) {
|
||||
if (p.secondaryColor !== undefined) out.secondaryColor = p.secondaryColor;
|
||||
if (p.homeScore !== undefined) out.homeScore = p.homeScore;
|
||||
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
|
||||
if (p.homeFouls !== undefined) out.homeFouls = p.homeFouls;
|
||||
if (p.awayFouls !== undefined) out.awayFouls = p.awayFouls;
|
||||
if (p.halfLength !== undefined) out.halfLength = p.halfLength;
|
||||
if (p.theme !== undefined) out.theme = p.theme;
|
||||
if (p.externalMatchId !== undefined) out.externalMatchId = p.externalMatchId;
|
||||
if (p.active !== undefined) out.active = p.active;
|
||||
if (p.timer !== undefined) out.timer = p.timer;
|
||||
if (p.sidesFlipped !== undefined) out.sidesFlipped = p.sidesFlipped;
|
||||
if (p.half !== undefined) out.half = p.half;
|
||||
if (p.qrEvery !== undefined) out.qrEvery = p.qrEvery;
|
||||
if (p.qrDuration !== undefined) out.qrDuration = p.qrDuration;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ func (pc *PollController) CreatePoll(c *gin.Context) {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Style string `json:"style"`
|
||||
Status string `json:"status"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
@@ -166,6 +167,7 @@ func (pc *PollController) CreatePoll(c *gin.Context) {
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Type: input.Type,
|
||||
Style: input.Style,
|
||||
Status: input.Status,
|
||||
StartDate: input.StartDate,
|
||||
EndDate: input.EndDate,
|
||||
@@ -188,6 +190,9 @@ func (pc *PollController) CreatePoll(c *gin.Context) {
|
||||
if poll.Type == "" {
|
||||
poll.Type = "single"
|
||||
}
|
||||
if poll.Style == "" {
|
||||
poll.Style = "auto"
|
||||
}
|
||||
if poll.Status == "" {
|
||||
poll.Status = "draft"
|
||||
}
|
||||
@@ -250,6 +255,7 @@ func (pc *PollController) UpdatePoll(c *gin.Context) {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Type *string `json:"type"`
|
||||
Style *string `json:"style"`
|
||||
Status *string `json:"status"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
@@ -282,6 +288,9 @@ func (pc *PollController) UpdatePoll(c *gin.Context) {
|
||||
if input.Type != nil {
|
||||
poll.Type = *input.Type
|
||||
}
|
||||
if input.Style != nil {
|
||||
poll.Style = *input.Style
|
||||
}
|
||||
if input.Status != nil {
|
||||
poll.Status = *input.Status
|
||||
}
|
||||
|
||||
@@ -144,36 +144,37 @@ func averageHex(img image.Image) string {
|
||||
return fmt.Sprintf("#%02x%02x%02x", r8, g8, b8)
|
||||
}
|
||||
|
||||
// SwapSides swaps home and away team info including names, logos, shorts, scores and colors.
|
||||
// SwapSides toggles visual sides flipping only. It does NOT swap team data.
|
||||
func (c *ScoreboardController) SwapSides(ctx *gin.Context) {
|
||||
s, err := c.getOrCreateSingleton()
|
||||
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
||||
s.HomeName, s.AwayName = s.AwayName, s.HomeName
|
||||
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
|
||||
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
|
||||
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
|
||||
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
|
||||
s.SidesFlipped = !s.SidesFlipped
|
||||
if err := c.DB.Save(s).Error; err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// StartSecondHalf swaps sides, resets the timer to 00:00 and immediately starts it.
|
||||
// StartSecondHalf starts the second half without flipping visual sides and continues timer from end of 1st half.
|
||||
func (c *ScoreboardController) StartSecondHalf(ctx *gin.Context) {
|
||||
s, err := c.getOrCreateSingleton()
|
||||
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
|
||||
// swap first
|
||||
s.HomeName, s.AwayName = s.AwayName, s.HomeName
|
||||
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
|
||||
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
|
||||
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
|
||||
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
|
||||
// reset and start timer for next half
|
||||
// Move to second half and continue from end of first half
|
||||
s.Half = 2
|
||||
// Ensure base elapsed reflects end of first half
|
||||
capFirst := s.HalfLength * 60
|
||||
if capFirst <= 0 { capFirst = 45 * 60 }
|
||||
base := s.ElapsedSeconds
|
||||
if s.Running && s.TimerStartUnix > 0 {
|
||||
now := time.Now().Unix()
|
||||
diff := int(now - s.TimerStartUnix)
|
||||
if diff > base { base = diff }
|
||||
}
|
||||
if base < capFirst { base = capFirst }
|
||||
s.ElapsedSeconds = base
|
||||
s.Timer = formatSeconds(base)
|
||||
s.Running = true
|
||||
s.ElapsedSeconds = 0
|
||||
s.TimerStartUnix = time.Now().Unix()
|
||||
s.Timer = "00:00"
|
||||
s.TimerStartUnix = time.Now().Unix() - int64(base)
|
||||
if err := c.DB.Save(s).Error; err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
|
||||
}
|
||||
@@ -260,6 +261,10 @@ func applyImportedState(imported models.ScoreboardState, c *ScoreboardController
|
||||
if imported.SecondaryColor != "" { s.SecondaryColor = imported.SecondaryColor }
|
||||
s.HomeScore = imported.HomeScore
|
||||
s.AwayScore = imported.AwayScore
|
||||
// fouls with clamping
|
||||
clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v }
|
||||
s.HomeFouls = clamp(imported.HomeFouls)
|
||||
s.AwayFouls = clamp(imported.AwayFouls)
|
||||
if imported.HalfLength > 0 { s.HalfLength = imported.HalfLength }
|
||||
if imported.Theme != "" { s.Theme = imported.Theme }
|
||||
// timer handling
|
||||
@@ -321,9 +326,10 @@ func computeTimer(s models.ScoreboardState) (timer string, running bool) {
|
||||
if diff > 0 { base = diff } else { base = 0 }
|
||||
}
|
||||
}
|
||||
// Cap by half length
|
||||
// Cap by half length; allow up to 2*half when second half is active
|
||||
cap := s.HalfLength * 60
|
||||
if cap <= 0 { cap = 45 * 60 }
|
||||
if s.Half >= 2 { cap = s.HalfLength * 120 }
|
||||
if base >= cap {
|
||||
base = cap
|
||||
running = false
|
||||
@@ -411,6 +417,8 @@ func (c *ScoreboardController) getOrCreateSingleton() (*models.ScoreboardState,
|
||||
Half: 1,
|
||||
QRShowEveryMinutes: 5,
|
||||
QRShowDurationSeconds: 60,
|
||||
HomeFouls: 0,
|
||||
AwayFouls: 0,
|
||||
}
|
||||
if err := c.DB.Create(&s).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -424,6 +432,12 @@ func (c *ScoreboardController) getOrCreateSingleton() (*models.ScoreboardState,
|
||||
if s.Half == 0 { s.Half = 1; changed = true }
|
||||
if s.QRShowEveryMinutes == 0 { s.QRShowEveryMinutes = 5; changed = true }
|
||||
if s.QRShowDurationSeconds == 0 { s.QRShowDurationSeconds = 60; changed = true }
|
||||
// Clamp fouls 0..5 and ensure non-negative
|
||||
clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v }
|
||||
nf := clamp(s.HomeFouls)
|
||||
af := clamp(s.AwayFouls)
|
||||
if s.HomeFouls != nf { s.HomeFouls = nf; changed = true }
|
||||
if s.AwayFouls != af { s.AwayFouls = af; changed = true }
|
||||
if changed { _ = c.DB.Save(&s).Error }
|
||||
return &s, nil
|
||||
}
|
||||
@@ -447,6 +461,8 @@ func (c *ScoreboardController) GetPublic(ctx *gin.Context) {
|
||||
"secondaryColor": s.SecondaryColor,
|
||||
"homeScore": s.HomeScore,
|
||||
"awayScore": s.AwayScore,
|
||||
"homeFouls": s.HomeFouls,
|
||||
"awayFouls": s.AwayFouls,
|
||||
"halfLength": s.HalfLength,
|
||||
"theme": s.Theme,
|
||||
"external_match_id": s.ExternalMatchID,
|
||||
@@ -491,6 +507,8 @@ func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
|
||||
SecondaryColor *string `json:"secondaryColor"`
|
||||
HomeScore *int `json:"homeScore"`
|
||||
AwayScore *int `json:"awayScore"`
|
||||
HomeFouls *int `json:"homeFouls"`
|
||||
AwayFouls *int `json:"awayFouls"`
|
||||
HalfLength *int `json:"halfLength"`
|
||||
Theme *string `json:"theme"`
|
||||
ExternalMatchID *string `json:"externalMatchId"`
|
||||
@@ -523,6 +541,10 @@ func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
|
||||
if payload.SecondaryColor != nil { s.SecondaryColor = *payload.SecondaryColor }
|
||||
if payload.HomeScore != nil { s.HomeScore = *payload.HomeScore }
|
||||
if payload.AwayScore != nil { s.AwayScore = *payload.AwayScore }
|
||||
// Clamp fouls 0..5
|
||||
clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v }
|
||||
if payload.HomeFouls != nil { s.HomeFouls = clamp(*payload.HomeFouls) }
|
||||
if payload.AwayFouls != nil { s.AwayFouls = clamp(*payload.AwayFouls) }
|
||||
if payload.HalfLength != nil { s.HalfLength = *payload.HalfLength }
|
||||
if payload.Theme != nil { s.Theme = *payload.Theme }
|
||||
if payload.ExternalMatchID != nil { s.ExternalMatchID = *payload.ExternalMatchID }
|
||||
|
||||
@@ -9,7 +9,8 @@ type Poll struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Type string `gorm:"size:50;not null;default:'single'" json:"type"` // single, multiple, rating
|
||||
Type string `gorm:"size:50;not null;default:'single'" json:"type"`
|
||||
Style string `gorm:"size:50;not null;default:'auto'" json:"style"`
|
||||
Status string `gorm:"size:20;not null;default:'draft'" json:"status"` // draft, active, closed, archived
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
|
||||
@@ -33,6 +33,9 @@ type ScoreboardState struct {
|
||||
// QR overlay schedule settings
|
||||
QRShowEveryMinutes int `json:"qr_show_every_minutes"`
|
||||
QRShowDurationSeconds int `json:"qr_show_duration_seconds"`
|
||||
// Team fouls (0..5 display dots)
|
||||
HomeFouls int `json:"home_fouls"`
|
||||
AwayFouls int `json:"away_fouls"`
|
||||
}
|
||||
|
||||
func (ScoreboardState) TableName() string { return "scoreboard_states" }
|
||||
|
||||
Reference in New Issue
Block a user