This commit is contained in:
Tomas Dvorak
2025-10-24 14:52:46 +02:00
parent 70ea0c3c91
commit 8a7c292e54
41 changed files with 912 additions and 404 deletions
+12 -12
View File
@@ -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řeskupe 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
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[{"id":1,"created_at":"2025-10-23T11:46:44.420056Z","updated_at":"2025-10-23T11:52:46.171879Z","title":"Rodičovská schůzka mládeže program a pozvánka","description":"\u003ch2\u003eRodičovská schůzka mládeže program a pozvánka\u003c/h2\u003e\u003cp\u003eMilí fanoušci a rodiče našich mládežnických týmů, chystáme pro vás příjemnou a užitečnou rodičovskou schůzku! Tato událost je skvělou příležitostí, jak se seznámit s trenéry, dozvědět se o aktuálních tématech a aktivně se zapojit do života klubu.\u003c/p\u003e\u003ch3\u003eProgram\u003c/h3\u003e\u003cp\u003eNa schůzce se dozvíte o plánovaných akcích, soutěžích a dalších aktivitách, které připravujeme pro naše mladé fotbalisty. Budou také představeny nové projekty, které pomohou rozvíjet talenty našich hráčů.\u003c/p\u003e\u003cp\u003eNezapomeňte, že vaše účast a podpora jsou pro nás neocenitelné. Těšíme se na setkání s vámi a na společně strávený čas.\u003c/p\u003e\u003cp\u003ePřijďte a buďte součástí našich úspěchů!\u003c/p\u003e\u003cp\u003e\u003cbr\u003e\u003c/p\u003e\u003cp\u003e\u003cimg src=\"http://localhost:8080/uploads/processed_1761213995865.jpg\"\u003e\u003c/p\u003e","start_time":"2025-10-31T16:49:00Z","end_time":"2025-10-31T18:49:00Z","location":"108, Kostelany nad Moravou, okres Uherské Hradiště, Zlínský kraj, Střední Morava, 686 01, Česko","type":"meeting","category_name":"Okresní přebor mladší přípravky (4+1)","is_public":true,"created_by_id":1,"created_by":{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"email":"","first_name":"","last_name":"","role":"","IsActive":false},"image_url":"/uploads/2025/10/20251023-114709-461ff3a8f1bafee57c90d0cc5acd6164.jpeg","file_url":"","attachments":[{"id":2,"created_at":"2025-10-23T11:52:46.174646Z","updated_at":"2025-10-23T11:52:46.174646Z","event_id":1,"name":"pdf-test.pdf","url":"/uploads/2025/10/20251023-115214-c6bd2db2ed5a1d456a0ab1c1868f152b.pdf","mime_type":"application/pdf","size":20597}],"youtube_url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs","latitude":49.044086,"longitude":17.4040833}]
[]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:13Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:16Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"lastUpdated":"2025-10-23T20:00:16Z"}
{"lastUpdated":"2025-10-24T12:52:10Z"}
+12 -12
View File
@@ -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"
}
+1 -1
View File
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-10-23T20:00:11Z","last_modified":""}
{"etag":"","fetched_at":"2025-10-24T12:52:10Z","last_modified":""}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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"}
+4 -13
View File
@@ -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"
}
]
+10 -10
View File
@@ -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
View File
@@ -1,4 +1,4 @@
{
"fetched_at": "2025-10-23T14:00:46Z",
"fetched_at": "2025-10-24T12:36:59Z",
"link": ""
}
+115 -115
View File
@@ -103,7 +103,7 @@
"photos_count": 75,
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
"views_count": 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 = '';
+9 -2
View File
@@ -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
+49 -5
View File
@@ -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í (110)',
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 }))
}))}>110</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 */}
+176 -111
View File
@@ -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 {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 {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 {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`;
}
+12 -2
View File
@@ -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',
+16 -11
View File
@@ -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>
);
})()}
+1 -1
View File
@@ -11,7 +11,7 @@ const OverlayScoreboardPage: React.FC = () => {
const { data, isLoading } = useQuery<ScoreboardState>({
queryKey: ['public-scoreboard'],
queryFn: getPublicScoreboard,
refetchInterval: 5000,
refetchInterval: 1000,
staleTime: 3000,
});
+29 -1
View File
@@ -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 (110)',
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">
+5
View File
@@ -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;
+30
View File
@@ -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;
}
+9
View File
@@ -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
}
+40 -18
View File
@@ -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 }
+2 -1
View File
@@ -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"`
+3
View File
@@ -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" }